aboutsummaryrefslogtreecommitdiff
path: root/website/src
diff options
context:
space:
mode:
Diffstat (limited to 'website/src')
-rw-r--r--website/src/components/AuthorCard.astro2
-rw-r--r--website/src/components/MarketingLayout.astro6
-rw-r--r--website/src/components/SiteLayout.astro219
-rw-r--r--website/src/content.config.ts3
-rw-r--r--website/src/content/blog/coding-agents-are-becoming-runtimes.md1
-rw-r--r--website/src/content/blog/live-updates-without-killing-your-terminal-processes.md1
-rw-r--r--website/src/content/blog/ten-agents-three-clients-95-percent-less-cpu.md163
-rw-r--r--website/src/data/plugins-fixture.json124
-rw-r--r--website/src/pages/blog/[slug].astro69
-rw-r--r--website/src/pages/blog/index.astro56
-rw-r--r--website/src/pages/compare.astro8
-rw-r--r--website/src/pages/index.astro28
-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/plugins.astro189
-rw-r--r--website/src/pages/stats.astro270
-rw-r--r--website/src/scripts/stats-page.ts742
-rw-r--r--website/src/site-data.ts103
-rw-r--r--website/src/styles/starlight.css105
19 files changed, 1037 insertions, 1168 deletions
diff --git a/website/src/components/AuthorCard.astro b/website/src/components/AuthorCard.astro
index 75eb9f7e..d5da3abe 100644
--- a/website/src/components/AuthorCard.astro
+++ b/website/src/components/AuthorCard.astro
@@ -3,7 +3,7 @@ const name = 'Can Celik';
const role = 'Building Herdr';
const photo = '/assets/author-can.jpeg';
const x = 'https://x.com/lumendriada';
-const email = 'ogulcancelik@gmail.com';
+const email = 'can@herdr.dev';
---
<aside class="author-card" aria-label="About the author">
diff --git a/website/src/components/MarketingLayout.astro b/website/src/components/MarketingLayout.astro
index 0ea30131..a536ec47 100644
--- a/website/src/components/MarketingLayout.astro
+++ b/website/src/components/MarketingLayout.astro
@@ -13,8 +13,8 @@ const {
description,
canonicalPath,
ogType = 'website',
- ogImage = '/assets/og-card-v8.png',
- ogImageAlt = 'Herdr — One terminal. The whole herd.',
+ ogImage = '/assets/og-card-v9.png',
+ ogImageAlt = 'Herdr — run them anywhere, leave them running.',
} = Astro.props;
const canonicalUrl = new URL(canonicalPath, 'https://herdr.dev').toString();
const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
@@ -77,7 +77,6 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
<a href="/">Home</a>
<a href="/compare/">Compare</a>
<a href="/plugins/">Plugins</a>
- <a href="/stats/">Stats</a>
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
@@ -90,7 +89,6 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
<a href="/">Home</a>
<a href="/compare/">Compare</a>
<a href="/plugins/">Plugins</a>
- <a href="/stats/">Stats</a>
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
diff --git a/website/src/components/SiteLayout.astro b/website/src/components/SiteLayout.astro
new file mode 100644
index 00000000..be42efe9
--- /dev/null
+++ b/website/src/components/SiteLayout.astro
@@ -0,0 +1,219 @@
+---
+/* The chassis every non-docs page shares: one dark ground, the framed grid,
+ the same nav and footer as the homepage. Pages supply their own <main>.
+ Docs keep Starlight's shell and are themed through src/styles/starlight.css. */
+interface Props {
+ title: string;
+ description: string;
+ canonicalPath: string;
+ ogType?: 'website' | 'article';
+ ogImage?: string;
+ ogImageAlt?: string;
+}
+
+const {
+ title,
+ description,
+ canonicalPath,
+ ogType = 'website',
+ ogImage = '/assets/og-card-v9.png',
+ ogImageAlt = 'Herdr — run them anywhere, leave them running.',
+} = Astro.props;
+
+import { fmt, releaseVersion, siteStats } from '../site-data';
+
+const canonicalUrl = new URL(canonicalPath, 'https://herdr.dev').toString();
+const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
+const year = new Date().getFullYear();
+
+/* Same build-time source the homepage uses, so the nav count and the footer
+ version agree across every page without being written down twice. */
+const { starCount } = await siteStats();
+const version = await releaseVersion();
+const facts = version ? `v${version} · Apache 2.0` : 'Apache 2.0';
+---
+
+<!doctype html>
+<html lang="en" data-palette="catppuccin" data-mode="ink">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{title}</title>
+ <meta name="description" content={description} />
+ <meta name="theme-color" content="#17171a" />
+ <link rel="canonical" href={canonicalUrl} />
+
+ <meta property="og:type" content={ogType} />
+ <meta property="og:site_name" content="Herdr" />
+ <meta property="og:title" content={title} />
+ <meta property="og:description" content={description} />
+ <meta property="og:url" content={canonicalUrl} />
+ <meta property="og:image" content={ogImageUrl} />
+ <meta property="og:image:width" content="1200" />
+ <meta property="og:image:height" content="630" />
+ <meta property="og:image:alt" content={ogImageAlt} />
+
+ <meta name="twitter:card" content="summary_large_image" />
+ <meta name="twitter:title" content={title} />
+ <meta name="twitter:description" content={description} />
+ <meta name="twitter:image" content={ogImageUrl} />
+ <meta name="twitter:image:alt" content={ogImageAlt} />
+
+ <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon.png?v=14" />
+ <link rel="apple-touch-icon" href="/assets/logo.png" />
+ <link rel="preload" href="/assets/jbm.woff2?v=2" as="font" type="font/woff2" crossorigin />
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+ <link
+ href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&family=Inter:wght@400;500;600&display=swap"
+ rel="stylesheet"
+ />
+ <script is:inline>
+ /* Ground selection, shared with the docs — see index.html for the
+ reasoning. Starlight's `starlight-theme` key is the source of truth;
+ `auto` is treated as dark so the default never follows the OS. */
+ window.herdrGround = {
+ read: function () {
+ try {
+ return localStorage.getItem('starlight-theme') === 'light' ? 'paper' : 'ink';
+ } catch (e) {
+ return 'ink';
+ }
+ },
+ apply: function (mode) {
+ var root = document.documentElement;
+ root.setAttribute('data-mode', mode);
+ root.setAttribute('data-palette', mode === 'paper' ? 'solarized-light' : 'catppuccin');
+ return mode;
+ },
+ save: function (mode) {
+ try {
+ localStorage.setItem('starlight-theme', mode === 'paper' ? 'light' : 'dark');
+ } catch (e) {}
+ },
+ };
+ window.herdrGround.apply(window.herdrGround.read());
+ </script>
+ <link rel="stylesheet" href="/css/style.css?v=59" />
+ <link rel="stylesheet" href="/css/site.css?v=60" />
+ </head>
+ <body class="site">
+ <div class="hd-frame">
+ <nav class="hd-nav">
+ <a class="lock" href="/"><span class="ram"></span><b>herdr</b></a>
+ <div class="sp"></div>
+ <a class="lnk" href="/docs/">Docs</a>
+ <a class="lnk" href="/plugins/">Plugins</a>
+ <a class="lnk" href="/blog/">Blog</a>
+ <a class="ico wide" href="https://github.com/herdrdev/herdr" aria-label="Herdr on GitHub — star count">
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.64 7.64 0 0 1 8 3.87c.68 0 1.36.09 2 .26 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" /></svg>
+ <b data-stars>{fmt(starCount)}</b>
+ </a>
+ <a class="ico" href="https://x.com/herdrdev" aria-label="Herdr on X">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117Z" /></svg>
+ </a>
+ <div class="mode">
+ <button type="button" data-ground="ink" aria-pressed="true">ink</button>
+ <button type="button" data-ground="paper" aria-pressed="false">paper</button>
+ </div>
+ <a class="btn" href="/docs/install/">Install</a>
+ <button type="button" class="menu" id="nav-menu" aria-expanded="false" aria-controls="nav-sheet" aria-label="Menu">
+ <span></span><span></span><span></span>
+ </button>
+ </nav>
+
+ <div class="sheet" id="nav-sheet">
+ <a href="/docs/">Docs</a>
+ <a href="/plugins/">Plugins</a>
+ <a href="/blog/">Blog</a>
+ <a href="https://x.com/herdrdev">@herdrdev</a>
+ <div class="sheet-ground">
+ <span>Ground</span>
+ <div class="mode">
+ <button type="button" data-ground="ink" aria-pressed="true">ink</button>
+ <button type="button" data-ground="paper" aria-pressed="false">paper</button>
+ </div>
+ </div>
+ </div>
+
+ <slot />
+
+ <footer class="foot">
+ <span class="ram"></span>
+ <a href="/docs/">docs</a>
+ <a href="/plugins/">plugins</a>
+ <a href="/blog/">blog</a>
+ <span>built in the terminal · {year}</span>
+ <span class="fact">{facts}</span>
+ <span class="out">
+ <a class="ico" href="https://github.com/herdrdev/herdr" aria-label="Herdr on GitHub">
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.64 7.64 0 0 1 8 3.87c.68 0 1.36.09 2 .26 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" /></svg>
+ </a>
+ <a class="ico" href="https://x.com/herdrdev" aria-label="Herdr on X">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117Z" /></svg>
+ </a>
+ <a href="mailto:hey@herdr.dev">hey@herdr.dev</a>
+ </span>
+ </footer>
+ </div>
+
+ <script is:inline>
+ (function () {
+ var buttons = document.querySelectorAll('[data-ground]');
+ function paint(mode) {
+ window.herdrGround.apply(mode);
+ buttons.forEach(function (b) {
+ b.setAttribute('aria-pressed', String(b.dataset.ground === mode));
+ });
+ }
+ buttons.forEach(function (b) {
+ b.addEventListener('click', function () {
+ window.herdrGround.save(b.dataset.ground);
+ paint(b.dataset.ground);
+ });
+ });
+ paint(window.herdrGround.read());
+ })();
+
+ /* nav sheet — the links, the account and the ground toggle that don't
+ fit the bar on a phone */
+ (function () {
+ var btn = document.getElementById('nav-menu');
+ var sheet = document.getElementById('nav-sheet');
+ if (!btn || !sheet) return;
+ function set(open) {
+ sheet.classList.toggle('open', open);
+ btn.setAttribute('aria-expanded', String(open));
+ }
+ btn.addEventListener('click', function (event) {
+ event.stopPropagation();
+ set(!sheet.classList.contains('open'));
+ });
+ /* the ground buttons live in the sheet, so a tap inside must not close
+ it — only taps on the page behind do */
+ document.addEventListener('click', function (event) {
+ if (!sheet.contains(event.target)) set(false);
+ });
+ document.addEventListener('keydown', function (event) {
+ if (event.key === 'Escape') set(false);
+ });
+ })();
+
+ /* The nav count is baked at build, which can be a deploy behind. Refresh
+ it from the same snapshot so the number is current on every page. No
+ count-up here — this is chrome, not the argument the homepage makes. */
+ fetch('https://assets.herdr.dev/stats/stats.json', { headers: { accept: 'application/json' } })
+ .then(function (r) {
+ return r.ok ? r.json() : null;
+ })
+ .then(function (d) {
+ if (!d || !d.stars || typeof d.stars.total !== 'number') return;
+ var text = d.stars.total.toLocaleString('en-US');
+ document.querySelectorAll('[data-stars]').forEach(function (el) {
+ el.textContent = text;
+ });
+ })
+ .catch(function () {});
+ </script>
+ </body>
+</html>
diff --git a/website/src/content.config.ts b/website/src/content.config.ts
index f4993be8..88291349 100644
--- a/website/src/content.config.ts
+++ b/website/src/content.config.ts
@@ -13,6 +13,9 @@ export const collections = {
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().default(false),
+ /* description is always used for meta and OG; set false when it should
+ not also print as a subtitle under the title */
+ lede: z.boolean().default(true),
ogImage: z.string().optional(),
}),
}),
diff --git a/website/src/content/blog/coding-agents-are-becoming-runtimes.md b/website/src/content/blog/coding-agents-are-becoming-runtimes.md
index 1802a233..ae2fb0a2 100644
--- a/website/src/content/blog/coding-agents-are-becoming-runtimes.md
+++ b/website/src/content/blog/coding-agents-are-becoming-runtimes.md
@@ -3,7 +3,6 @@ title: Coding agents are becoming runtimes
description: Coding agent CLIs are becoming developer infrastructure. Project instructions, lifecycle state, hooks, and terminal runtimes need shared contracts.
date: 2026-06-10
draft: false
-ogImage: /assets/og-blog-coding-agents-runtimes.png
---
Coding agents have crossed an important line.
diff --git a/website/src/content/blog/live-updates-without-killing-your-terminal-processes.md b/website/src/content/blog/live-updates-without-killing-your-terminal-processes.md
index dfa75c01..b78a3caf 100644
--- a/website/src/content/blog/live-updates-without-killing-your-terminal-processes.md
+++ b/website/src/content/blog/live-updates-without-killing-your-terminal-processes.md
@@ -2,7 +2,6 @@
title: Live updates without killing your terminal processes
description: Herdr can replace its server during an update while keeping pane processes alive.
date: 2026-05-27
-ogImage: /assets/og-blog-live-updates.png
---
Live handoff shipped in Herdr 0.6.3 as an experimental, opt-in update path. See the [live handoff docs](/docs/session-state/#live-handoff) for the user-facing behavior.
diff --git a/website/src/content/blog/ten-agents-three-clients-95-percent-less-cpu.md b/website/src/content/blog/ten-agents-three-clients-95-percent-less-cpu.md
new file mode 100644
index 00000000..e21bf451
--- /dev/null
+++ b/website/src/content/blog/ten-agents-three-clients-95-percent-less-cpu.md
@@ -0,0 +1,163 @@
+---
+title: Ten agents, three clients, 95% less CPU
+description: Herdr's sidebar stopped animating, hidden panes stopped drawing, and mouse movement stopped counting as a change. The saving compounds with panes and clients, so the busiest sessions gained the most.
+date: 2026-08-03
+ogImage: /assets/og-blog-frames-v1.png
+draft: false
+---
+
+Herdr did not get faster.
+
+That distinction is worth keeping, because the honest version is more interesting. No renderer in Herdr became ten times quicker. Herdr stopped asking the renderer to draw things that nobody was going to look at. In the workloads where that work dominated, total CPU across the server and every attached client fell by 89 to 95 percent.
+
+The saving also compounds, which is the part worth knowing. One agent with one client attached improved 91 percent. Ten agents with background output and three clients attached improved 95, because a frame that is never produced is also never serialized, never transmitted, and never applied by any of them. The busier the session, the more there is to not do.
+
+Three changes got us there, and none of them were found in a profiler. The first started as a complaint, and the rule that came out of it was that motion should mean something changed.
+
+## The spinner was a complaint before it was a cost
+
+Herdr's job is to tell you which agent needs you. You run five, ten, twenty of them, and the sidebar is how you know where to look.
+
+So every working agent got a spinner. It seemed obvious at the time: motion means alive, and an agent that is thinking should look like it is thinking.
+
+Run twenty agents and you find out what that actually builds. Twenty things moving at once, all saying the same thing, none of them saying it is the one that needs you. The workspace list already carried state as coloured dots, and the sidebar was the noisy half of the same information. People told us. We agreed.
+
+The first fix was a performance fix. Instead of rebuilding pane contents for a spinner tick, the server reused the client's retained frame, redrew only the status rectangle, patched it, and sent the smaller result. It worked. It made an animation that should not exist about as cheap as an animation can be.
+
+It was still twenty things moving. So we removed them.
+
+<figure class="pf">
+ <div class="pf-2">
+ <div class="pf-panel">
+ <div class="pf-label"><span>0.7.5</span><b>3 spinners, 8fps</b></div>
+ <div class="pf-row working"><span class="pf-mark pf-spin"><i>⠋</i><i>⠙</i><i>⠹</i><i>⠸</i></span><div><strong>herdr</strong><small>working</small></div></div>
+ <div class="pf-row working"><span class="pf-mark pf-spin"><i>⠋</i><i>⠙</i><i>⠹</i><i>⠸</i></span><div><strong>web-dashboard</strong><small>working</small></div></div>
+ <div class="pf-row blocked"><span class="pf-mark">◉</span><div><strong>llm-proxy</strong><small>blocked, needs you</small></div></div>
+ <div class="pf-row working"><span class="pf-mark pf-spin"><i>⠋</i><i>⠙</i><i>⠹</i><i>⠸</i></span><div><strong>data-pipeline</strong><small>working</small></div></div>
+ <div class="pf-row done"><span class="pf-mark">●</span><div><strong>docs</strong><small>done</small></div></div>
+ </div>
+ <div class="pf-panel">
+ <div class="pf-label"><span>0.8.0</span><b>nothing moves</b></div>
+ <div class="pf-row working"><span class="pf-mark">●</span><div><strong>herdr</strong><small>working</small></div></div>
+ <div class="pf-row working"><span class="pf-mark">●</span><div><strong>web-dashboard</strong><small>working</small></div></div>
+ <div class="pf-row blocked"><span class="pf-mark">◉</span><div><strong>llm-proxy</strong><small>blocked, needs you</small></div></div>
+ <div class="pf-row working"><span class="pf-mark">●</span><div><strong>data-pipeline</strong><small>working</small></div></div>
+ <div class="pf-row done"><span class="pf-mark">●</span><div><strong>docs</strong><small>done</small></div></div>
+ </div>
+ </div>
+ <figcaption class="pf-cap">Both panels carry the same information. Only one of them lets you find the blocked agent without reading. The left panel is animating in your browser right now, which is the entire argument.</figcaption>
+</figure>
+
+Working state is now a static coloured mark. Entering the state still renders immediately, so a change catches your eye the moment it happens. Staying in it costs nothing, and looks like nothing.
+
+The performance consequence was larger than the design one. Animating that sidebar meant waking on a timer, rendering the status surface, comparing the frame, preparing it, serializing it, sending it to every attached client, and having each client receive and apply it. Roughly eight times a second, for as long as any agent was working, whether or not one other thing on screen had changed.
+
+Removing the spinner removed all of it: the timer, the animation deadlines, the scans asking whether any workspace contained a working pane, the animation-only render causes, the frame patching. Master produces zero scheduled animation frames. One agent working with nothing else on screen went from 1.467 percent CPU to 0.133 on Linux, and from 3.280 to 0.265 on macOS.
+
+We removed spinners because they were noise, not because they were inefficient.
+
+## The same rule, applied to output you cannot see
+
+Once motion means a change, the question becomes which things are actually changes.
+
+When a pane produced output, the PTY reader set a shared flag meaning something happened. It coalesced wakeups efficiently and threw away the one fact that mattered, which was which pane. So when the server picked that flag up it could not tell whether the pane was even on screen. A background agent writing to a tab you were not looking at went into rendering anyway, and with several clients attached, produced frames for all of them.
+
+<figure class="pf">
+ <div class="pf-2">
+ <div class="pf-panel">
+ <div class="pf-label"><span>0.7.5</span><b>a pane writes</b></div>
+ <div class="pf-flow">
+ <div class="pf-step"><b>dirty flag set</b><s>something changed, we do not know what</s></div>
+ <div class="pf-arrow">│</div>
+ <div class="pf-step"><b>render</b><s>no way to rule it out</s></div>
+ <div class="pf-arrow">│</div>
+ <div class="pf-step"><b>serialize, transmit</b><s>once per attached client</s></div>
+ <div class="pf-arrow">│</div>
+ <div class="pf-step pf-cost"><b>3 clients apply a frame</b><s>for a tab none of them are showing</s></div>
+ </div>
+ </div>
+ <div class="pf-panel">
+ <div class="pf-label"><span>0.8.0</span><b>a pane writes</b></div>
+ <div class="pf-flow">
+ <div class="pf-step"><b>request_pty(pane 7)</b><s>the source is kept</s></div>
+ <div class="pf-arrow">│</div>
+ <div class="pf-step"><b>can any client see pane 7?</b><s>tabs, zoom, popups, direct attaches</s></div>
+ <div class="pf-fork">
+ <div class="pf-step pf-stop"><em>├─ no</em><b>stop here</b><s>bytes already parsed, nothing drawn</s></div>
+ <div class="pf-step"><em>└─ yes</em><b>render as before</b><s>visible output is still visible output</s></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <figcaption class="pf-cap">Render requests now carry their source. Writes from one pane coalesce into a single entry, while simultaneous writes from different panes each keep their identity.</figcaption>
+</figure>
+
+Skipping loses nothing. The bytes have already been parsed, so terminal state, scrollback, cursor, OSC metadata and agent detection all stay current. Switching to that tab renders from that state normally. Where the mapping is unknown, the pane is treated as visible rather than suppressed, because a wrong skip costs you a stale screen and a wrong render costs some CPU. Those are not the same kind of mistake.
+
+## And to input that changes nothing
+
+Every mouse event reported that the view had changed. Pointer movement over a pane is forwarded to the application inside it, but Herdr's own frame is usually identical before and after. At 60 motion events per second, that meant roughly 60 server renders and 60 client frames per second, to draw the same picture sixty times.
+
+Herdr now knows which of its own modes respond to hover, which is the global menu, context menus and the navigator. Outside those, movement alone repaints nothing, while the events themselves still route through to the pane exactly as before.
+
+The benchmark counted both ends. In every measured round, on both builds, 1,680 motion packets were sent and all 1,680 arrived in the pane. Version 0.7.5 emitted around 56 to 60 frames per second doing it. Master emitted none. Linux CPU fell from 9.450 percent to 0.667.
+
+## Why three changes became ninety percent
+
+A render avoided at the server is not one saving. It is the frame preparation, the serialization, the transmission and the frame handling in every attached client, none of which happen.
+
+That is why the numbers grow with clients rather than shrinking. Ten silent working panes with one client improved 78 percent. The same panes with three clients improved 91.
+
+<figure class="pf">
+ <div class="pf-panel">
+ <div class="pf-label"><span>total CPU, server plus every attached client</span><b>linux</b></div>
+ <div class="pf-bars">
+ <div class="pf-bar-row">
+ <div class="pf-bar-name">one silent working pane</div>
+ <div class="pf-bar old"><span>0.7.5</span><i style="width:11.3%"></i><em>1.467%</em></div>
+ <div class="pf-bar new"><span>0.8.0</span><i style="width:1%"></i><em>0.133%</em></div>
+ </div>
+ <div class="pf-bar-row">
+ <div class="pf-bar-name">ten silent working panes, three clients</div>
+ <div class="pf-bar old"><span>0.7.5</span><i style="width:33.2%"></i><em>4.316%</em></div>
+ <div class="pf-bar new"><span>0.8.0</span><i style="width:3.1%"></i><em>0.400%</em></div>
+ </div>
+ <div class="pf-bar-row">
+ <div class="pf-bar-name">ten panes, nine hidden writers, three clients</div>
+ <div class="pf-bar old"><span>0.7.5</span><i style="width:100%"></i><em>13.014%</em></div>
+ <div class="pf-bar new"><span>0.8.0</span><i style="width:4.7%"></i><em>0.617%</em></div>
+ </div>
+ <div class="pf-bar-row">
+ <div class="pf-bar-name">passive mouse motion at 60Hz</div>
+ <div class="pf-bar old"><span>0.7.5</span><i style="width:72.6%"></i><em>9.450%</em></div>
+ <div class="pf-bar new"><span>0.8.0</span><i style="width:5.1%"></i><em>0.667%</em></div>
+ </div>
+ <div class="pf-bar-row">
+ <div class="pf-bar-name">forty-nine hidden writers at 60Hz</div>
+ <div class="pf-bar old"><span>0.7.5</span><i style="width:44.6%"></i><em>5.800%</em></div>
+ <div class="pf-bar new"><span>0.8.0</span><i style="width:34.1%"></i><em>4.433%</em></div>
+ </div>
+ </div>
+ </div>
+ <figcaption class="pf-cap">The last row is the one to look at. macOS followed the same shape, from 22.958 percent to 1.673 on the hidden output case and from 14.857 to 13.498 on the last one.</figcaption>
+</figure>
+
+On 0.7.5, adding hidden background output to a session cost 8.7 CPU points on Linux and 12.7 on macOS. On master the same activity costs 0.2 and 0.6, which is roughly the price of reading and parsing the bytes and nothing else.
+
+## Where it does not help
+
+Fifty panes, forty-nine of them writing at 60Hz, is about 2,940 terminal updates per second. Every one of them still has to be read from the PTY and parsed into terminal state, because that is what makes the pane correct when you switch to it. Suppressing frames cannot suppress terminal emulation.
+
+That case improved 23.6 percent on Linux and 9.1 on macOS. Visible output at 60Hz, a pane you are actually watching, improved 33.9 percent on Linux and 2.7 on macOS, which is to say it did not regress. Idle sat at the measurement floor on both builds and deserves no percentage claim at all.
+
+None of those are the headline. All of them are the point. The gains are large exactly where the work was unnecessary and small exactly where it was not.
+
+## How this was measured
+
+Official 0.7.5 release binaries against hashed master builds, on separate Linux and macOS machines, giving 54 isolated observations per operating system. Every run used a fresh named session, real attached clients in fixed 86 by 47 terminals, a five second warmup and twenty one second samples. The figures are total CPU for the Herdr server plus every attached client.
+
+Every headline result also has a behavioural counter behind it rather than only a CPU total: scheduled frames per second for the sidebar, hidden source skips for background output, exact packet delivery counts for mouse motion. A CPU number on its own can be explained by a dozen things. A CPU number with a matching mechanism counter is harder to argue with.
+
+One limit worth stating plainly. This compares a shipped release against master, so intervening changes and build environments are part of what is being compared. The profiler evidence matches the mechanisms closely, but not every CPU point can be assigned to one commit.
+
+None of this started in a profiler. It started with people saying the sidebar was too busy, and with bug reports and performance reports from people running far stranger sessions than we test. Keep them coming. Thank you to everyone helping make Herdr better.
diff --git a/website/src/data/plugins-fixture.json b/website/src/data/plugins-fixture.json
index 27a9c265..c41ab764 100644
--- a/website/src/data/plugins-fixture.json
+++ b/website/src/data/plugins-fixture.json
@@ -1,11 +1,13 @@
{
"schemaVersion": 1,
"generatedAt": "2026-06-20T12:00:00.000Z",
+ "pluginCount": 10,
+ "repositoryCount": 7,
"source": {
"provider": "github",
"query": "topic:herdr-plugin",
- "totalCount": 7,
- "collectedCount": 7,
+ "totalCount": 8,
+ "collectedCount": 8,
"truncated": false
},
"plugins": [
@@ -25,7 +27,18 @@
"updatedAt": "2026-06-19T19:58:26Z",
"pushedAt": "2026-06-19T19:58:26Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "herdr-plugin.toml",
+ "id": "github-start",
+ "name": "GitHub Start",
+ "version": "1.4.0",
+ "minHerdrVersion": "0.7.0",
+ "description": "Start an agent from a GitHub issue, PR, or discussion.",
+ "platforms": ["linux", "macos"]
+ }
+ ]
},
{
"id": 223456789,
@@ -43,7 +56,36 @@
"updatedAt": "2026-06-18T08:10:00Z",
"pushedAt": "2026-06-18T08:10:00Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "agent-telegram-notify/herdr-plugin.toml",
+ "id": "agent-telegram-notify",
+ "name": "Telegram Notify",
+ "version": "0.3.1",
+ "minHerdrVersion": "0.7.0",
+ "description": "Send a Telegram message when an agent blocks or finishes.",
+ "platforms": ["linux", "macos"]
+ },
+ {
+ "path": "link-preview/herdr-plugin.toml",
+ "id": "link-preview",
+ "name": "Link Preview",
+ "version": "0.2.0",
+ "minHerdrVersion": "0.7.0",
+ "description": "Preview a URL from pane output without leaving the terminal.",
+ "platforms": ["linux", "macos"]
+ },
+ {
+ "path": "worktree-bootstrap/herdr-plugin.toml",
+ "id": "worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.5.0",
+ "minHerdrVersion": "0.7.0",
+ "description": "Create a worktree, install dependencies, and open a layout in one step.",
+ "platforms": ["linux", "macos", "windows"]
+ }
+ ]
},
{
"id": 323456789,
@@ -61,7 +103,18 @@
"updatedAt": "2026-06-17T14:02:00Z",
"pushedAt": "2026-06-17T14:02:00Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "herdr-plugin.toml",
+ "id": "lindqvist.board",
+ "name": "Board",
+ "version": "2.1.0",
+ "minHerdrVersion": "0.7.5",
+ "description": "A live project board pane that tracks agent tasks across worktrees.",
+ "platforms": ["linux", "macos"]
+ }
+ ]
},
{
"id": 423456789,
@@ -79,7 +132,18 @@
"updatedAt": "2026-06-15T09:30:00Z",
"pushedAt": "2026-06-15T09:30:00Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "herdr-plugin.toml",
+ "id": "layout-presets",
+ "name": "Layout Presets",
+ "version": "0.9.2",
+ "minHerdrVersion": "0.7.0",
+ "description": "Apply opinionated split-pane layouts per project with one keybinding.",
+ "platforms": ["linux", "macos"]
+ }
+ ]
},
{
"id": 523456789,
@@ -97,7 +161,27 @@
"updatedAt": "2026-06-10T11:45:00Z",
"pushedAt": "2026-06-10T11:45:00Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "plugin/herdr-plugin.toml",
+ "id": "jira-bridge",
+ "name": "Jira Bridge",
+ "version": "0.4.0",
+ "minHerdrVersion": "0.7.0",
+ "description": "Open Jira tickets as workspaces and push agent summaries back as comments.",
+ "platforms": ["linux", "macos"]
+ },
+ {
+ "path": "plugin-cloud/herdr-plugin.toml",
+ "id": "jira-bridge-cloud",
+ "name": "Jira Bridge Cloud",
+ "version": "0.4.0",
+ "minHerdrVersion": "0.7.0",
+ "description": "The Jira Cloud variant of the bridge.",
+ "platforms": ["linux", "macos"]
+ }
+ ]
},
{
"id": 623456789,
@@ -115,7 +199,18 @@
"updatedAt": "2026-06-08T16:20:00Z",
"pushedAt": "2026-06-08T16:20:00Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "herdr-plugin.toml",
+ "id": "notify",
+ "name": "Notify",
+ "version": "1.0.0",
+ "minHerdrVersion": "0.7.0",
+ "description": "Desktop and mobile notifications when an agent finishes, blocks, or needs input.",
+ "platforms": ["linux", "macos", "windows"]
+ }
+ ]
},
{
"id": 723456789,
@@ -133,7 +228,18 @@
"updatedAt": "2026-06-14T10:00:00Z",
"pushedAt": "2026-06-14T10:00:00Z",
"archived": false,
- "fork": false
+ "fork": false,
+ "manifests": [
+ {
+ "path": "herdr-plugin.toml",
+ "id": "snippet-runner",
+ "name": "snippet-runner",
+ "version": "0.1.0",
+ "minHerdrVersion": "0.7.0",
+ "description": null,
+ "platforms": ["linux"]
+ }
+ ]
}
]
}
diff --git a/website/src/pages/blog/[slug].astro b/website/src/pages/blog/[slug].astro
index 48cba857..3c33a38c 100644
--- a/website/src/pages/blog/[slug].astro
+++ b/website/src/pages/blog/[slug].astro
@@ -1,18 +1,16 @@
---
import { getCollection, render } from 'astro:content';
-import MarketingLayout from '../../components/MarketingLayout.astro';
+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) => !post.data.draft)
+ .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 },
- };
+ return { params: { slug }, props: { post, slug } };
});
}
@@ -21,7 +19,7 @@ const { Content, headings } = await render(post);
const sectionHeadings = headings.filter((heading) => heading.depth === 2);
---
-<MarketingLayout
+<SiteLayout
title={`${post.data.title} — Herdr blog`}
description={post.data.description}
canonicalPath={`/blog/${slug}/`}
@@ -29,9 +27,33 @@ const sectionHeadings = headings.filter((heading) => heading.depth === 2);
ogImage={post.data.ogImage}
ogImageAlt={post.data.ogImage ? post.data.title : undefined}
>
- <main class="content-shell">
- <div class="article-layout section">
- <aside class="article-toc" aria-label="Article sections">
+ {/* 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) => (
@@ -39,30 +61,11 @@ const sectionHeadings = headings.filter((heading) => heading.depth === 2);
))}
</nav>
</aside>
-
- <article class="article-shell">
- <header class="article-header">
- <p class="post-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>
- <p>{post.data.description}</p>
- </header>
- <div class="article-prose">
- <Content />
- </div>
- <AuthorCard />
- </article>
- </div>
- </main>
+ )}
+ </div>
<script>
- const headings = Array.from(document.querySelectorAll('.article-prose h2[id]'));
+ 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'),
@@ -89,4 +92,4 @@ const sectionHeadings = headings.filter((heading) => heading.depth === 2);
for (const heading of headings) observer.observe(heading);
}
</script>
-</MarketingLayout>
+</SiteLayout>
diff --git a/website/src/pages/blog/index.astro b/website/src/pages/blog/index.astro
index 8ca2ca10..de9349ea 100644
--- a/website/src/pages/blog/index.astro
+++ b/website/src/pages/blog/index.astro
@@ -1,38 +1,42 @@
---
import { getCollection } from 'astro:content';
-import MarketingLayout from '../../components/MarketingLayout.astro';
+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) => !post.data.draft)
+ .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' });
---
-<MarketingLayout
+<SiteLayout
title="Herdr blog"
- description="Notes on terminal-native agent workflows and the engineering behind Herdr."
+ description="Notes on the agent runtime — product decisions, implementation writeups, and what we learned running agents on machines nobody is watching."
canonicalPath="/blog/"
>
- <main class="content-shell">
- <section class="page-hero page-hero-compact">
- <p class="eyebrow"><span class="status-dot dot-working"></span>Blog</p>
- <h1>Notes from Herdr.</h1>
- <p class="hero-lede">
- Product notes and implementation writeups for the terminal-native agent multiplexer.
- </p>
- </section>
+ <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>
- <section class="section section-narrow" aria-label="Blog posts">
- <div class="post-list">
- {posts.map((post) => (
- <article class="post-card">
- <a class="post-card-link" href={`/blog/${post.slug}/`}>
- <h2>{post.data.title}</h2>
- <span aria-hidden="true">→</span>
- </a>
- </article>
- ))}
- </div>
- </section>
- </main>
-</MarketingLayout>
+ <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
index c3ba436d..b9131be6 100644
--- a/website/src/pages/compare.astro
+++ b/website/src/pages/compare.astro
@@ -19,10 +19,10 @@
content="Herdr combines tmux-style persistence, real terminal panes, agent state, direct attach, SSH workflows, and an API agents can drive."
/>
<meta property="og:url" content="https://herdr.dev/compare/" />
- <meta property="og:image" content="https://herdr.dev/assets/og-card-v8.png" />
+ <meta property="og:image" content="https://herdr.dev/assets/og-card-v9.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
- <meta property="og:image:alt" content="Herdr — One terminal. The whole herd." />
+ <meta property="og:image:alt" content="Herdr — run them anywhere, leave them running." />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Compare Herdr — terminal-native agent multiplexer" />
@@ -30,8 +30,8 @@
name="twitter:description"
content="Compare Herdr with tmux, Zellij, cmux, Warp, Solo, Conductor, Emdash, and Superset."
/>
- <meta name="twitter:image" content="https://herdr.dev/assets/og-card-v8.png" />
- <meta name="twitter:image:alt" content="Herdr — One terminal. The whole herd." />
+ <meta name="twitter:image" content="https://herdr.dev/assets/og-card-v9.png" />
+ <meta name="twitter:image:alt" content="Herdr — run them anywhere, leave them running." />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon.png?v=14" />
<link rel="apple-touch-icon" href="/assets/logo.png" />
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index e46ccbe6..26a048d4 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -1,7 +1,33 @@
---
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={homepage} />
+<Fragment set:html={page} />
diff --git a/website/src/pages/og/card-b.html b/website/src/pages/og/card-b.html
new file mode 100644
index 00000000..c508778b
--- /dev/null
+++ b/website/src/pages/og/card-b.html
@@ -0,0 +1,60 @@
+<!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
new file mode 100644
index 00000000..e2142252
--- /dev/null
+++ b/website/src/pages/og/card-perf.html
@@ -0,0 +1,56 @@
+<!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/plugins.astro b/website/src/pages/plugins.astro
index 6119f834..f9c4854f 100644
--- a/website/src/pages/plugins.astro
+++ b/website/src/pages/plugins.astro
@@ -1,5 +1,5 @@
---
-import MarketingLayout from '../components/MarketingLayout.astro';
+import SiteLayout from '../components/SiteLayout.astro';
import fixture from '../data/plugins-fixture.json';
const snapshotUrl = 'https://assets.herdr.dev/plugins/index.json';
@@ -17,29 +17,45 @@ try {
}
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;
---
-<MarketingLayout
+<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">
- <section class="section plugins-hero">
- <p class="section-kicker"># marketplace</p>
- <h2>Plugins built on Herdr.</h2>
- <p class="plugins-lede">
- Community plugins published on GitHub. Install any of them with
+ <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 are discovered automatically from the GitHub topic
- <code>herdr-plugin</code>. They are not reviewed by Herdr, so install at
- your own discretion. <a href="/docs/plugins/#trust-and-security">Trust guidance</a>.
+ Listings aren't reviewed by Herdr, so install at your own discretion.
+ <a href="/docs/plugins/#trust-and-security">Trust guidance →</a>
</p>
- </section>
+ </div>
+ <aside class="ph-side">
+ <b>{pluginCount.toLocaleString('en-US')}</b>
+ <span>plugins across {repositoryCount.toLocaleString('en-US')} repositories</span>
+ <a href="/docs/plugins/">Publish yours →</a>
+ </aside>
+ </header>
<section class="section plugins-controls">
<div class="plugins-search-wrap">
@@ -48,7 +64,7 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
id="plugins-search"
type="search"
class="plugins-search"
- placeholder="search plugins, owners, languages…"
+ placeholder="search plugins, repositories, owners, languages…"
autocomplete="off"
spellcheck="false"
aria-label="Search plugins"
@@ -63,6 +79,9 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
<option value="name">Name</option>
</select>
</label>
+ </section>
+
+ <section class="section plugins-metabar">
<p id="plugins-meta" class="plugins-meta" aria-live="polite"></p>
</section>
@@ -98,8 +117,8 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
const moreButton = document.getElementById("plugins-more");
// 24 fills complete rows in both the 3-column and 2-column grid layouts.
- const SHOW_LIMIT = 24;
- let showAll = false;
+ const PAGE_SIZE = 24;
+ let visibleLimit = PAGE_SIZE;
const LANG_COLORS = {
TypeScript: "#3178c6",
@@ -113,7 +132,8 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
Ruby: "#701516",
};
- let plugins = [];
+ // One entry per repository. Its `manifests` are the plugins it ships.
+ let repos = [];
let usingFallback = false;
function timeAgo(iso) {
@@ -188,23 +208,58 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
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 || [])]
+ 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 sortPlugins(list, mode) {
+ 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));
@@ -219,16 +274,18 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
function render() {
const q = searchInput.value.trim().toLowerCase();
- const visible = sortPlugins(plugins.filter((p) => matches(p, q)), sortSelect.value);
- const shown = showAll ? visible : visible.slice(0, SHOW_LIMIT);
+ 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));
- const hiddenCount = visible.length - shown.length;
- moreButton.hidden = hiddenCount === 0;
- if (hiddenCount > 0) {
- moreButton.textContent = "show " + hiddenCount + " more";
+ // 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) {
@@ -241,23 +298,28 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
}
}
- function setMeta(generatedAt) {
+ function setMeta(data) {
const parts = [];
- parts.push(plugins.length + (plugins.length === 1 ? " plugin" : " plugins"));
- const ago = timeAgo(generatedAt);
+ 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"));
+ 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) {
- plugins = Array.isArray(data && data.plugins) ? data.plugins : [];
- setMeta(data && data.generatedAt);
+ repos = Array.isArray(data && data.plugins) ? data.plugins : [];
+ setMeta(data);
render();
}
function showError() {
- plugins = [];
+ repos = [];
metaEl.textContent = "";
grid.replaceChildren();
moreButton.hidden = true;
@@ -288,10 +350,16 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
}
}
- searchInput.addEventListener("input", render);
- sortSelect.addEventListener("change", render);
+ // 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", () => {
- showAll = true;
+ visibleLimit += PAGE_SIZE;
render();
});
load();
@@ -300,6 +368,7 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
<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. */
@@ -416,6 +485,23 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
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;
@@ -566,6 +652,43 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
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;
@@ -626,4 +749,4 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
}
}
</style>
-</MarketingLayout>
+</SiteLayout>
diff --git a/website/src/pages/stats.astro b/website/src/pages/stats.astro
deleted file mode 100644
index 2287b929..00000000
--- a/website/src/pages/stats.astro
+++ /dev/null
@@ -1,270 +0,0 @@
----
-import MarketingLayout from '../components/MarketingLayout.astro';
----
-
-<MarketingLayout
- title="Herdr stats: live public numbers"
- description="Live public stats for Herdr — GitHub stars, downloads, and website traffic, pulled from publicly verifiable sources."
- canonicalPath="/stats/"
->
- <main class="stats-page">
- <section class="hero stats-hero">
- <div class="hero-left">
- <div class="hero-copy">
- <p class="eyebrow">
- <span class="status-dot dot-working"></span>
- build in public · live numbers
- </p>
- <div class="hero-stat-value" id="star-count">—</div>
- <div class="hero-stat-label"><span class="live-dot" aria-hidden="true"></span> GitHub stars · counting live</div>
- <p class="hero-lede">
- Live public stats for herdr: stars, installs, and traffic. Everything on this
- page comes from sources you can check yourself, refreshed continuously.
- </p>
- <div class="hero-actions">
- <a class="button button-primary" href="https://github.com/herdrdev/herdr">★ Star on GitHub</a>
- <a class="button button-secondary" href="/docs/install/">Get herdr</a>
- </div>
- <p class="hero-note" id="hero-note"></p>
- </div>
- </div>
- <div class="hero-right">
- <div class="chart-card">
- <div class="chart-card-title">
- <span>github stars · all time</span>
- <span class="when" id="stars-since"></span>
- </div>
- <div id="chart-stars" class="chart-slot"></div>
- </div>
- </div>
- </section>
-
- <div class="stats-main">
- <section class="kpi-row" aria-label="Headline numbers">
- <div class="kpi">
- <div class="label">total installs</div>
- <div class="value" id="kpi-installs">—</div>
- <div class="sub" id="kpi-installs-sub"></div>
- </div>
- <div class="kpi">
- <div class="label">daily visitors</div>
- <div class="value" id="kpi-visitors">—</div>
- <div class="sub">herdr.dev uniques · 7-day avg · cloudflare</div>
- </div>
- <div class="kpi">
- <div class="label">releases shipped</div>
- <div class="value" id="kpi-releases">—</div>
- <div class="sub">stable + preview channels</div>
- </div>
- <div class="kpi">
- <div class="label">days old</div>
- <div class="value" id="kpi-age">—</div>
- <div class="sub" id="kpi-age-sub"></div>
- </div>
- </section>
-
- <section class="stats-section">
- <h2>Week by week</h2>
- <p class="section-sub">Weekly totals. The current week is still filling up, shown dimmed.</p>
- <div class="velocity-grid">
- <div class="chart-card">
- <div class="chart-card-title"><span>stars / week</span></div>
- <div id="chart-stars-week" class="chart-slot"></div>
- </div>
- <div class="chart-card">
- <div class="chart-card-title"><span>downloads / week</span></div>
- <div id="chart-downloads-week" class="chart-slot"></div>
- </div>
- <div class="chart-card">
- <div class="chart-card-title"><span>avg daily visitors / week</span></div>
- <div id="chart-visitors-week" class="chart-slot"></div>
- </div>
- </div>
- </section>
-
- <section class="stats-section">
- <h2>Downloads</h2>
- <p class="section-sub" id="downloads-sub"></p>
- <div class="chart-card">
- <div class="chart-card-title">
- <span>release downloads · cumulative</span>
- <span class="when">github api</span>
- </div>
- <div id="chart-downloads" class="chart-slot"></div>
- </div>
- </section>
-
- <section class="stats-section">
- <h2>Where the herd browses from</h2>
- <p class="section-sub" id="traffic-sub"></p>
- <div class="traffic-grid">
- <div class="chart-card">
- <div class="chart-card-title"><span>top countries</span><span class="when">visits</span></div>
- <div id="rank-countries"></div>
- </div>
- <div class="chart-card">
- <div class="chart-card-title"><span>top referrers</span><span class="when">visits</span></div>
- <div id="rank-referrers"></div>
- </div>
- <div class="chart-card">
- <div class="chart-card-title"><span>top pages</span><span class="when">page views</span></div>
- <div id="rank-paths"></div>
- </div>
- </div>
- </section>
-
- <section class="stats-section">
- <h2>Timeline</h2>
- <p class="section-sub">From first commit to today.</p>
- <div id="timeline"></div>
- </section>
-
- <p class="sources-note" id="sources-note">
- Every number on this page comes from publicly verifiable sources: the
- <a href="https://api.github.com/repos/herdrdev/herdr">GitHub API</a> for stars and release downloads, and
- <a href="https://formulae.brew.sh/api/analytics/install/365d.json">Homebrew analytics</a> for brew installs.
- Website traffic is measured by Cloudflare (unique visitors per day, herdr.dev zone).
- <span id="updated-note"></span>
- </p>
- </div>
-
- <script>
- import { initStatsPage } from '../scripts/stats-page';
- initStatsPage();
- </script>
- </main>
-</MarketingLayout>
-
-<style is:global>
- .stats-hero { min-height: auto; }
- .stats-hero .hero-left { padding: 3.5rem 3rem 3.5rem max(2.5rem, calc((100vw - var(--max)) / 2)); }
- .stats-hero .hero-right { padding: 3rem max(2.5rem, calc((100vw - var(--max)) / 2)) 3rem 2.5rem; }
- .stats-hero .chart-card { width: 100%; position: relative; z-index: 1; }
- .hero-stat-value {
- font-family: var(--mono);
- font-weight: 600;
- font-size: clamp(3.2rem, 7vw, 5.2rem);
- line-height: 1;
- letter-spacing: -0.04em;
- color: var(--ink);
- min-height: 1em;
- }
- .hero-stat-label {
- display: flex; align-items: center; gap: 0.55rem;
- margin-top: 0.55rem;
- font-family: var(--mono); font-size: 0.95rem; color: var(--muted);
- }
- .live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: live-pulse 2.4s ease-in-out infinite; }
- @keyframes live-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
- @media (prefers-reduced-motion: reduce) { .live-dot { animation: none; } }
-
- .stats-main { max-width: var(--max); margin: 0 auto; padding: 0 1.5rem 4rem; }
- .stats-section { margin-top: 3.5rem; }
- .stats-section > h2 { font-family: var(--mono); font-weight: 400; letter-spacing: -0.02em; font-size: 1.45rem; margin: 0 0 0.35rem; color: var(--ink); }
- .stats-section > .section-sub { color: var(--muted); font-size: 0.95rem; margin: 0 0 1.4rem; max-width: 62ch; }
-
- .chart-card {
- border: 1px solid var(--line); border-radius: var(--radius-lg);
- background: var(--bg-elevated); padding: 1rem 1.1rem 0.8rem; position: relative;
- }
- .chart-card-title {
- display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
- font-family: var(--mono); font-size: 0.85rem; color: var(--ink-soft); margin-bottom: 0.5rem;
- }
- .chart-card-title .when { color: var(--muted-2); font-size: 0.78rem; }
- .chart { position: relative; }
- .chart svg { display: block; width: 100%; height: auto; }
- .chart svg:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }
-
- .chart .grid { stroke: var(--line); stroke-width: 1; }
- .chart .grid.strong { stroke: var(--line-strong); }
- .chart .tick { fill: var(--muted-2); font-family: var(--mono); font-size: 11px; }
- .chart .line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
- .chart .area { opacity: 0.1; }
- .chart .dot { stroke: var(--bg-elevated); stroke-width: 2; }
- .chart .event-dot { fill: var(--bg-elevated); stroke-width: 2; }
- .chart .milestone-label { fill: var(--muted); font-family: var(--mono); font-size: 11.5px; font-weight: 600; }
- .chart .milestone-leader { stroke: var(--line-strong); stroke-width: 1; }
- .chart .cap-label { fill: var(--muted); font-family: var(--mono); font-size: 11.5px; font-weight: 600; }
- .chart .crosshair { stroke: var(--line-strong); stroke-width: 1; }
- .chart .hover-dot { stroke: var(--bg-elevated); stroke-width: 2; }
- .chart .bar .hit { fill: transparent; }
- .chart .bar.partial .fill { opacity: 0.45; }
- .chart .bar:hover .fill, .chart .bar.active .fill { filter: brightness(1.12); }
-
- .chart-tip {
- position: absolute; pointer-events: none; z-index: 4;
- background: var(--ink); color: var(--bg);
- font-family: var(--mono); font-size: 0.78rem; line-height: 1.4;
- padding: 0.35rem 0.55rem; border-radius: var(--radius-md);
- white-space: nowrap; transform: translate(-50%, calc(-100% - 10px));
- }
- .chart-tip strong { font-size: 0.88rem; }
- .chart-empty { color: var(--muted); font-family: var(--mono); font-size: 0.85rem; padding: 2rem 0; text-align: center; }
-
- .chart-table { margin-top: 0.4rem; font-family: var(--mono); font-size: 0.78rem; }
- .chart-table summary { color: var(--muted-2); cursor: pointer; padding: 0.25rem 0; }
- .chart-table summary:hover { color: var(--muted); }
- .chart-table-scroll {
- max-height: 250px; overflow-y: auto; margin-top: 0.4rem;
- scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent;
- }
- .chart-table-scroll::-webkit-scrollbar { width: 8px; }
- .chart-table-scroll::-webkit-scrollbar-track { background: transparent; }
- .chart-table-scroll::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
- /* the site stylesheet styles tables globally (min-width, accent last column) — reset inside cards */
- .chart-table table { width: 100%; min-width: 0; border-collapse: collapse; font-size: 0.78rem; }
- .chart-table th, .chart-table td { text-align: left; padding: 0.28rem 0.6rem 0.28rem 0; border-bottom: 1px solid var(--line); color: var(--ink-soft); font-variant-numeric: tabular-nums; font-size: 0.78rem; }
- .chart-table th { color: var(--muted); font-weight: 600; letter-spacing: 0.04em; }
- .chart-table th:last-child, .chart-table td:last-child { background: none; color: var(--ink-soft); font-weight: 600; text-align: right; padding-right: 0; }
- .chart-table tr:last-child td { border-bottom: 0; }
-
- .kpi-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.9rem; margin-top: 3rem; }
- .kpi {
- border: 1px solid var(--line); border-radius: var(--radius-lg);
- background: var(--bg-elevated); padding: 1rem 1.1rem;
- }
- .kpi .label { font-family: var(--mono); font-size: 0.78rem; color: var(--muted); }
- .kpi .value { font-family: var(--mono); font-weight: 600; font-size: 1.9rem; letter-spacing: -0.02em; color: var(--ink); margin: 0.25rem 0 0.15rem; }
- .kpi .sub { font-family: var(--mono); font-size: 0.75rem; color: var(--muted-2); }
- .kpi .sub .up { color: var(--green); font-weight: 600; }
-
- .velocity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.9rem; }
- .traffic-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.9rem; }
-
- .rank-list { display: flex; flex-direction: column; gap: 0.15rem; margin-top: 0.3rem; }
- .rank-row {
- display: grid; grid-template-columns: minmax(0, 45%) 1fr auto;
- gap: 0.7rem; align-items: center; padding: 0.28rem 0;
- }
- .rank-name { font-family: var(--mono); font-size: 0.8rem; color: var(--ink-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .rank-bar { height: 8px; border-radius: 2px; background: color-mix(in srgb, var(--yellow) 14%, transparent); overflow: hidden; }
- .rank-fill { display: block; height: 100%; border-radius: 2px; background: var(--yellow); }
- .rank-value { font-family: var(--mono); font-size: 0.78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
-
- .milestone-term {
- background: var(--term-bg); border: 1px solid var(--term-border); border-radius: var(--radius-lg);
- font-family: var(--mono); font-size: 0.92rem; padding: 1.1rem 1.3rem; max-width: 560px;
- }
- .term-line { display: flex; gap: 1.2rem; padding: 0.22rem 0; align-items: baseline; }
- .term-cmd-line { margin-bottom: 0.5rem; gap: 0.6rem; }
- .term-prompt { color: var(--term-prompt); }
- .term-cmd { color: var(--term-text); }
- .term-date { color: var(--term-dim); flex: 0 0 4.4em; }
- .term-text { color: var(--term-text); flex: 1; }
- .term-note { color: var(--term-muted); }
- .term-line:last-child .term-text { color: var(--term-green-bright); font-weight: 600; }
- .term-line:last-child .term-note { color: var(--term-green-bright); }
-
- .sources-note { margin-top: 3.5rem; padding-top: 1.4rem; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.88rem; max-width: 72ch; }
- .sources-note a { color: var(--accent); }
-
- .stats-stale {
- margin-top: 1rem; font-family: var(--mono); font-size: 0.8rem; color: var(--yellow);
- }
-
- @media (max-width: 900px) {
- .kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .velocity-grid, .traffic-grid { grid-template-columns: minmax(0, 1fr); }
- }
-</style>
diff --git a/website/src/scripts/stats-page.ts b/website/src/scripts/stats-page.ts
deleted file mode 100644
index 9d583f56..00000000
--- a/website/src/scripts/stats-page.ts
+++ /dev/null
@@ -1,742 +0,0 @@
-// Client renderer for /stats/. Fetches the public stats snapshot from R2 and
-// renders every number and chart client-side, so the static page never goes stale.
-
-const STATS_URL = "https://assets.herdr.dev/stats/stats.json";
-const REPO_API_URL = "https://api.github.com/repos/herdrdev/herdr";
-
-const LINE_H = 320;
-const BAR_W = 420;
-const BAR_H = 240;
-const STAR_MILESTONES = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000];
-const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
-
-type SeriesPoint = { d: string; v: number };
-
-type StatsJson = {
- schemaVersion: number;
- generatedAt: string;
- repo: string;
- bornDay: string;
- stars: { total: number; daily: SeriesPoint[] };
- downloads: { github: number; brew: number; daily: SeriesPoint[] };
- releases: { shipped: number };
- traffic: {
- windowDays: number;
- daily: SeriesPoint[];
- topCountries: Array<{ code: string; visits: number }>;
- topReferrers: Array<{ host: string; visits: number }>;
- topPaths: Array<{ path: string; views: number }>;
- };
- timeline: Array<{ d: string; label: string; note?: string; badge?: string }>;
-};
-
-type WeekPoint = { weekStart: string; value: number; partial: boolean; daysIn: number };
-type Milestone = { day: string; value: number; label: string };
-
-// ---------- formatting ----------
-
-function fmtInt(value: number): string {
- return Math.round(value).toLocaleString("en-US");
-}
-
-function fmtCompact(value: number): string {
- if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
- if (value >= 10_000) return `${Math.round(value / 1_000)}k`;
- if (value >= 1_000) {
- const scaled = value / 1_000;
- return `${Number.isInteger(scaled) ? scaled : scaled.toFixed(1)}k`;
- }
- return String(Math.round(value));
-}
-
-function fmtDay(day: string): string {
- const date = new Date(`${day}T00:00:00Z`);
- return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
-}
-
-function escapeHtml(value: string): string {
- return value
- .replaceAll("&", "&amp;")
- .replaceAll("<", "&lt;")
- .replaceAll(">", "&gt;")
- .replaceAll('"', "&quot;");
-}
-
-// ---------- date/series math ----------
-
-function dayEpoch(day: string): number {
- return Date.parse(`${day}T00:00:00Z`);
-}
-
-function daysBetween(fromDay: string, toDay: string): number {
- return Math.round((dayEpoch(toDay) - dayEpoch(fromDay)) / 86_400_000);
-}
-
-function todayUtc(): string {
- return new Date().toISOString().slice(0, 10);
-}
-
-function weekStartOf(day: string): string {
- const date = new Date(`${day}T00:00:00Z`);
- const sinceMonday = (date.getUTCDay() + 6) % 7;
- return new Date(date.getTime() - sinceMonday * 86_400_000).toISOString().slice(0, 10);
-}
-
-function weekMeta(weekStart: string, lastDataDay: string | null): { partial: boolean; daysIn: number } {
- if (!lastDataDay) return { partial: false, daysIn: 7 };
- const weekEnd = new Date(dayEpoch(weekStart) + 6 * 86_400_000).toISOString().slice(0, 10);
- const partial = lastDataDay < weekEnd || weekEnd >= todayUtc();
- const daysIn = partial ? Math.min(daysBetween(weekStart, lastDataDay) + 1, 7) : 7;
- return { partial, daysIn };
-}
-
-function deltasFromCumulative(daily: SeriesPoint[]): SeriesPoint[] {
- const deltas: SeriesPoint[] = [];
- for (let index = 1; index < daily.length; index += 1) {
- deltas.push({ d: daily[index].d, v: Math.max(daily[index].v - daily[index - 1].v, 0) });
- }
- return deltas;
-}
-
-function weeklySums(days: SeriesPoint[], lastDataDay: string | null): WeekPoint[] {
- const byWeek = new Map<string, number>();
- for (const point of days) {
- const week = weekStartOf(point.d);
- byWeek.set(week, (byWeek.get(week) ?? 0) + point.v);
- }
- return [...byWeek.entries()]
- .sort((a, b) => a[0].localeCompare(b[0]))
- .map(([weekStart, value]) => ({ weekStart, value, ...weekMeta(weekStart, lastDataDay) }));
-}
-
-function weeklyAverages(days: SeriesPoint[], lastDataDay: string | null): WeekPoint[] {
- const byWeek = new Map<string, { sum: number; count: number }>();
- for (const point of days) {
- const week = weekStartOf(point.d);
- const bucket = byWeek.get(week) ?? { sum: 0, count: 0 };
- bucket.sum += point.v;
- bucket.count += 1;
- byWeek.set(week, bucket);
- }
- return [...byWeek.entries()]
- .sort((a, b) => a[0].localeCompare(b[0]))
- .map(([weekStart, bucket]) => ({
- weekStart,
- value: Math.round(bucket.sum / bucket.count),
- ...weekMeta(weekStart, lastDataDay),
- }));
-}
-
-function starMilestones(daily: SeriesPoint[]): Milestone[] {
- const milestones: Milestone[] = [];
- let next = 0;
- for (const point of daily) {
- while (next < STAR_MILESTONES.length && point.v >= STAR_MILESTONES[next]) {
- const value = STAR_MILESTONES[next];
- milestones.push({ day: point.d, value, label: value >= 1_000 ? `${value / 1_000}k` : String(value) });
- next += 1;
- }
- }
- return milestones;
-}
-
-function niceTicks(max: number, count = 4): number[] {
- if (max <= 0) return [0];
- const rawStep = max / count;
- const power = 10 ** Math.floor(Math.log10(rawStep));
- const step = [1, 2, 2.5, 5, 10].map((mult) => mult * power).find((candidate) => candidate >= rawStep) ?? rawStep;
- const ticks: number[] = [];
- for (let tick = 0; tick <= max; tick += step) ticks.push(tick);
- return ticks;
-}
-
-function monthStartsBetween(firstDay: string, lastDay: string): string[] {
- const starts: string[] = [];
- const last = dayEpoch(lastDay);
- const cursor = new Date(`${firstDay.slice(0, 7)}-01T00:00:00Z`);
- cursor.setUTCMonth(cursor.getUTCMonth() + 1);
- while (cursor.getTime() <= last) {
- starts.push(cursor.toISOString().slice(0, 10));
- cursor.setUTCMonth(cursor.getUTCMonth() + 1);
- }
- return starts;
-}
-
-// ---------- chart builders (SVG strings) ----------
-
-type LineChartOptions = {
- points: SeriesPoint[];
- colorVar: string;
- unit: string;
- width: number;
- milestones?: Milestone[];
- events?: Array<{ day: string; label: string }>;
-};
-
-function lineChart(options: LineChartOptions): string {
- const { points, width } = options;
- if (points.length < 2) return `<p class="chart-empty">not enough data yet</p>`;
-
- const margin = { top: 34, right: 26, bottom: 30, left: 54 };
- const innerW = width - margin.left - margin.right;
- const innerH = LINE_H - margin.top - margin.bottom;
-
- const x0 = dayEpoch(points[0].d);
- const x1 = dayEpoch(points[points.length - 1].d);
- const yMaxData = Math.max(...points.map((point) => point.v));
- const ticks = niceTicks(yMaxData);
- const yMax = Math.max(ticks[ticks.length - 1], yMaxData) * 1.04;
-
- const xFor = (day: string): number => margin.left + ((dayEpoch(day) - x0) / (x1 - x0)) * innerW;
- const yFor = (value: number): number => margin.top + innerH - (value / yMax) * innerH;
-
- const coords = points.map((point) => ({ x: xFor(point.d), y: yFor(point.v), day: point.d, value: point.v }));
- const linePath = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(1)},${c.y.toFixed(1)}`).join("");
- const baseline = margin.top + innerH;
- const areaPath = `${linePath}L${coords[coords.length - 1].x.toFixed(1)},${baseline}L${coords[0].x.toFixed(1)},${baseline}Z`;
-
- const grid = ticks
- .map((tick) => {
- const y = yFor(tick).toFixed(1);
- return `<line class="grid" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"/>` +
- `<text class="tick" x="${margin.left - 8}" y="${y}" dy="0.32em" text-anchor="end">${fmtCompact(tick)}</text>`;
- })
- .join("");
-
- const monthLabels = monthStartsBetween(points[0].d, points[points.length - 1].d)
- .map((day) => {
- const x = xFor(day).toFixed(1);
- return `<text class="tick" x="${x}" y="${baseline + 20}" text-anchor="middle">${MONTHS[new Date(`${day}T00:00:00Z`).getUTCMonth()]}</text>`;
- })
- .join("");
-
- const milestoneMarks = (options.milestones ?? [])
- .map((milestone) => {
- const x = xFor(milestone.day);
- const y = yFor(milestone.value);
- return (
- `<line class="milestone-leader" x1="${x.toFixed(1)}" y1="${(y - 9).toFixed(1)}" x2="${x.toFixed(1)}" y2="${(y - 18).toFixed(1)}"/>` +
- `<circle class="dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4.5" style="fill:var(${options.colorVar})"/>` +
- `<text class="milestone-label" x="${x.toFixed(1)}" y="${(y - 23).toFixed(1)}" text-anchor="middle">${milestone.label}</text>`
- );
- })
- .join("");
-
- const events = (options.events ?? []).map((event) => {
- const x = xFor(event.day);
- const nearest = coords.reduce((best, c) => (Math.abs(c.x - x) < Math.abs(best.x - x) ? c : best), coords[0]);
- return { ...event, dotX: nearest.x, dotY: nearest.y };
- });
- const eventBaseX = Math.min(...events.map((event) => event.dotX), Infinity) - 12;
- const eventMarks = events
- .map((event, index) => {
- const labelY = margin.top + 14 + index * 16;
- return (
- `<line class="milestone-leader" x1="${(eventBaseX + 4).toFixed(1)}" y1="${(labelY + 4).toFixed(1)}" x2="${(event.dotX - 2).toFixed(1)}" y2="${(event.dotY - 6).toFixed(1)}"/>` +
- `<circle class="event-dot" cx="${event.dotX.toFixed(1)}" cy="${event.dotY.toFixed(1)}" r="4" style="stroke:var(${options.colorVar})"/>` +
- `<text class="milestone-label" x="${eventBaseX.toFixed(1)}" y="${labelY.toFixed(1)}" text-anchor="end">${escapeHtml(event.label)}</text>`
- );
- })
- .join("");
-
- const end = coords[coords.length - 1];
- const endDot = `<circle class="dot" cx="${end.x.toFixed(1)}" cy="${end.y.toFixed(1)}" r="4.5" style="fill:var(${options.colorVar})"/>`;
-
- // escape < so the JSON can never terminate the wrapping <script> tag
- const data = JSON.stringify({
- unit: options.unit,
- width,
- points: coords.map((c) => ({ x: Number(c.x.toFixed(1)), y: Number(c.y.toFixed(1)), day: c.day, value: c.value })),
- }).replace(/</g, "\\u003c");
-
- return `
- <div class="chart">
- <svg viewBox="0 0 ${width} ${LINE_H}" role="img" aria-label="${options.unit} over time" tabindex="0">
- ${grid}
- ${monthLabels}
- <path class="area" d="${areaPath}" style="fill:var(${options.colorVar})"/>
- <path class="line" d="${linePath}" style="stroke:var(${options.colorVar})"/>
- ${milestoneMarks}
- ${eventMarks}
- ${endDot}
- <line class="crosshair" y1="${margin.top}" y2="${baseline}" x1="-10" x2="-10" hidden></line>
- <circle class="hover-dot" r="4.5" cx="-10" cy="-10" style="fill:var(${options.colorVar})" hidden></circle>
- </svg>
- <div class="chart-tip" hidden></div>
- <script type="application/json">${data}</script>
- </div>`;
-}
-
-type BarChartOptions = {
- weeks: WeekPoint[];
- colorVar: string;
- unit: string;
- maxWeeks?: number;
-};
-
-function barChart(options: BarChartOptions): string {
- const weeks = options.weeks.slice(-(options.maxWeeks ?? 14));
- if (weeks.length === 0) return `<p class="chart-empty">not enough data yet</p>`;
-
- const margin = { top: 26, right: 12, bottom: 30, left: 44 };
- const innerW = BAR_W - margin.left - margin.right;
- const innerH = BAR_H - margin.top - margin.bottom;
-
- const yMaxData = Math.max(...weeks.map((week) => week.value));
- const ticks = niceTicks(yMaxData, 3);
- const yMax = Math.max(ticks[ticks.length - 1], yMaxData) * 1.06;
- const yFor = (value: number): number => margin.top + innerH - (value / yMax) * innerH;
- const baseline = margin.top + innerH;
-
- const band = innerW / weeks.length;
- const barWidth = Math.min(24, Math.max(band - 2, 4));
-
- const grid = ticks
- .filter((tick) => tick > 0)
- .map((tick) => {
- const y = yFor(tick).toFixed(1);
- return `<line class="grid" x1="${margin.left}" y1="${y}" x2="${BAR_W - margin.right}" y2="${y}"/>` +
- `<text class="tick" x="${margin.left - 7}" y="${y}" dy="0.32em" text-anchor="end">${fmtCompact(tick)}</text>`;
- })
- .join("");
-
- const maxValue = Math.max(...weeks.filter((week) => !week.partial).map((week) => week.value), 0);
- const labelStep = Math.ceil(weeks.length / 5);
-
- const bars = weeks
- .map((week, index) => {
- const xCenter = margin.left + band * index + band / 2;
- const x = xCenter - barWidth / 2;
- const yTop = yFor(week.value);
- const r = Math.min(4, barWidth / 2, Math.max(baseline - yTop, 0));
- const path =
- `M${x.toFixed(1)},${baseline}` +
- `L${x.toFixed(1)},${(yTop + r).toFixed(1)}` +
- `Q${x.toFixed(1)},${yTop.toFixed(1)} ${(x + r).toFixed(1)},${yTop.toFixed(1)}` +
- `L${(x + barWidth - r).toFixed(1)},${yTop.toFixed(1)}` +
- `Q${(x + barWidth).toFixed(1)},${yTop.toFixed(1)} ${(x + barWidth).toFixed(1)},${(yTop + r).toFixed(1)}` +
- `L${(x + barWidth).toFixed(1)},${baseline}Z`;
- const capLabel = !week.partial && week.value === maxValue && week.value > 0
- ? `<text class="cap-label" x="${xCenter.toFixed(1)}" y="${(yTop - 7).toFixed(1)}" text-anchor="middle">${fmtCompact(week.value)}</text>`
- : "";
- const axisLabel = index % labelStep === 0
- ? `<text class="tick" x="${xCenter.toFixed(1)}" y="${baseline + 20}" text-anchor="middle">${fmtDay(week.weekStart)}</text>`
- : "";
- const tipText = week.partial
- ? `week of ${fmtDay(week.weekStart)} · ${fmtInt(week.value)} ${options.unit} · day ${week.daysIn}/7`
- : `week of ${fmtDay(week.weekStart)} · ${fmtInt(week.value)} ${options.unit}`;
- return (
- `<g class="bar${week.partial ? " partial" : ""}" data-tip="${escapeHtml(tipText)}">` +
- `<rect class="hit" x="${(margin.left + band * index).toFixed(1)}" y="${margin.top}" width="${band.toFixed(1)}" height="${innerH}"></rect>` +
- `<path class="fill" d="${path}" style="fill:var(${options.colorVar})"/>` +
- `</g>${capLabel}${axisLabel}`
- );
- })
- .join("");
-
- return `
- <div class="chart">
- <svg viewBox="0 0 ${BAR_W} ${BAR_H}" role="img" aria-label="${options.unit} per week">
- ${grid}
- <line class="grid strong" x1="${margin.left}" y1="${baseline}" x2="${BAR_W - margin.right}" y2="${baseline}"/>
- ${bars}
- </svg>
- <div class="chart-tip" hidden></div>
- </div>`;
-}
-
-// ---------- tables, rank lists, timeline ----------
-
-function detailsTable(headers: [string, string], rows: string): string {
- return `
- <details class="chart-table">
- <summary>view as table</summary>
- <div class="chart-table-scroll">
- <table><thead><tr><th>${headers[0]}</th><th>${headers[1]}</th></tr></thead><tbody>${rows}</tbody></table>
- </div>
- </details>`;
-}
-
-function weeklyTable(weeks: WeekPoint[], unit: string): string {
- const rows = [...weeks]
- .slice(-14)
- .reverse()
- .map(
- (week) =>
- `<tr><td>week of ${fmtDay(week.weekStart)}</td><td>${fmtInt(week.value)}${week.partial ? ` (day ${week.daysIn}/7)` : ""}</td></tr>`,
- )
- .join("");
- return detailsTable(["week", unit], rows);
-}
-
-function cumulativeTable(points: SeriesPoint[], unit: string): string {
- const byMonth = new Map<string, SeriesPoint>();
- for (const point of points) byMonth.set(point.d.slice(0, 7), point);
- const rows = [...byMonth.values()]
- .map((point) => `<tr><td>${fmtDay(point.d)}</td><td>${fmtInt(point.v)}</td></tr>`)
- .join("");
- return detailsTable(["end of month", unit], rows);
-}
-
-function flagEmoji(code: string): string {
- if (!/^[A-Z]{2}$/.test(code)) return "";
- return String.fromCodePoint(...[...code].map((char) => 0x1f1a5 + char.charCodeAt(0)));
-}
-
-function countryName(code: string): string {
- try {
- return new Intl.DisplayNames(["en"], { type: "region" }).of(code) ?? code;
- } catch {
- return code;
- }
-}
-
-function rankList(rows: Array<{ label: string; value: number }>, unit: string): string {
- if (rows.length === 0) return `<p class="chart-empty">no data yet</p>`;
- const max = Math.max(...rows.map((row) => row.value), 1);
- const items = rows
- .map(
- (row) =>
- `<div class="rank-row" title="${escapeHtml(row.label)} · ${fmtInt(row.value)} ${unit}">` +
- `<span class="rank-name">${escapeHtml(row.label)}</span>` +
- `<span class="rank-bar"><span class="rank-fill" style="width:${((row.value / max) * 100).toFixed(1)}%"></span></span>` +
- `<span class="rank-value">${fmtCompact(row.value)}</span>` +
- `</div>`,
- )
- .join("");
- return `<div class="rank-list">${items}</div>`;
-}
-
-function timelineTerminal(stats: StatsJson, milestones: Milestone[]): string {
- const lines: Array<{ day: string; text: string; note: string }> = [];
- for (const entry of stats.timeline) {
- const note = entry.note ?? `day ${daysBetween(stats.bornDay, entry.d)}`;
- lines.push({ day: entry.d, text: escapeHtml(entry.label), note: escapeHtml(note) });
- }
- let previousDay = stats.timeline.find((entry) => entry.label.includes("first star"))?.d ?? stats.bornDay;
- for (const milestone of milestones) {
- lines.push({ day: milestone.day, text: `★ ${fmtInt(milestone.value)}`, note: `+${daysBetween(previousDay, milestone.day)} days` });
- previousDay = milestone.day;
- }
- lines.sort((a, b) => a.day.localeCompare(b.day));
- lines.push({ day: todayUtc(), text: `★ ${fmtInt(stats.stars.total)}`, note: "today" });
-
- const body = lines
- .map(
- (line) =>
- `<div class="term-line"><span class="term-date">${fmtDay(line.day)}</span><span class="term-text">${line.text}</span><span class="term-note">${line.note}</span></div>`,
- )
- .join("");
-
- return `
- <div class="milestone-term" role="img" aria-label="herdr timeline">
- <div class="term-line term-cmd-line"><span class="term-prompt">$</span><span class="term-cmd">herdr stats --timeline</span></div>
- ${body}
- </div>`;
-}
-
-// ---------- interaction ----------
-
-function attachChartInteractions(root: ParentNode): void {
- root.querySelectorAll<HTMLElement>(".chart").forEach((chart) => {
- const dataEl = chart.querySelector<HTMLScriptElement>("script[type='application/json']");
- const tip = chart.querySelector<HTMLElement>(".chart-tip");
- const svg = chart.querySelector<SVGSVGElement>("svg");
- if (!svg || !tip) return;
-
- if (dataEl?.textContent) {
- const data = JSON.parse(dataEl.textContent) as {
- unit: string;
- width: number;
- points: Array<{ x: number; y: number; day: string; value: number }>;
- };
- const crosshair = svg.querySelector<SVGLineElement>(".crosshair");
- const hoverDot = svg.querySelector<SVGCircleElement>(".hover-dot");
- if (!crosshair || !hoverDot) return;
- const points = data.points;
- let active = -1;
-
- const showIndex = (index: number): void => {
- if (index < 0 || index >= points.length) return;
- active = index;
- const point = points[index];
- crosshair.setAttribute("x1", String(point.x));
- crosshair.setAttribute("x2", String(point.x));
- crosshair.hidden = false;
- hoverDot.setAttribute("cx", String(point.x));
- hoverDot.setAttribute("cy", String(point.y));
- hoverDot.hidden = false;
- tip.innerHTML = "";
- const strong = document.createElement("strong");
- strong.textContent = `${point.value.toLocaleString("en-US")} ${data.unit}`;
- tip.appendChild(strong);
- tip.appendChild(document.createElement("br"));
- tip.appendChild(document.createTextNode(fmtDay(point.day)));
- const rect = svg.getBoundingClientRect();
- const scale = rect.width / data.width;
- tip.style.left = `${point.x * scale}px`;
- tip.style.top = `${point.y * scale}px`;
- tip.hidden = false;
- };
-
- const hide = (): void => {
- active = -1;
- crosshair.hidden = true;
- hoverDot.hidden = true;
- tip.hidden = true;
- };
-
- svg.addEventListener("pointermove", (event) => {
- const rect = svg.getBoundingClientRect();
- const x = ((event.clientX - rect.left) / rect.width) * data.width;
- let best = 0;
- let bestDist = Infinity;
- for (let i = 0; i < points.length; i += 1) {
- const dist = Math.abs(points[i].x - x);
- if (dist < bestDist) {
- bestDist = dist;
- best = i;
- }
- }
- showIndex(best);
- });
- svg.addEventListener("pointerleave", hide);
- svg.addEventListener("focus", () => showIndex(points.length - 1));
- svg.addEventListener("blur", hide);
- svg.addEventListener("keydown", (event) => {
- if (event.key === "ArrowLeft") {
- showIndex(Math.max(active - 1, 0));
- event.preventDefault();
- }
- if (event.key === "ArrowRight") {
- showIndex(Math.min(active + 1, points.length - 1));
- event.preventDefault();
- }
- });
- }
-
- chart.querySelectorAll<SVGGElement>(".bar").forEach((bar) => {
- bar.addEventListener("pointerenter", () => {
- const hit = bar.querySelector(".hit");
- if (!hit) return;
- const svgRect = svg.getBoundingClientRect();
- const barRect = hit.getBoundingClientRect();
- tip.textContent = bar.getAttribute("data-tip");
- tip.style.left = `${barRect.left - svgRect.left + barRect.width / 2}px`;
- tip.style.top = `${barRect.top - svgRect.top + 18}px`;
- tip.hidden = false;
- bar.classList.add("active");
- });
- bar.addEventListener("pointerleave", () => {
- tip.hidden = true;
- bar.classList.remove("active");
- });
- });
- });
-}
-
-// ---------- live star counter ----------
-
-function startLiveStars(initial: number): void {
- const hero = document.getElementById("star-count");
- if (!hero) return;
- let current = initial;
- const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
-
- const renderCount = (value: number): void => {
- hero.textContent = Math.round(value).toLocaleString("en-US");
- };
- renderCount(current);
-
- const animateTo = (target: number): void => {
- if (reduceMotion || target <= current) {
- current = Math.max(current, target);
- renderCount(current);
- return;
- }
- const from = current;
- const start = performance.now();
- const duration = 900;
- const frame = (now: number): void => {
- const t = Math.min((now - start) / duration, 1);
- const eased = 1 - (1 - t) ** 3;
- renderCount(from + (target - from) * eased);
- if (t < 1) requestAnimationFrame(frame);
- else current = target;
- };
- requestAnimationFrame(frame);
- };
-
- const poll = (): void => {
- fetch(REPO_API_URL)
- .then((res) => (res.ok ? res.json() : null))
- .then((repo) => {
- if (repo && typeof repo.stargazers_count === "number") animateTo(repo.stargazers_count);
- })
- .catch(() => {
- // keep last known number; the counter must never break the page
- });
- };
- poll();
- setInterval(poll, 60_000);
-}
-
-// ---------- page assembly ----------
-
-function setText(id: string, text: string): void {
- const el = document.getElementById(id);
- if (el) el.textContent = text;
-}
-
-function setHtml(id: string, html: string): void {
- const el = document.getElementById(id);
- if (el) el.innerHTML = html;
-}
-
-function render(stats: StatsJson): void {
- const starsDaily = stats.stars.daily;
- const lastStarDay = starsDaily.at(-1)?.d ?? null;
- const milestones = starMilestones(starsDaily);
- const badges = stats.timeline
- .filter((entry) => entry.badge)
- .map((entry) => ({ day: entry.d, label: entry.badge ?? "" }));
-
- // hero
- const last7d = (() => {
- const last = starsDaily.at(-1);
- if (!last) return null;
- const weekAgoDay = new Date(dayEpoch(last.d) - 7 * 86_400_000).toISOString().slice(0, 10);
- const anchor = [...starsDaily].reverse().find((point) => point.d <= weekAgoDay);
- return anchor ? stats.stars.total - anchor.v : null;
- })();
- if (last7d !== null) setText("hero-note", `+${fmtInt(last7d)} stars in the last 7 days`);
- if (starsDaily[0]) setText("stars-since", `since ${fmtDay(starsDaily[0].d)}`);
- setHtml(
- "chart-stars",
- lineChart({ points: starsDaily, colorVar: "--accent", unit: "stars", width: 640, milestones, events: badges }) +
- cumulativeTable(starsDaily, "stars"),
- );
-
- // KPIs
- const downloadsDeltas = deltasFromCumulative(stats.downloads.daily);
- const lastDownloadDay = stats.downloads.daily.at(-1)?.d ?? null;
- const downloadsWeekly = weeklySums(downloadsDeltas, lastDownloadDay);
- const lastFullWeek = [...downloadsWeekly].reverse().find((week) => !week.partial);
- setText("kpi-installs", fmtInt(stats.downloads.github + stats.downloads.brew));
- setHtml(
- "kpi-installs-sub",
- `${lastFullWeek ? `<span class="up">+${fmtInt(lastFullWeek.value)}</span> last week · ` : ""}releases + homebrew`,
- );
-
- const trafficDaily = stats.traffic.daily;
- const lastSevenTraffic = trafficDaily.slice(-7);
- const visitorsAvg = lastSevenTraffic.length
- ? Math.round(lastSevenTraffic.reduce((sum, point) => sum + point.v, 0) / lastSevenTraffic.length)
- : null;
- setText("kpi-visitors", visitorsAvg === null ? "—" : fmtCompact(visitorsAvg));
-
- setText("kpi-releases", fmtInt(stats.releases.shipped));
- setText("kpi-age", fmtInt(daysBetween(stats.bornDay, todayUtc())));
- setText("kpi-age-sub", `born ${fmtDay(stats.bornDay)}, ${stats.bornDay.slice(0, 4)}`);
-
- // weekly velocity
- const starsDeltas = deltasFromCumulative(starsDaily);
- setHtml(
- "chart-stars-week",
- barChart({ weeks: weeklySums(starsDeltas, lastStarDay), colorVar: "--accent", unit: "stars" }) +
- weeklyTable(weeklySums(starsDeltas, lastStarDay), "stars"),
- );
- setHtml(
- "chart-downloads-week",
- barChart({ weeks: downloadsWeekly, colorVar: "--green", unit: "downloads" }) + weeklyTable(downloadsWeekly, "downloads"),
- );
- const visitorsWeekly = weeklyAverages(trafficDaily, trafficDaily.at(-1)?.d ?? null);
- setHtml(
- "chart-visitors-week",
- barChart({ weeks: visitorsWeekly, colorVar: "--yellow", unit: "avg daily visitors" }) +
- weeklyTable(visitorsWeekly, "avg daily visitors"),
- );
-
- // downloads cumulative
- const trackedSince = stats.downloads.daily[0]?.d;
- setText(
- "downloads-sub",
- `GitHub release downloads, cumulative${trackedSince ? `, tracked since ${fmtDay(trackedSince)}` : ""}. ` +
- `Homebrew adds ${fmtInt(stats.downloads.brew)} installs on top (not shown in the curve).`,
- );
- setHtml(
- "chart-downloads",
- lineChart({ points: stats.downloads.daily, colorVar: "--green", unit: "downloads", width: 960 }) +
- cumulativeTable(stats.downloads.daily, "downloads"),
- );
-
- // traffic
- setText("traffic-sub", `herdr.dev traffic over the last ${stats.traffic.windowDays} days, measured by Cloudflare.`);
- setHtml(
- "rank-countries",
- rankList(
- stats.traffic.topCountries.map((row) => ({ label: `${flagEmoji(row.code)} ${countryName(row.code)}`.trim(), value: row.visits })),
- "visits",
- ),
- );
- setHtml(
- "rank-referrers",
- rankList(stats.traffic.topReferrers.map((row) => ({ label: row.host, value: row.visits })), "visits"),
- );
- setHtml(
- "rank-paths",
- rankList(stats.traffic.topPaths.map((row) => ({ label: row.path, value: row.views })), "page views"),
- );
-
- // timeline + freshness
- setHtml("timeline", timelineTerminal(stats, milestones));
- const generated = new Date(stats.generatedAt);
- const ageHours = (Date.now() - generated.getTime()) / 3_600_000;
- const stampText = `Data updated ${fmtDay(stats.generatedAt.slice(0, 10))}, ${stats.generatedAt.slice(11, 16)} UTC.`;
- setText("updated-note", stampText);
- if (ageHours > 6) {
- const note = document.createElement("p");
- note.className = "stats-stale";
- note.textContent = `note: snapshot is ${Math.round(ageHours)} hours old — the star counter above is still live.`;
- document.getElementById("sources-note")?.after(note);
- }
-
- attachChartInteractions(document);
- startLiveStars(stats.stars.total);
-}
-
-function assertStatsShape(stats: StatsJson): void {
- const ok =
- stats.schemaVersion === 1 &&
- typeof stats.generatedAt === "string" &&
- typeof stats.bornDay === "string" &&
- typeof stats.stars?.total === "number" &&
- Array.isArray(stats.stars?.daily) &&
- typeof stats.downloads?.github === "number" &&
- typeof stats.downloads?.brew === "number" &&
- Array.isArray(stats.downloads?.daily) &&
- typeof stats.releases?.shipped === "number" &&
- Array.isArray(stats.traffic?.daily) &&
- Array.isArray(stats.traffic?.topCountries) &&
- Array.isArray(stats.traffic?.topReferrers) &&
- Array.isArray(stats.traffic?.topPaths) &&
- Array.isArray(stats.timeline);
- if (!ok) throw new Error("stats snapshot has unexpected shape");
-}
-
-export function initStatsPage(): void {
- fetch(STATS_URL, { headers: { accept: "application/json" } })
- .then((res) => {
- if (!res.ok) throw new Error(`stats fetch failed: ${res.status}`);
- return res.json() as Promise<StatsJson>;
- })
- .then((stats) => {
- assertStatsShape(stats);
- render(stats);
- })
- .catch((error) => {
- console.error("stats page render failed", error);
- setText("hero-note", "stats are temporarily unavailable, try again in a minute");
- startLiveStars(0);
- });
-}
diff --git a/website/src/site-data.ts b/website/src/site-data.ts
new file mode 100644
index 00000000..94836fe7
--- /dev/null
+++ b/website/src/site-data.ts
@@ -0,0 +1,103 @@
+import { readdir, readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+
+/* The numbers the site's chrome shows, resolved once per build.
+ Stars, installs and plugin counts come from the same R2 snapshots /stats/
+ and /plugins/ read; the version comes from latest.json, which release CI
+ owns. Nothing here is hand-maintained, so no figure on the site can go
+ stale independently of a deploy.
+
+ Memoised because SiteLayout renders on every blog post, and a build that
+ renders many pages should still fetch once. */
+
+export type SiteStats = {
+ starCount?: number;
+ installCount?: number;
+ pluginCount?: number;
+};
+
+async function snapshot(url: string): Promise<any | null> {
+ try {
+ const res = await fetch(url);
+ return res.ok ? await res.json() : null;
+ } catch {
+ return null;
+ }
+}
+
+async function load(): Promise<SiteStats> {
+ const [stats, plugins] = await Promise.all([
+ snapshot('https://assets.herdr.dev/stats/stats.json'),
+ snapshot('https://assets.herdr.dev/plugins/index.json'),
+ ]);
+
+ return {
+ starCount: stats?.stars?.total,
+ installCount: stats?.downloads
+ ? (stats.downloads.github || 0) + (stats.downloads.brew || 0)
+ : undefined,
+ /* `pluginCount` counts validated manifests, which is what the marketplace
+ lists. `source.totalCount` is the raw GitHub topic search and still
+ includes blacklisted repositories and ones with no manifest. */
+ pluginCount: plugins?.pluginCount ?? plugins?.plugins?.length,
+ };
+}
+
+let statsPromise: Promise<SiteStats> | null = null;
+
+export function siteStats(): Promise<SiteStats> {
+ statsPromise ??= load();
+ return statsPromise;
+}
+
+/* latest.json carries the full release notes, so read and pick rather than
+ importing it and bundling all of that into the page. */
+async function readVersion(): Promise<string | null> {
+ try {
+ const raw = await readFile(new URL('../latest.json', import.meta.url), 'utf8');
+ return JSON.parse(raw).version ?? null;
+ } catch {
+ return null;
+ }
+}
+
+let versionPromise: Promise<string | null> | null = null;
+
+export function releaseVersion(): Promise<string | null> {
+ versionPromise ??= readVersion();
+ return versionPromise;
+}
+
+/* How many agents Herdr detects out of the box, counted from the bundled
+ manifests. The site is built from the same repo as the binary, so the number
+ tracks the code and cannot drift the way a written-down one would — add a
+ manifest and the homepage says so on the next deploy. */
+async function countAgentManifests(): Promise<number | null> {
+ /* Resolved from cwd, not import.meta.url: once Vite bundles this module for
+ the build the module URL no longer points at the source file, so a
+ relative URL silently found nothing and the line vanished from dist while
+ still rendering in dev. Both candidates cover running the build from
+ website/ or from the repo root. */
+ for (const rel of ['../src/detect/manifests', 'src/detect/manifests']) {
+ try {
+ const files = await readdir(resolve(process.cwd(), rel));
+ const count = files.filter((f) => f.endsWith('.toml')).length;
+ if (count > 0) return count;
+ } catch {
+ /* try the next candidate */
+ }
+ }
+ return null;
+}
+
+let agentCountPromise: Promise<number | null> | null = null;
+
+export function agentCount(): Promise<number | null> {
+ agentCountPromise ??= countAgentManifests();
+ return agentCountPromise;
+}
+
+/* A fetch that failed at build shows an em dash. A stale hardcoded number
+ would look authoritative and be wrong, which is worse. */
+export const fmt = (n: unknown): string =>
+ typeof n === 'number' ? n.toLocaleString('en-US') : '—';
diff --git a/website/src/styles/starlight.css b/website/src/styles/starlight.css
index 0747bcc4..c7b82f67 100644
--- a/website/src/styles/starlight.css
+++ b/website/src/styles/starlight.css
@@ -16,47 +16,65 @@
--sl-sidebar-width: 18.5rem;
}
+/* Docs share the site's ground and mauve. Prose stays in a reading sans
+ rather than the marketing mono — docs is a tool, not a statement.
+
+ The ramp is the marketing scale, not a separate one: the same near-neutral
+ ground and the same text triad, so the two surfaces cannot drift apart on
+ the next palette change. Chroma is drained for the reason it was drained
+ there — saturated blue at low luminance softens the edges of light text,
+ and a side-by-side read easier without it. Surfaces are luminance-matched
+ to what they replace; text is taken straight from the marketing scale,
+ which lands every step at or above its old contrast except gray-3, and
+ that one stays clear of AA. Contrast on the ground: white 14.7, gray-1
+ 11.9, gray-2 9.3, gray-3 5.3, gray-4 4.2, accent 8.8. */
:root,
:root[data-theme="dark"] {
- --sl-color-accent-low: #26314f;
- --sl-color-accent: #7aa2f7;
- --sl-color-accent-high: #a9c2ff;
- --sl-color-white: #d5dcff;
- --sl-color-gray-1: #c0caf5;
- --sl-color-gray-2: #a9b1d6;
- --sl-color-gray-3: #737aa2;
- --sl-color-gray-4: #565f89;
- --sl-color-gray-5: #414868;
- --sl-color-gray-6: #2f3549;
- --sl-color-gray-7: #24283b;
- --sl-color-black: #1a1b26;
- --sl-color-bg: #1a1b26;
- --sl-color-bg-nav: #202331;
- --sl-color-bg-sidebar: #202331;
- --sl-color-bg-inline-code: #24283b;
- --sl-color-hairline: #2f3549;
- --sl-color-hairline-light: #414868;
-}
-
+ --sl-color-accent-low: #3f364b;
+ --sl-color-accent: #cba6f7;
+ --sl-color-accent-high: #ddc4fb;
+ --sl-color-white: #eae8ee;
+ --sl-color-gray-1: #d5d1dd;
+ --sl-color-gray-2: #bdb9c9;
+ --sl-color-gray-3: #8e8a98;
+ --sl-color-gray-4: #7c7887;
+ --sl-color-gray-5: #4a4851;
+ --sl-color-gray-6: #35353d;
+ --sl-color-gray-7: #26262b;
+ --sl-color-black: #17171a;
+ --sl-color-bg: #17171a;
+ --sl-color-bg-nav: #1e1e22;
+ --sl-color-bg-sidebar: #1e1e22;
+ --sl-color-bg-inline-code: #26262b;
+ --sl-color-hairline: #26262b;
+ --sl-color-hairline-light: #35353d;
+}
+
+/* the brand display face, same pairing as the marketing pages:
+ Archivo for the branded headings, mono for everything code-adjacent */
+:root { --sl-font-display: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif; }
+
+/* paper — the same ground the marketing pages use, with the mauve dropped to
+ a value that holds on a light background */
:root[data-theme="light"] {
- --sl-color-accent-low: #c4c8da;
- --sl-color-accent: #2e7de9;
- --sl-color-accent-high: #1f2f66;
- --sl-color-white: #1f2f66;
- --sl-color-gray-1: #3760bf;
- --sl-color-gray-2: #68709a;
- --sl-color-gray-3: #8990b3;
- --sl-color-gray-4: #a8aecb;
- --sl-color-gray-5: #b6bbd1;
- --sl-color-gray-6: #c4c8da;
- --sl-color-gray-7: #d8d9e0;
- --sl-color-black: #e1e2e7;
- --sl-color-bg: #e1e2e7;
- --sl-color-bg-nav: #d8d9e0;
- --sl-color-bg-sidebar: #d8d9e0;
- --sl-color-bg-inline-code: #c4c8da;
- --sl-color-hairline: #b6bbd1;
- --sl-color-hairline-light: #a8aecb;
+ --sl-color-accent-low: #ded3f2;
+ --sl-color-accent: #8839ef;
+ --sl-color-accent-high: #5b2ba3;
+ --sl-color-white: #15140f;
+ --sl-color-gray-1: #26241d;
+ --sl-color-gray-2: #55534a;
+ --sl-color-gray-3: #6f6c60;
+ --sl-color-gray-4: #86826f;
+ --sl-color-gray-5: #a8a493;
+ --sl-color-gray-6: #cbc5b6;
+ --sl-color-gray-7: #e2ded4;
+ --sl-color-black: #efece5;
+ --sl-color-bg: #efece5;
+ --sl-color-bg-nav: #e7e3da;
+ --sl-color-bg-sidebar: #e7e3da;
+ --sl-color-bg-inline-code: #ddd8cc;
+ --sl-color-hairline: #e2ded4;
+ --sl-color-hairline-light: #cbc5b6;
}
.site-title {
@@ -103,16 +121,17 @@
.hero h1 {
letter-spacing: -0.03em;
line-height: 1.1;
- font-weight: 400;
+ font-weight: 900;
font-size: clamp(2rem, 5vw, 3.2rem);
- font-family: var(--sl-font-mono);
+ font-family: var(--sl-font-display);
+ letter-spacing: -0.045em;
}
.sl-markdown-content h2 {
margin-top: 2.4rem;
- letter-spacing: -0.02em;
- font-family: var(--sl-font-mono);
- font-weight: 400;
+ letter-spacing: -0.03em;
+ font-family: var(--sl-font-display);
+ font-weight: 800;
}
.sl-markdown-content h3 {