aboutsummaryrefslogtreecommitdiff
path: root/website/src/pages
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-09-01 14:48:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-09-01 14:48:45 +0300
commit8a6d697308162874a236c84aac0b0f8c7ac01f83 (patch)
treeed7dc7e8c6f50d66bde721fae78576aede929f66 /website/src/pages
parentb54a78aa510b87d1e3b56c619b72eaeb59e4a2a1 (diff)
refactor: separate website presentation from runtime
Diffstat (limited to 'website/src/pages')
-rw-r--r--website/src/pages/blog/[slug].astro95
-rw-r--r--website/src/pages/blog/index.astro42
-rw-r--r--website/src/pages/compare.astro265
-rw-r--r--website/src/pages/index.astro33
-rw-r--r--website/src/pages/og/card-b.html60
-rw-r--r--website/src/pages/og/card-perf.html56
-rw-r--r--website/src/pages/og/card-yc.html42
-rw-r--r--website/src/pages/plugins.astro1159
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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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>