diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-01 14:48:45 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-01 14:48:45 +0300 |
| commit | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (patch) | |
| tree | ed7dc7e8c6f50d66bde721fae78576aede929f66 /website/src/pages | |
| parent | b54a78aa510b87d1e3b56c619b72eaeb59e4a2a1 (diff) | |
refactor: separate website presentation from runtime
Diffstat (limited to 'website/src/pages')
| -rw-r--r-- | website/src/pages/blog/[slug].astro | 95 | ||||
| -rw-r--r-- | website/src/pages/blog/index.astro | 42 | ||||
| -rw-r--r-- | website/src/pages/compare.astro | 265 | ||||
| -rw-r--r-- | website/src/pages/index.astro | 33 | ||||
| -rw-r--r-- | website/src/pages/og/card-b.html | 60 | ||||
| -rw-r--r-- | website/src/pages/og/card-perf.html | 56 | ||||
| -rw-r--r-- | website/src/pages/og/card-yc.html | 42 | ||||
| -rw-r--r-- | website/src/pages/plugins.astro | 1159 |
8 files changed, 0 insertions, 1752 deletions
diff --git a/website/src/pages/blog/[slug].astro b/website/src/pages/blog/[slug].astro deleted file mode 100644 index 3c33a38c..00000000 --- a/website/src/pages/blog/[slug].astro +++ /dev/null @@ -1,95 +0,0 @@ ---- -import { getCollection, render } from 'astro:content'; -import SiteLayout from '../../components/SiteLayout.astro'; -import AuthorCard from '../../components/AuthorCard.astro'; - -export async function getStaticPaths() { - const posts = await getCollection('blog'); - /* dev only, see blog/index.astro */ - return posts - .filter((post) => import.meta.env.DEV || !post.data.draft) - .map((post) => { - const slug = post.id.replace(/\.(md|mdx)$/i, ''); - return { params: { slug }, props: { post, slug } }; - }); -} - -const { post, slug } = Astro.props; -const { Content, headings } = await render(post); -const sectionHeadings = headings.filter((heading) => heading.depth === 2); ---- - -<SiteLayout - title={`${post.data.title} — Herdr blog`} - description={post.data.description} - canonicalPath={`/blog/${slug}/`} - ogType="article" - ogImage={post.data.ogImage} - ogImageAlt={post.data.ogImage ? post.data.title : undefined} -> - {/* a post with no h2s renders no sidebar, so the grid must stop reserving - a column for it */} - <div class:list={['article', sectionHeadings.length === 0 && 'article-solo']}> - <article class="article-body"> - <p class="article-meta"> - <a href="/blog/">← Blog</a> - <time datetime={post.data.date.toISOString()}> - {post.data.date.toLocaleDateString('en', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - </time> - </p> - <h1>{post.data.title}</h1> - {post.data.lede && post.data.description && ( - <p class="article-lede">{post.data.description}</p> - )} - <hr class="article-rule" /> - <div class="prose"> - <Content /> - </div> - <AuthorCard /> - </article> - - {sectionHeadings.length > 0 && ( - <aside class="toc" aria-label="Article sections"> - <p>Sections</p> - <nav> - {sectionHeadings.map((heading) => ( - <a href={`#${heading.slug}`} data-heading-link={heading.slug}>{heading.text}</a> - ))} - </nav> - </aside> - )} - </div> - - <script> - const headings = Array.from(document.querySelectorAll('.prose h2[id]')); - const links = new Map( - Array.from(document.querySelectorAll('[data-heading-link]')).map((link) => [ - link.getAttribute('data-heading-link'), - link, - ]), - ); - - function setActive(id) { - for (const link of links.values()) link.removeAttribute('aria-current'); - links.get(id)?.setAttribute('aria-current', 'true'); - } - - if (headings.length > 0 && links.size > 0) { - setActive(headings[0].id); - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((entry) => entry.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; - if (visible?.target instanceof HTMLElement) setActive(visible.target.id); - }, - { rootMargin: '-18% 0px -68% 0px', threshold: 0 }, - ); - for (const heading of headings) observer.observe(heading); - } - </script> -</SiteLayout> diff --git a/website/src/pages/blog/index.astro b/website/src/pages/blog/index.astro deleted file mode 100644 index de9349ea..00000000 --- a/website/src/pages/blog/index.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -import { getCollection } from 'astro:content'; -import SiteLayout from '../../components/SiteLayout.astro'; - -/* Drafts render locally and never in a build, so an unpublished post can be - reviewed on the dev server without being one deploy away from going live. */ -const posts = (await getCollection('blog')) - .filter((post) => import.meta.env.DEV || !post.data.draft) - .map((post) => ({ ...post, slug: post.id.replace(/\.(md|mdx)$/i, '') })) - .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); - -const stamp = (date) => - date.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }); ---- - -<SiteLayout - title="Herdr blog" - description="Notes on the agent runtime — product decisions, implementation writeups, and what we learned running agents on machines nobody is watching." - canonicalPath="/blog/" -> - <header class="ph"> - <div class="hd-eyebrow"><s></s>the blog</div> - <h1>Notes from the herd.</h1> - <p> - Product decisions, implementation writeups, and what we learn running agents on - machines nobody is watching. - </p> - </header> - - <div class="posts"> - {posts.map((post) => ( - <a href={`/blog/${post.slug}/`}> - <time datetime={post.data.date.toISOString()}>{stamp(post.data.date)}</time> - <div> - <h2>{post.data.title}</h2> - {post.data.description && <p>{post.data.description}</p>} - </div> - <span class="go-arrow" aria-hidden="true">→</span> - </a> - ))} - </div> -</SiteLayout> diff --git a/website/src/pages/compare.astro b/website/src/pages/compare.astro deleted file mode 100644 index 315d6a1b..00000000 --- a/website/src/pages/compare.astro +++ /dev/null @@ -1,265 +0,0 @@ ---- -import SiteLayout from '../components/SiteLayout.astro'; ---- - -<SiteLayout - title="Compare Herdr vs tmux, Zellij, cmux, Warp & agent managers" - description="Compare Herdr with tmux, Zellij, cmux, Warp, Solo, Conductor, Emdash, and Superset. Multiplexers persist terminals and apps manage agents. Herdr is the runtime agents live on, with clients on top." - canonicalPath="/compare/" -> - <main> - <header class="ph ph-split"> - <div> - <div class="hd-eyebrow"><s></s>compare</div> - <h1>Apps manage the herd. Herdr <em>runs</em> it.</h1> - <p> - Most tools Herdr gets compared to are apps: a window you open to - manage coding agents, and a window the work depends on. Herdr is a - different kind of thing: a runtime. A server holds real terminals - open on the machine, the agents live in those, and every UI, ours - included, is just a client that attaches. Keep that split in mind - and the matrix below mostly explains itself. - </p> - </div> - <aside class="ph-side"> - <b>9</b> - <span>tools compared · one runtime</span> - </aside> - </header> - - <section class="cmp" id="matrix"> - <div class="fighd"> - <span>herdr against the field</span> - <span class="r">scrolls sideways on a phone</span> - </div> - <div class="cmp-scroll"> - <table> - <thead> - <tr> - <th>capability</th> - <th class="h">herdr</th> - <th>tmux · zellij</th> - <th>cmux · warp</th> - <th>solo</th> - <th>conductor · emdash · superset</th> - </tr> - </thead> - <tbody> - <tr> - <th scope="row">Kind of thing</th> - <td class="h">runtime + clients</td> - <td>terminal multiplexer</td> - <td>terminal app</td> - <td>process dashboard</td> - <td>manager app</td> - </tr> - <tr> - <th scope="row">Work survives its own UI closing</th> - <td class="h">yes, the server owns the terminals</td> - <td>yes, detach</td> - <td>session restore</td> - <td>while the app is open</td> - <td>while the app is open</td> - </tr> - <tr> - <th scope="row">Runs inside your existing terminal</th> - <td class="h">yes</td> - <td>yes</td> - <td>replaces it</td> - <td class="no">no, desktop app</td> - <td class="no">no, desktop app</td> - </tr> - <tr> - <th scope="row">Semantic agent state</th> - <td class="h">blocked · working · done · idle</td> - <td class="no">—</td> - <td>attention cues</td> - <td>process status</td> - <td>workspace status</td> - </tr> - <tr> - <th scope="row">Detach, reattach, SSH in</th> - <td class="h">yes, any tty</td> - <td>yes</td> - <td>partial</td> - <td class="no">—</td> - <td>remote projects</td> - </tr> - <tr> - <th scope="row">Direct attach to one agent</th> - <td class="h">yes</td> - <td class="no">—</td> - <td class="no">—</td> - <td class="no">—</td> - <td class="no">—</td> - </tr> - <tr> - <th scope="row">API agents drive themselves</th> - <td class="h">read · send · wait · split · attach</td> - <td>terminal scripting</td> - <td>app APIs</td> - <td>MCP for processes</td> - <td>workflow APIs</td> - </tr> - <tr> - <th scope="row">Worktree and diff review flow</th> - <td class="h">pairs with it</td> - <td class="no">—</td> - <td>partial</td> - <td class="no">—</td> - <td>yes, their core</td> - </tr> - <tr> - <th scope="row">Clients on the same runtime</th> - <td class="h">TUI · CLI · plain SSH, more coming</td> - <td>its own client</td> - <td>the app only</td> - <td>the app only</td> - <td>the app only</td> - </tr> - </tbody> - </table> - </div> - <p class="figcap"> - The columns are categories, not enemies: Herdr pairs happily with a - worktree manager and runs fine inside a fancy terminal. - <b>The row that sorts the field is the second one:</b> what happens to - the agents when the thing you're looking at goes away. - </p> - </section> - - <div class="caps"> - <div class="cap cap-flat"> - <div class="n"><span>01</span></div> - <div class="cap-in"> - <h3>Herdr is the runtime.</h3> - <p> - A server owns the terminals; every UI is a client of it. The TUI in - the screenshots attaches, detaches and even crashes without the - agents noticing. Same for the CLI, plain SSH, and whatever - attaches next. The TUI is the first client, not the last. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>herdr-server</span><b><i class="beat"></i>up</b></div> - <div class="ev-note">4 agents · 0 clients attached · still working</div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>02</span></div> - <div class="cap-in"> - <h3>Multiplexers persist terminals, not agents.</h3> - <p> - tmux gave humans persistence, and Herdr keeps that whole - inheritance: real PTYs, detach, SSH. What it adds is the part - multiplexers never had: knowing which pane is an agent, whether - it's blocked, and how to wait on it instead of polling it. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>tmux ls</span><b>3 sessions</b></div> - <div class="ev-note">which one is waiting on you? no idea</div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>03</span></div> - <div class="cap-in"> - <h3>Terminal apps replace your terminal.</h3> - <p> - cmux and Warp can be genuinely polished, but the deal is that the - work moves into their window, with their renderer, on the machines - they support. Herdr changes nothing about your terminal. It runs - inside it, wherever a terminal and SSH already reach. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>ghostty · kitty · iterm · alacritty</span><b>kept</b></div> - <div class="ev-note">herdr runs in the one you already chose</div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>04</span></div> - <div class="cap-in"> - <h3>Manager apps are clients without a runtime.</h3> - <p> - Conductor, Emdash and Superset manage a herd from a window: - worktrees, diffs, review queues. Useful, and Herdr pairs fine with - that flow. But a window that manages agents is not a place agents - live: quit the app and the herd goes with it. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>their app quits</span><b>agents stop</b></div> - <div class="ev-row"><span>every herdr client quits</span><b>agents keep going</b></div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>05</span></div> - <div class="cap-in"> - <h3>Dashboards watch processes.</h3> - <p> - Solo supervises a dev stack: health, restarts, logs. Herdr's job - is different: interactive agent terminals that stay open, hold - scrollback and state, and stay yours whether or not anything is - watching them. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>process status</span><b>up / down</b></div> - <div class="ev-row"><span>agent state</span><b>blocked · working · idle</b></div> - </div> - </div> - </div> - - <section class="vs"> - <div class="fighd"> - <span>one-liners, for deciding fast</span> - </div> - <div class="vs-list"> - <article> - <b>herdr vs tmux</b> - <p>tmux keeps terminals alive; so does Herdr. The difference is Herdr knows which terminals are agents, what state each one is in, and how to wait on them. tmux sees panes.</p> - </article> - <article> - <b>herdr vs zellij</b> - <p>Zellij is a friendlier workspace for humans in terminals. Herdr is a runtime for agents in terminals: state, waits, direct attach, and an API.</p> - </article> - <article> - <b>herdr vs cmux</b> - <p>cmux is a Mac terminal app built around agents. Herdr isn't an app: it's the runtime underneath, in whatever terminal you already use, reachable over SSH.</p> - </article> - <article> - <b>herdr vs warp</b> - <p>Warp wants to be your terminal and your platform. Herdr keeps your terminal and owns what runs inside it.</p> - </article> - <article> - <b>herdr vs opencode web</b> - <p>opencode's web mode serves one agent over HTTP. Herdr runs the whole herd: any agent CLI, many at once, from any tty.</p> - </article> - <article> - <b>herdr vs solo</b> - <p>Solo is a dashboard that supervises your dev processes. Herdr holds interactive agent terminals open whether or not anything is watching them.</p> - </article> - <article> - <b>herdr vs conductor, emdash, superset</b> - <p>Manager apps put worktrees, diffs and review queues in a window. Herdr is the layer under that kind of window: quit their app and the agents stop; detach every Herdr client and they don't.</p> - </article> - </div> - </section> - - <section class="end"> - <span class="ram"></span> - <div class="in"> - <h2>A runtime, not another window.</h2> - <p> - Install it and nothing about your setup changes: same terminal, same - agent CLIs. They just stop dying when a window does. - </p> - <div class="ph-actions"> - <a class="btn-solid" href="/docs/install/">Install Herdr</a> - <a class="btn-ghost" href="/docs/quick-start/">Quick start</a> - </div> - </div> - </section> - </main> -</SiteLayout> diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro deleted file mode 100644 index 26a048d4..00000000 --- a/website/src/pages/index.astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -import { readFile } from 'node:fs/promises'; -import { agentCount, fmt, releaseVersion, siteStats } from '../site-data'; - -/* The numbers in the nav and hero strip are baked here at build time from the - snapshots and latest.json, so the page ships with real values, renders - correctly without JS, and can never be more than one deploy stale. A small - script on the page refreshes the counts live afterwards. */ -const homepage = await readFile(new URL('../../index.html', import.meta.url), 'utf8'); - -const { starCount, installCount, pluginCount } = await siteStats(); -const version = await releaseVersion(); -const facts = version ? `v${version} · Apache 2.0` : 'Apache 2.0'; - -/* the line drops out entirely rather than showing a placeholder if the - manifests can't be read — a missing sentence beats a wrong number */ -const agents = await agentCount(); -const agentsNote = agents ? `${agents} agents detected out of the box` : ''; -/* the strip carried a hardcoded 19; same source as row 04 now */ -const agentsCount = agents ? String(agents) : '—'; - -/* replaceAll, not replace: the star count appears in the nav as well as the - strip, and replace() would fill only the first of them. */ -const page = homepage - .replaceAll('<!--N_STARS-->', fmt(starCount)) - .replaceAll('<!--N_INSTALLS-->', fmt(installCount)) - .replaceAll('<!--N_PLUGINS-->', fmt(pluginCount)) - .replaceAll('<!--FACTS-->', facts) - .replaceAll('<!--AGENTS_NOTE-->', agentsNote) - .replaceAll('<!--N_AGENTS-->', agentsCount); ---- - -<Fragment set:html={page} /> diff --git a/website/src/pages/og/card-b.html b/website/src/pages/og/card-b.html deleted file mode 100644 index c508778b..00000000 --- a/website/src/pages/og/card-b.html +++ /dev/null @@ -1,60 +0,0 @@ -<!doctype html> -<html lang="en" class="og"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>herdr OG card — source</title> -<meta name="robots" content="noindex" /> -<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&display=swap" rel="stylesheet" /> -<link rel="stylesheet" href="/css/og-card.css?v=3" /> -<style> - /* Source of truth for /assets/og-card-v9.png. - Regenerate: open this page, capture the full document, crop to the - leftmost 1200x630 (the document is pinned to exactly the card size, but - the browser viewport may be wider). Bump the filename when the artwork - changes — Slack, X and LinkedIn cache unfurls by URL for weeks. */ - /* centre the headline in the band below the head rather than in the whole - card, so a four-line headline can't ride up into the lockup */ - .in { display: flex; flex-direction: column; justify-content: center; padding-top: 118px; } - h1 { - font-family: var(--disp); - font-weight: 900; - letter-spacing: -0.055em; - line-height: 0.86; - font-size: 104px; - margin: 30px 0 0; - max-width: 15ch; - color: var(--ink); - } - h1 em { font-style: normal; color: var(--spot); } - /* X, LinkedIn and Slack all draw their own attribution over the bottom of an - unfurl, so the lockup and provenance sit at the top and the bottom band is - left deliberately empty. */ - .head { - position: absolute; - left: 62px; - right: 62px; - top: 48px; - z-index: 1; - display: flex; - align-items: center; - justify-content: space-between; - } - .head .sub { color: var(--faint); font-size: 21px; letter-spacing: 0.1em; } - .head .sub b { color: var(--dim); font-weight: 400; } -</style> -</head> -<body class="og"> -<div class="card"> - <span class="ram mass"></span> - <div class="head"> - <div class="lockup"><span class="ram"></span><b>herdr</b></div> - <div class="sub"><b>herdr.dev</b> · Apache 2.0</div> - </div> - <div class="in pad"> - <div class="eyebrow"><s></s>the agent runtime</div> - <h1>Run them <em>anywhere.</em> Leave them running.</h1> - </div> -</div> -</body> -</html> diff --git a/website/src/pages/og/card-perf.html b/website/src/pages/og/card-perf.html deleted file mode 100644 index e2142252..00000000 --- a/website/src/pages/og/card-perf.html +++ /dev/null @@ -1,56 +0,0 @@ -<!doctype html> -<html lang="en" class="og"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>herdr performance OG card — source</title> -<meta name="robots" content="noindex" /> -<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&display=swap" rel="stylesheet" /> -<link rel="stylesheet" href="/css/og-card.css?v=3" /> -<style> - /* Source of truth for /assets/og-blog-frames-v1.png. Same capture recipe as - card-b: open the page, capture the full document, it is already 1200x630. - - A share card is read at thumbnail size in a timeline, so it carries the - headline and the one measurement that backs it. The caveats live in the - post, where there is room to read them. */ - .head { - position: absolute; left: 62px; right: 62px; top: 48px; z-index: 1; - display: flex; align-items: center; justify-content: space-between; - } - .head .sub { color: var(--faint); font-size: 21px; letter-spacing: 0.1em; } - .head .sub b { color: var(--dim); font-weight: 400; } - - .in { display: flex; flex-direction: column; justify-content: center; padding: 118px 62px 54px; } - h1 { - font-family: var(--disp); font-weight: 900; letter-spacing: -0.055em; - line-height: 0.9; font-size: 96px; margin: 0; color: var(--ink); - } - h1 em { font-style: normal; color: var(--spot); } - - .meas { - display: flex; align-items: baseline; gap: 22px; - margin-top: 42px; padding-top: 22px; border-top: 1px solid var(--line2); - color: var(--faint); font-size: 22px; letter-spacing: 0.04em; - } - .meas b { color: var(--ink); font-weight: 400; } - .meas .r { margin-left: auto; color: var(--dim); } -</style> -</head> -<body class="og"> -<div class="card"> - <span class="ram mass"></span> - <div class="head"> - <div class="lockup"><span class="ram"></span><b>herdr</b></div> - <div class="sub"><b>herdr.dev</b> · Apache 2.0</div> - </div> - <div class="in"> - <h1>Ten agents,<br />three clients,<br /><em>95% less CPU.</em></h1> - <div class="meas"> - <span>total cpu <b>13.014% → 0.617%</b></span> - <span class="r">v0.7.5 → v0.8.0</span> - </div> - </div> -</div> -</body> -</html> diff --git a/website/src/pages/og/card-yc.html b/website/src/pages/og/card-yc.html deleted file mode 100644 index af8c69e7..00000000 --- a/website/src/pages/og/card-yc.html +++ /dev/null @@ -1,42 +0,0 @@ -<!doctype html> -<html lang="en" class="og"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>herdr YC announcement OG card — source</title> -<meta name="robots" content="noindex" /> -<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&display=swap" rel="stylesheet" /> -<link rel="stylesheet" href="/css/og-card.css?v=3" /> -<style> - /* Source of truth for /assets/og-blog-yc-v1.png. Same capture recipe as - card-b: open the page, capture the full document, it is already 1200x630. */ - :root { --spot: #fb651e; } /* YC orange, this card only */ - .head { - position: absolute; left: 62px; right: 62px; top: 48px; z-index: 1; - display: flex; align-items: center; justify-content: space-between; - } - .head .sub { color: var(--faint); font-size: 21px; letter-spacing: 0.1em; } - .head .sub b { color: var(--dim); font-weight: 400; } - - .in { display: flex; flex-direction: column; justify-content: center; padding: 118px 62px 54px; } - /* headline is the post title verbatim */ - h1 { - font-family: var(--disp); font-weight: 900; letter-spacing: -0.055em; - line-height: 0.9; font-size: 92px; margin: 0; color: var(--ink); - } - h1 em { font-style: normal; color: var(--spot); } -</style> -</head> -<body class="og"> -<div class="card"> - <span class="ram mass"></span> - <div class="head"> - <div class="lockup"><span class="ram"></span><b>herdr</b></div> - <div class="sub"><b>herdr.dev</b> · Apache 2.0</div> - </div> - <div class="in"> - <h1>Herdr is joining<br /><em>Y Combinator.</em><br />The runtime stays open.</h1> - </div> -</div> -</body> -</html> diff --git a/website/src/pages/plugins.astro b/website/src/pages/plugins.astro deleted file mode 100644 index 57b189ea..00000000 --- a/website/src/pages/plugins.astro +++ /dev/null @@ -1,1159 +0,0 @@ ---- -import SiteLayout from '../components/SiteLayout.astro'; -import fixture from '../data/plugins-fixture.json'; -const snapshotUrl = 'https://assets.herdr.dev/plugins/index.json'; - -// Seed the page server-side from the public R2 snapshot so `bun run dev` and -// the production build both render real data immediately. If the live fetch -// fails, dev falls back to a local fixture for offline design; production seeds -// empty and lets the client fetch fill it (never fake data). -let liveData = null; -try { - const res = await fetch(snapshotUrl); - if (res.ok) liveData = await res.json(); -} catch { - // ignore: handled by the fallbacks below -} -const initialData = liveData ?? (import.meta.env.DEV ? fixture : null); -const initialIsFixture = !liveData && import.meta.env.DEV; -// A card is a repository; a plugin is a validated manifest inside it. A monorepo -// with three manifests is one card and three plugins, so the two counts differ -// and both are stated. `source.totalCount` is the raw GitHub topic search and -// still includes blacklisted and manifest-less repositories, so it is not used. -const repos = initialData?.plugins ?? []; -const repositoryCount = initialData?.repositoryCount ?? repos.length; -const pluginCount = - initialData?.pluginCount ?? - repos.reduce((n, repo) => n + (repo.manifests?.length || 1), 0); -const apiUrl = import.meta.env.DEV ? null : snapshotUrl; ---- - -<SiteLayout - title="Herdr plugins: community plugin marketplace" - description="Discover community Herdr plugins published on GitHub. An automatic index of public repositories tagged with the herdr-plugin topic." - canonicalPath="/plugins/" -> - <main class="plugins-page"> - <header class="ph ph-split"> - <div> - <div class="hd-eyebrow"><s></s>the marketplace</div> - <h1>Built by the herd.</h1> - <p> - Community plugins published on GitHub, discovered automatically from the - <code>herdr-plugin</code> topic. Install any of them with - <code>herdr plugin install</code>, or publish your own and get listed. - </p> - <p class="plugins-trust"> - <span class="status-dot dot-idle"></span> - Listings aren't reviewed by Herdr, so install at your own discretion. - <a href="/docs/plugins/#trust-and-security">Trust guidance →</a> - </p> - </div> - <aside class="ph-side"> - <b id="plugins-hero-count">{pluginCount.toLocaleString('en-US')}</b> - <span id="plugins-hero-summary"> - plugins across {repositoryCount.toLocaleString('en-US')} repositories - </span> - <a href="/docs/plugins/">Publish yours →</a> - </aside> - </header> - - <div id="plugins-shelves" hidden> - <section class="section plugins-shelf" id="shelf-trending" hidden> - <div class="shelf-head"> - <div> - <h2 class="shelf-title">Trending this week</h2> - <p class="shelf-sub">fastest-growing repositories, relative to their own size</p> - </div> - <button class="shelf-all" type="button" data-sort="trending">see all →</button> - </div> - <div id="trend-grid" class="trend-grid" role="list"></div> - </section> - - <section class="section plugins-shelf" id="shelf-new" hidden> - <div class="shelf-head"> - <div> - <h2 class="shelf-title">New to the herd</h2> - <p class="shelf-sub">fresh projects that joined recently</p> - </div> - <button class="shelf-all" type="button" data-sort="newest">see all →</button> - </div> - <div class="new-rows" role="list"></div> - </section> - </div> - - <section class="section plugins-results"> - <div class="shelf-head plugins-browse-head" id="plugins-browse-head"> - <div> - <h2 class="shelf-title">Browse everything</h2> - <p class="shelf-sub">the full index — search or pick a sort</p> - </div> - </div> - <div class="plugins-controls"> - <div class="plugins-search-wrap"> - <span class="plugins-search-icon" aria-hidden="true">/</span> - <input - id="plugins-search" - type="search" - class="plugins-search" - placeholder="search plugins, repositories, owners, languages…" - autocomplete="off" - spellcheck="false" - aria-label="Search plugins" - /> - </div> - <label class="plugins-sort-wrap"> - <span class="plugins-sort-label">sort</span> - <select id="plugins-sort" class="plugins-sort" aria-label="Sort plugins"> - <option value="popular">Popular</option> - <option value="trending">Trending</option> - <option value="recent">Recently updated</option> - <option value="newest">Newest</option> - <option value="name">Name</option> - </select> - </label> - </div> - <p id="plugins-meta" class="plugins-meta" aria-live="polite"></p> - <div id="plugins-grid" class="plugins-grid" role="list"></div> - <div id="plugins-status" class="plugins-status">Loading plugins…</div> - <button id="plugins-more" class="button button-secondary plugins-more" hidden></button> - </section> - - <section class="section plugins-publish"> - <div class="plugins-publish-card"> - <p class="section-kicker"># publish</p> - <h3>Want your plugin listed?</h3> - <p> - Add the GitHub topic <code>herdr-plugin</code> to a public repository - with a <code>herdr-plugin.toml</code> manifest. The index picks it up - on the next refresh. No submission, no review queue. - </p> - <div class="plugins-publish-actions"> - <a class="button button-primary" href="/docs/marketplace/">How publishing works</a> - <a class="button button-secondary" href="/docs/plugins/">Author a plugin</a> - </div> - </div> - </section> - </main> - - <script define:vars={{ initialData, initialIsFixture, apiUrl }}> - const grid = document.getElementById("plugins-grid"); - const statusEl = document.getElementById("plugins-status"); - const searchInput = document.getElementById("plugins-search"); - const sortSelect = document.getElementById("plugins-sort"); - const metaEl = document.getElementById("plugins-meta"); - const moreButton = document.getElementById("plugins-more"); - const heroCountEl = document.getElementById("plugins-hero-count"); - const heroSummaryEl = document.getElementById("plugins-hero-summary"); - const shelvesWrap = document.getElementById("plugins-shelves"); - const trendingShelf = document.getElementById("shelf-trending"); - const trendGrid = document.getElementById("trend-grid"); - const newShelf = document.getElementById("shelf-new"); - - // 24 fills complete rows in both the 3-column and 2-column grid layouts. - const PAGE_SIZE = 24; - let visibleLimit = PAGE_SIZE; - - // A shelf earns its place: percent growth only counts once a repository - // gained real stars this week, so the long tail (median: 1 star) can't - // flood trending with 1-to-3-star noise, while big repositories must grow - // at their own scale to appear. - const TRENDING_MIN_GAIN = 10; - // "New" means new to everyone: recently indexed AND a recently created - // repository. Established projects that just adopted the topic surface - // through trending instead of burying actual newcomers. - const NEW_SEEN_MAX_DAYS = 14; - const NEW_CREATED_MAX_DAYS = 60; - // Six cards fill complete shelf rows in the 3, 2, and 1 column layouts. - const SHELF_SIZE = 6; - // A shelf with one or two cards reads as a struggling marketplace, which - // is worse than no shelf at all. - const SHELF_MIN_CARDS = 3; - - const LANG_COLORS = { - TypeScript: "#3178c6", - JavaScript: "#f1e05a", - Rust: "#dea584", - Python: "#3572A5", - Go: "#00ADD8", - Lua: "#000080", - Shell: "#89e051", - "C++": "#f34b7d", - Ruby: "#701516", - }; - - // One entry per repository. Its `manifests` are the plugins it ships. - let repos = []; - let usingFallback = false; - - function timeAgo(iso) { - if (!iso) return null; - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return null; - const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); - const units = [ - ["y", 31536000], - ["mo", 2592000], - ["w", 604800], - ["d", 86400], - ["h", 3600], - ["m", 60], - ]; - for (const [label, size] of units) { - const v = Math.floor(secs / size); - if (v >= 1) return v + label + " ago"; - } - return "just now"; - } - - function isGithubUrl(url) { - return typeof url === "string" && /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(url); - } - - function daysSince(iso) { - if (!iso) return null; - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return null; - return (Date.now() - then) / 86400000; - } - - function trendingScore(p) { - const delta = p.starsDelta7d; - if (typeof delta !== "number" || delta < TRENDING_MIN_GAIN) return null; - return delta / Math.max((p.stars ?? 0) - delta, 1); - } - - function isNewToHerd(p) { - const seen = daysSince(p.firstSeenAt); - const created = daysSince(p.createdAt); - return ( - seen !== null && seen <= NEW_SEEN_MAX_DAYS && - created !== null && created <= NEW_CREATED_MAX_DAYS - ); - } - - function trendingBadge(p) { - const delta = p.starsDelta7d ?? 0; - const base = Math.max((p.stars ?? 0) - delta, 0); - return "▲ " + base + " → " + (p.stars ?? 0) + " ★ this week"; - } - - function joinedBadge(p) { - const ago = timeAgo(p.firstSeenAt); - return ago === "just now" ? "✦ just joined" : ago ? "✦ joined " + ago : null; - } - - function avatar(p, size) { - const img = el("img", "plugin-avatar"); - img.src = "https://github.com/" + encodeURIComponent(p.owner || "") + ".png?size=" + size; - img.alt = ""; - img.loading = "lazy"; - img.width = size / 2; - img.height = size / 2; - return img; - } - - function cardShell(p, className) { - const url = isGithubUrl(p.url) ? p.url : null; - const card = el(url ? "a" : "div", className); - card.setAttribute("role", "listitem"); - if (url) { - card.href = url; - card.target = "_blank"; - card.rel = "noopener noreferrer"; - } - return card; - } - - function titleOf(p) { - const title = el("div", "plugin-title"); - title.appendChild(el("span", "plugin-owner", (p.owner || "?") + "/")); - title.appendChild(el("span", "plugin-name", p.name || "unknown")); - return title; - } - - function buildTrendCard(p) { - const card = cardShell(p, "plugin-card plugin-trend-card"); - const head = el("div", "plugin-card-head"); - const lead = el("div", "trend-lead"); - lead.appendChild(avatar(p, 64)); - lead.appendChild(titleOf(p)); - head.appendChild(lead); - head.appendChild(el("span", "plugin-go", "↗")); - card.appendChild(head); - card.appendChild(el("div", "plugin-badge", trendingBadge(p))); - const desc = el( - "p", - "plugin-desc" + (p.description ? "" : " is-empty"), - p.description || "No description provided.", - ); - card.appendChild(desc); - return card; - } - - function buildRow(p, note) { - const row = cardShell(p, "plugin-row"); - row.appendChild(avatar(p, 64)); - const body = el("div", "plugin-row-body"); - body.appendChild(titleOf(p)); - const desc = el( - "span", - "plugin-row-desc" + (p.description ? "" : " is-empty"), - p.description || "No description provided.", - ); - body.appendChild(desc); - row.appendChild(body); - const side = el("div", "plugin-row-side"); - if (note) side.appendChild(el("span", "plugin-row-note", note)); - const stars = el("span", "plugin-stat"); - stars.appendChild(el("span", "plugin-star", "★")); - stars.appendChild(el("span", null, String(p.stars ?? 0))); - side.appendChild(stars); - row.appendChild(side); - return row; - } - - function el(tag, className, text) { - const node = document.createElement(tag); - if (className) node.className = className; - if (text != null) node.textContent = text; - return node; - } - - function buildCard(p, badge) { - const url = isGithubUrl(p.url) ? p.url : null; - const card = el(url ? "a" : "div", "plugin-card"); - card.setAttribute("role", "listitem"); - if (url) { - card.href = url; - card.target = "_blank"; - card.rel = "noopener noreferrer"; - } - - const head = el("div", "plugin-card-head"); - const lead = el("div", "trend-lead"); - lead.appendChild(avatar(p, 64)); - lead.appendChild(titleOf(p)); - head.appendChild(lead); - head.appendChild(el("span", "plugin-go", "↗")); - card.appendChild(head); - if (badge) card.appendChild(el("div", "plugin-badge", badge)); - - const desc = el( - "p", - "plugin-desc" + (p.description ? "" : " is-empty"), - p.description || "No description provided.", - ); - card.appendChild(desc); - - const meta = el("div", "plugin-meta-row"); - - const stars = el("span", "plugin-stat"); - stars.appendChild(el("span", "plugin-star", "★")); - stars.appendChild(el("span", null, String(p.stars ?? 0))); - meta.appendChild(stars); - - if (p.language) { - const lang = el("span", "plugin-stat"); - const dot = el("span", "plugin-lang-dot"); - dot.style.background = LANG_COLORS[p.language] || "var(--muted)"; - lang.appendChild(dot); - lang.appendChild(el("span", null, p.language)); - meta.appendChild(lang); - } - - // Nearly every repository ships exactly one plugin whose name restates the - // repository name, so only its version earns a place in the meta row. - const manifests = manifestsOf(p); - if (manifests.length === 1 && manifests[0].version) { - meta.appendChild(el("span", "plugin-stat plugin-version", "v" + manifests[0].version)); - } - - const updated = timeAgo(p.pushedAt); - if (updated) meta.appendChild(el("span", "plugin-stat plugin-updated", "updated " + updated)); - - card.appendChild(meta); - - // A monorepo is the case the card title can't answer on its own, so it - // gets the extra row: how many plugins, and which. - if (manifests.length > 1) { - const ships = el("div", "plugin-ships"); - ships.appendChild(el("span", "plugin-ship-count", manifests.length + " plugins")); - ships.appendChild( - el("span", "plugin-ship-list", manifests.map(manifestLabel).join(" · ")), - ); - card.appendChild(ships); - } - - return card; - } - - function manifestsOf(p) { - return Array.isArray(p.manifests) ? p.manifests : []; - } - - function manifestLabel(m) { - return m.name || m.id || m.path || "plugin"; - } - - function matches(p, q) { - if (!q) return true; - const hay = [ - p.fullName, - p.owner, - p.name, - p.description, - p.language, - ...(p.topics || []), - ...manifestsOf(p).flatMap((m) => [m.name, m.id, m.description]), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return q.split(/\s+/).every((term) => hay.includes(term)); - } - - function sortRepos(list, mode) { - const by = [...list]; - const t = (v) => new Date(v || 0).getTime() || 0; - if (mode === "recent") by.sort((a, b) => t(b.pushedAt) - t(a.pushedAt)); - // "Newest" means newest to the marketplace, matching the shelf's order; - // GitHub's createdAt is the fallback for snapshots without firstSeenAt. - else if (mode === "newest") - by.sort( - (a, b) => t(b.firstSeenAt ?? b.createdAt) - t(a.firstSeenAt ?? a.createdAt), - ); - else if (mode === "name") by.sort((a, b) => (a.fullName || "").localeCompare(b.fullName || "")); - else if (mode === "trending") - // Qualified movers first by growth rate, then everything else by raw - // weekly gain so the list stays fully ordered past the shelf criteria. - by.sort( - (a, b) => - (trendingScore(b) ?? -1) - (trendingScore(a) ?? -1) || - (b.starsDelta7d ?? 0) - (a.starsDelta7d ?? 0) || - (b.stars ?? 0) - (a.stars ?? 0), - ); - else - by.sort( - (a, b) => (b.stars ?? 0) - (a.stars ?? 0) || t(b.pushedAt) - t(a.pushedAt), - ); - return by; - } - - function renderShelves() { - const trending = repos - .map((p) => [p, trendingScore(p)]) - .filter(([, score]) => score !== null) - .sort((a, b) => b[1] - a[1]) - .slice(0, SHELF_SIZE) - .map(([p]) => p); - trendGrid.replaceChildren(); - if (trending.length >= SHELF_MIN_CARDS) { - for (const p of trending) trendGrid.appendChild(buildTrendCard(p)); - } - trendingShelf.hidden = trending.length < SHELF_MIN_CARDS; - - const fresh = repos - .filter(isNewToHerd) - .sort((a, b) => new Date(b.firstSeenAt).getTime() - new Date(a.firstSeenAt).getTime()) - .slice(0, SHELF_SIZE); - const newRows = newShelf.querySelector(".new-rows"); - newRows.replaceChildren(); - for (const p of fresh) newRows.appendChild(buildRow(p, joinedBadge(p))); - newShelf.hidden = fresh.length < SHELF_MIN_CARDS; - - const anyShelf = !trendingShelf.hidden || !newShelf.hidden; - shelvesWrap.dataset.available = anyShelf ? "1" : ""; - updateShelfVisibility(); - } - - function updateShelfVisibility() { - shelvesWrap.hidden = shelvesWrap.dataset.available !== "1"; - } - - function render() { - const q = searchInput.value.trim().toLowerCase(); - const visible = sortRepos(repos.filter((p) => matches(p, q)), sortSelect.value); - const shown = visible.slice(0, visibleLimit); - - grid.replaceChildren(); - for (const p of shown) grid.appendChild(buildCard(p)); - - // Grow a page at a time, and name the real remainder on the last step so - // the button never promises 24 and delivers 5. - const remaining = visible.length - shown.length; - moreButton.hidden = remaining === 0; - if (remaining > 0) { - moreButton.textContent = "show " + Math.min(PAGE_SIZE, remaining) + " more"; - } - - if (visible.length === 0) { - statusEl.textContent = q - ? "No plugins match your search." - : "No plugins are listed yet."; - statusEl.hidden = false; - } else { - statusEl.hidden = true; - } - } - - function setMeta(data) { - const parts = []; - const repoCount = (data && data.repositoryCount) ?? repos.length; - const total = - (data && data.pluginCount) ?? - repos.reduce((n, repo) => n + (manifestsOf(repo).length || 1), 0); - parts.push(total + (total === 1 ? " plugin" : " plugins")); - parts.push(repoCount + (repoCount === 1 ? " repository" : " repositories")); - heroCountEl.textContent = total.toLocaleString("en-US"); - heroSummaryEl.textContent = - (total === 1 ? "plugin" : "plugins") + - " across " + - repoCount.toLocaleString("en-US") + - (repoCount === 1 ? " repository" : " repositories"); - const starsGained = repos.reduce( - (n, repo) => n + Math.max(repo.starsDelta7d ?? 0, 0), - 0, - ); - if (starsGained > 0) parts.push("+" + starsGained.toLocaleString("en-US") + " ★ this week"); - const joined = repos.filter((repo) => { - const seen = daysSince(repo.firstSeenAt); - return seen !== null && seen <= 7; - }).length; - if (joined > 0) parts.push(joined + " joined this week"); - const ago = timeAgo(data && data.generatedAt); - if (ago) parts.push("updated " + ago); - if (usingFallback) parts.push("preview data"); - metaEl.textContent = parts.join(" · "); - } - - function apply(data) { - repos = Array.isArray(data && data.plugins) ? data.plugins : []; - setMeta(data); - renderShelves(); - render(); - } - - function showError() { - repos = []; - metaEl.textContent = ""; - grid.replaceChildren(); - moreButton.hidden = true; - statusEl.textContent = - "The plugin marketplace is warming up. Check back in a few minutes."; - statusEl.hidden = false; - } - - async function load() { - // Render the server-seeded data immediately (live in prod and dev; fixture - // only as a dev offline fallback). - if (initialData) { - usingFallback = initialIsFixture; - apply(initialData); - } - // Refresh from the same-origin function in production for the freshest snapshot. - if (apiUrl) { - try { - const res = await fetch(apiUrl, { headers: { accept: "application/json" } }); - if (!res.ok) throw new Error("status " + res.status); - usingFallback = false; - apply(await res.json()); - } catch (err) { - if (!initialData) showError(); - } - } else if (!initialData) { - showError(); - } - } - - // A new query or order is a new list, so paging starts over. - function resetAndRender() { - visibleLimit = PAGE_SIZE; - render(); - } - - searchInput.addEventListener("input", resetAndRender); - sortSelect.addEventListener("change", resetAndRender); - moreButton.addEventListener("click", () => { - visibleLimit += PAGE_SIZE; - render(); - }); - for (const button of document.querySelectorAll(".shelf-all")) { - button.addEventListener("click", () => { - sortSelect.value = button.dataset.sort; - resetAndRender(); - grid.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - } - load(); - </script> - - <style is:global> - .plugins-page { - padding-bottom: 5rem; - --plugins-clear-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M1.5 1.5l9 9M10.5 1.5l-9 9' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); - } - - /* Reuse .section only for max-width centering, not its 5rem padding + top divider. */ - .plugins-page .section { - padding-block: 0; - border-top: none; - } - - /* Everything below the hero sits inside the same horizontal gutter the - hero uses, so text never touches the page's column lines. Selectors - outrank site.css's `body.site .plugins-page .section` padding reset. */ - .plugins-page #plugins-shelves, - body.site .plugins-page .section.plugins-results, - body.site .plugins-page .section.plugins-publish { - padding-inline: var(--gut); - } - - .plugins-page .plugins-hero { - padding-top: 3rem; - } - - .plugins-hero h2 { - margin: 0; - color: var(--ink); - font-family: var(--mono); - font-weight: 400; - letter-spacing: -0.04em; - line-height: 1.05; - font-size: clamp(2rem, 4vw, 3rem); - } - - .plugins-lede { - max-width: 640px; - margin: 1.1rem 0 0; - color: var(--ink-soft); - font-size: clamp(1rem, 1.6vw, 1.15rem); - line-height: 1.6; - } - - .plugins-lede code, - .plugins-trust code, - .plugins-publish-card code { - font-family: var(--mono); - font-size: 0.85em; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-sm); - padding: 0.1em 0.35em; - white-space: nowrap; - } - - .plugins-trust { - max-width: 640px; - margin: 1.1rem 0 0; - color: var(--muted); - font-size: 0.85rem; - line-height: 1.55; - } - - .plugins-trust .status-dot { - display: inline-block; - margin-right: 0.4rem; - vertical-align: middle; - } - - .plugins-trust a { - color: var(--accent); - text-decoration: underline; - text-underline-offset: 2px; - } - - /* Controls */ - .plugins-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; - margin-top: 1.5rem; - } - - .plugins-search-wrap { - position: relative; - flex: 1 1 18rem; - display: flex; - align-items: center; - } - - .plugins-search-icon { - position: absolute; - left: 0.7rem; - color: var(--muted-2); - font-family: var(--mono); - font-size: 0.9rem; - pointer-events: none; - } - - .plugins-search { - width: 100%; - padding: 0.6rem 0.8rem 0.6rem 1.7rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-md); - color: var(--ink); - font-family: var(--mono); - font-size: 0.85rem; - } - - .plugins-search:focus { - outline: none; - border-color: var(--accent); - } - - /* Chromium paints the native clear button in the OS accent colour, which - belongs to no ground the site defines. Redraw it from our own tokens. */ - .plugins-search::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; - width: 0.7rem; - height: 0.7rem; - cursor: pointer; - background: var(--muted-2); - -webkit-mask: var(--plugins-clear-icon) center / contain no-repeat; - mask: var(--plugins-clear-icon) center / contain no-repeat; - } - - .plugins-search::-webkit-search-cancel-button:hover { - background: var(--ink); - } - - .plugins-sort-wrap { - display: inline-flex; - align-items: center; - gap: 0.5rem; - } - - .plugins-sort-label { - color: var(--muted); - font-family: var(--mono); - font-size: 0.78rem; - text-transform: lowercase; - letter-spacing: 0.06em; - } - - .plugins-sort { - padding: 0.55rem 0.7rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-md); - color: var(--ink); - font-family: var(--mono); - font-size: 0.82rem; - cursor: pointer; - } - - .plugins-sort:focus { - outline: none; - border-color: var(--accent); - } - - .plugins-meta { - margin: 1rem 0 0; - color: var(--muted); - font-family: var(--mono); - font-size: 0.78rem; - letter-spacing: 0.04em; - } - - /* Shelves */ - #plugins-shelves { - margin-top: 3.5rem; - } - - body.site .plugins-page .section.plugins-shelf { - margin-top: 4.5rem; - } - - body.site .plugins-page #plugins-shelves > .section.plugins-shelf:first-child { - margin-top: 0; - } - - .shelf-head { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1.25rem; - } - - /* Outranks style.css's `.section h2` mono heading rule. */ - body.site .plugins-page h2.shelf-title { - margin: 0; - color: var(--ink); - font-family: var(--disp); - font-weight: 800; - font-size: 21px; - letter-spacing: -0.03em; - line-height: 1.2; - } - - .shelf-sub { - margin: 0.35rem 0 0; - color: var(--muted); - font-size: 0.82rem; - } - - .shelf-all { - flex: 0 0 auto; - padding: 0; - background: none; - border: none; - color: var(--accent); - font-family: var(--mono); - font-size: 0.82rem; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 3px; - } - - .shelf-all:hover { - color: var(--ink); - } - - .plugin-badge { - align-self: flex-start; - padding: 0.15rem 0.5rem; - background: color-mix(in srgb, var(--accent) 10%, transparent); - border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); - border-radius: var(--radius-sm); - color: var(--accent); - font-family: var(--mono); - font-size: 0.74rem; - letter-spacing: 0.02em; - white-space: nowrap; - } - - .plugin-avatar { - width: 1.5rem; - height: 1.5rem; - flex: 0 0 auto; - border-radius: var(--radius-sm); - background: var(--bg); - border: 1px solid var(--line); - } - - .trend-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.85rem; - } - - .plugin-trend-card { - min-height: 0; - gap: 0.65rem; - padding: 1.1rem 1.2rem; - } - - .trend-lead { - display: flex; - align-items: center; - gap: 0.6rem; - min-width: 0; - } - - .new-rows { - display: flex; - flex-direction: column; - } - - .plugin-row { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.65rem 0.5rem; - border-top: 1px solid var(--line); - border-radius: var(--radius-sm); - transition: background 0.12s ease; - min-width: 0; - } - - .plugin-row:last-child { - border-bottom: 1px solid var(--line); - } - - a.plugin-row:hover { - background: var(--bg-elevated); - } - - .plugin-row-body { - display: flex; - flex-direction: column; - gap: 0.1rem; - min-width: 0; - } - - .plugin-row-body .plugin-title { - font-size: 0.85rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .plugin-row-desc { - color: var(--muted); - font-size: 0.78rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .plugin-row-desc.is-empty { - color: var(--muted-2); - font-style: italic; - } - - .plugin-row-side { - display: flex; - align-items: center; - gap: 0.75rem; - margin-left: auto; - flex: 0 0 auto; - color: var(--muted); - font-family: var(--mono); - font-size: 0.75rem; - } - - .plugin-row-note { - color: var(--accent); - white-space: nowrap; - } - - .plugins-browse-head { - margin-bottom: 1rem; - padding-top: 2.25rem; - border-top: 1px solid var(--line); - } - - .plugins-browse-head[hidden] { - display: none; - } - - @media (max-width: 56rem) { - .trend-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - @media (max-width: 38rem) { - .trend-grid { - grid-template-columns: minmax(0, 1fr); - } - } - - /* Grid */ - body.site .plugins-page .section.plugins-results { - margin-top: 4.5rem; - } - - .plugins-results .plugins-grid { - margin-top: 1.5rem; - } - - .plugins-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); - gap: 0.85rem; - } - - .plugin-card { - display: flex; - flex-direction: column; - gap: 0.7rem; - padding: 1.25rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-md); - transition: border-color 0.12s ease, transform 0.12s ease; - min-height: 8.5rem; - } - - a.plugin-card:hover { - border-color: var(--accent); - transform: translateY(-2px); - } - - .plugin-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.5rem; - } - - .plugin-title { - font-family: var(--mono); - font-size: 0.95rem; - line-height: 1.3; - word-break: break-word; - } - - .plugin-owner { - color: var(--muted); - } - - .plugin-name { - color: var(--ink); - font-weight: 600; - } - - .plugin-go { - color: var(--muted-2); - font-family: var(--mono); - flex: 0 0 auto; - transition: color 0.12s ease; - } - - a.plugin-card:hover .plugin-go { - color: var(--accent); - } - - .plugin-desc { - margin: 0; - color: var(--ink-soft); - font-size: 0.88rem; - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .plugin-desc.is-empty { - color: var(--muted-2); - font-style: italic; - } - - .plugin-meta-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.9rem; - margin-top: auto; - color: var(--muted); - font-family: var(--mono); - font-size: 0.78rem; - } - - .plugin-stat { - display: inline-flex; - align-items: center; - gap: 0.35rem; - } - - .plugin-star { - color: var(--yellow, #d4a72c); - } - - .plugin-lang-dot { - width: 0.6rem; - height: 0.6rem; - border-radius: 50%; - flex: 0 0 auto; - } - - .plugin-updated { - margin-left: auto; - color: var(--muted-2); - } - - /* What the repository ships. Sits below the repo stats, separated by a rule, - so the repository stays the primary identity of the card. */ - .plugin-ships { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.45rem; - padding-top: 0.7rem; - border-top: 1px solid var(--line); - font-family: var(--mono); - font-size: 0.78rem; - line-height: 1.5; - min-width: 0; - } - - .plugin-version { - color: var(--muted-2); - } - - .plugin-ship-count { - flex: 0 0 auto; - padding: 0.05rem 0.4rem; - border: 1px solid var(--line); - border-radius: var(--radius-sm); - color: var(--muted-2); - } - - .plugin-ship-list { - align-self: stretch; - min-width: 0; - color: var(--muted); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .plugins-status { - padding: 3rem 1rem; - text-align: center; - color: var(--muted); - font-family: var(--mono); - font-size: 0.85rem; - } - - .plugins-more { - display: flex; - margin: 2rem auto 0; - cursor: pointer; - } - - .plugins-more[hidden] { - display: none; - } - - /* Publish */ - .plugins-publish { - margin-top: 3.5rem; - } - - .plugins-publish-card { - padding: 2rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-lg); - } - - .plugins-publish-card h3 { - margin: 0.25rem 0 0; - color: var(--ink); - font-family: var(--mono); - font-weight: 400; - font-size: 1.4rem; - letter-spacing: -0.02em; - } - - .plugins-publish-card p { - max-width: 620px; - margin: 0.8rem 0 0; - color: var(--ink-soft); - line-height: 1.6; - } - - .plugins-publish-actions { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - margin-top: 1.5rem; - } - - @media (max-width: 38rem) { - .plugins-meta { - margin-left: 0; - width: 100%; - } - } - </style> -</SiteLayout> |
