aboutsummaryrefslogtreecommitdiff
path: root/website/src/pages
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-08-03 21:33:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-08-03 21:39:25 +0300
commit22bb476d646ccadec71f584b90c2a135fec92929 (patch)
treec6d4b6dffe0b8891c52e7e0cc10c8e6051734c30 /website/src/pages
parenteacea2daf0b72973173b728936b27478374f2cd2 (diff)
feat(website): rebuild marketing pages on new brand chassis
Diffstat (limited to 'website/src/pages')
-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
8 files changed, 369 insertions, 367 deletions
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>