diff options
Diffstat (limited to 'website/src')
30 files changed, 0 insertions, 4390 deletions
diff --git a/website/src/components/AuthorCard.astro b/website/src/components/AuthorCard.astro deleted file mode 100644 index ba3a1014..00000000 --- a/website/src/components/AuthorCard.astro +++ /dev/null @@ -1,30 +0,0 @@ ---- -const name = 'Can Celik'; -const role = 'Building Herdr'; -const photo = '/assets/author-can.jpeg'; -const x = 'https://x.com/cancelik'; -const email = 'can@herdr.dev'; ---- - -<aside class="author-card" aria-label="About the author"> - <img class="author-photo" src={photo} width="72" height="72" alt={name} loading="lazy" decoding="async" /> - <div class="author-body"> - <p class="author-name">{name}</p> - <p class="author-role">{role}</p> - <div class="author-links"> - <a href={x} target="_blank" rel="me noopener" aria-label={`${name} on X`}> - <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="currentColor"> - <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> - <span>X</span> - </a> - <a href={`mailto:${email}`} aria-label={`Email ${name}`}> - <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> - <rect x="2.5" y="4.5" width="19" height="15" rx="2" /> - <path d="m3 6 9 7 9-7" /> - </svg> - <span>Email</span> - </a> - </div> - </div> -</aside> diff --git a/website/src/components/Banner.astro b/website/src/components/Banner.astro deleted file mode 100644 index cc6567a6..00000000 --- a/website/src/components/Banner.astro +++ /dev/null @@ -1,75 +0,0 @@ ---- -import versions from '../data/docs-versions.json'; -import { docsRoute, docsTargetHref } from '../docs-path'; - -const { entry, locale, isFallback } = Astro.locals.starlightRoute; -const { banner } = entry.data; - -const issuesUrl = 'https://github.com/herdrdev/herdr/issues/new?template=translation.yml'; -const translationNotices = { - ja: `このページの翻訳は LLM によって生成されています。誤りに気づいた場合は <a href="${issuesUrl}">GitHub で issue を開いて</a>お知らせください。`, - 'zh-cn': `本页面的翻译由 LLM 生成。如果你发现翻译有误,请<a href="${issuesUrl}">在 GitHub 上提交 issue</a> 告诉我们。`, -}; -const translationNotice = locale && !isFallback ? translationNotices[locale] : undefined; -const docs = docsRoute(Astro.url.pathname); -const archivedVersion = - docs.target !== 'stable' && docs.target !== 'preview' && docs.target !== versions.current - ? docs.target - : undefined; -const latestHref = docsTargetHref(docs, 'stable', versions.scopes); -const archivedNotices = { - root: `This page documents Herdr ${archivedVersion}. <a href="${latestHref}">Switch to the latest release.</a>`, - ja: `このページは Herdr ${archivedVersion} のドキュメントです。<a href="${latestHref}">最新版に切り替えてください。</a>`, - 'zh-cn': `本页面是 Herdr ${archivedVersion} 的文档。<a href="${latestHref}">切换到最新版本。</a>`, -}; -const archivedNotice = archivedVersion ? archivedNotices[locale ?? 'root'] : undefined; ---- - -{banner && <div class="sl-banner" data-pagefind-ignore set:html={banner.content} />} -{archivedNotice && ( - <div class="version-notice" data-pagefind-ignore set:html={archivedNotice} /> -)} -{translationNotice && ( - <div class="translation-notice" data-pagefind-ignore set:html={translationNotice} /> -)} - -<style> - @layer starlight.core { - .sl-banner { - --__sl-banner-text: var(--sl-color-banner-text, var(--sl-color-text-invert)); - padding: var(--sl-nav-pad-y) var(--sl-nav-pad-x); - background-color: var(--sl-color-banner-bg, var(--sl-color-bg-accent)); - color: var(--__sl-banner-text); - line-height: var(--sl-line-height-headings); - text-align: center; - text-wrap: balance; - box-shadow: var(--sl-shadow-sm); - } - .sl-banner :global(a) { - color: var(--__sl-banner-text); - } - - .translation-notice, - .version-notice { - padding: 0.5rem var(--sl-nav-pad-x); - background-color: var(--sl-color-bg-nav); - border-bottom: 1px solid var(--sl-color-hairline); - color: var(--sl-color-gray-2); - font-size: var(--sl-text-xs); - line-height: var(--sl-line-height-headings); - text-align: center; - text-wrap: balance; - } - .version-notice { - background-color: var(--sl-color-orange-low); - border-bottom-color: var(--sl-color-orange); - color: var(--sl-color-orange-high); - } - .translation-notice :global(a) { - color: var(--sl-color-text-accent); - } - .version-notice :global(a) { - color: var(--sl-color-orange-high); - } - } -</style> diff --git a/website/src/components/ConfigReference.astro b/website/src/components/ConfigReference.astro deleted file mode 100644 index 33894993..00000000 --- a/website/src/components/ConfigReference.astro +++ /dev/null @@ -1,204 +0,0 @@ ---- -import stableReference from '../data/config-reference.json'; -import previewReference from '../data/config-reference-preview.json'; -import versionReferences from '../data/config-reference-versions.json'; -import { docsVersion } from '../docs-path'; - -interface Props { - placeholder?: string; - noMatches?: string; - keyCountLabel?: string; -} - -const { - placeholder = 'filter keys, e.g. sidebar, prefix, sound…', - noMatches = 'No keys match this filter.', - keyCountLabel = 'keys', -} = Astro.props; - -const version = docsVersion(Astro.url.pathname); -const archivedReferences = versionReferences as Record<string, typeof stableReference>; -const reference = version === 'preview' - ? previewReference - : version === 'stable' - ? stableReference - : archivedReferences[version]; -if (!reference) { - throw new Error(`documentation version ${version} has no archived configuration reference`); -} -const totalKeys = reference.sections.reduce((sum, section) => sum + section.keys.length, 0); ---- - -<div class="config-reference" data-count-label={keyCountLabel}> - <div class="config-reference-controls"> - <input - type="search" - class="config-reference-filter" - placeholder={placeholder} - autocomplete="off" - spellcheck="false" - aria-label={placeholder} - /> - <p class="config-reference-meta" aria-live="polite">{totalKeys} {keyCountLabel}</p> - </div> - - {reference.sections.map((section) => ( - <section class="config-reference-section"> - <h2 class="config-reference-title" id={`ref-${section.id}`}>{section.title}</h2> - <ul class="config-reference-list"> - {section.keys.map((entry) => ( - <li - class="config-reference-row" - data-search={`${entry.key} ${entry.description} ${(entry.values ?? []).join(' ')}`.toLowerCase()} - > - <div class="config-reference-head"> - <code class="config-reference-key">{entry.key}</code> - <span class="config-reference-type">{entry.type}</span> - {entry.default && ( - <span class="config-reference-default"> - default <code>{entry.default}</code> - </span> - )} - </div> - <p class="config-reference-desc"> - {entry.description} - {entry.values && ( - <span class="config-reference-values"> - {entry.values.map((value) => ( - <code>{value}</code> - ))} - </span> - )} - </p> - </li> - ))} - </ul> - </section> - ))} - - <p class="config-reference-empty" hidden>{noMatches}</p> -</div> - -<style> - .config-reference-controls { - display: flex; - align-items: baseline; - gap: 1rem; - margin-top: 1rem; - } - - .config-reference-filter { - flex: 1; - font: inherit; - font-family: var(--sl-font-mono); - font-size: var(--sl-text-sm); - color: var(--sl-color-white); - background: var(--sl-color-bg-inline-code); - border: 1px solid var(--sl-color-hairline-light); - border-radius: 0.4rem; - padding: 0.5rem 0.75rem; - } - - .config-reference-filter:focus { - outline: 2px solid var(--sl-color-accent); - outline-offset: -1px; - } - - .config-reference-meta { - margin: 0; - font-size: var(--sl-text-xs); - color: var(--sl-color-gray-3); - white-space: nowrap; - } - - .config-reference-title { - margin-top: 2rem; - } - - .config-reference-list { - list-style: none; - padding: 0; - margin: 0.75rem 0 0; - } - - .config-reference-row { - padding: 0.6rem 0; - border-bottom: 1px solid var(--sl-color-hairline); - } - - .config-reference-row[hidden] { - display: none; - } - - .config-reference-head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.6rem; - } - - .config-reference-key { - font-weight: 600; - } - - .config-reference-type { - font-size: var(--sl-text-xs); - color: var(--sl-color-gray-3); - border: 1px solid var(--sl-color-hairline-light); - border-radius: 0.3rem; - padding: 0 0.35rem; - } - - .config-reference-default { - font-size: var(--sl-text-xs); - color: var(--sl-color-gray-3); - } - - .config-reference-desc { - margin: 0.25rem 0 0; - font-size: var(--sl-text-sm); - color: var(--sl-color-gray-2); - } - - .config-reference-values { - display: inline-flex; - flex-wrap: wrap; - gap: 0.3rem; - margin-inline-start: 0.4rem; - } - - .config-reference-empty { - margin-top: 1.5rem; - color: var(--sl-color-gray-3); - } -</style> - -<script> - for (const root of document.querySelectorAll('.config-reference')) { - const filter = root.querySelector('.config-reference-filter'); - const meta = root.querySelector('.config-reference-meta'); - const empty = root.querySelector('.config-reference-empty'); - const sections = [...root.querySelectorAll('.config-reference-section')]; - const rows = [...root.querySelectorAll('.config-reference-row')]; - const countLabel = root.dataset.countLabel; - - filter?.addEventListener('input', () => { - const terms = filter.value.trim().toLowerCase().split(/\s+/).filter(Boolean); - let visible = 0; - - for (const row of rows) { - const haystack = row.dataset.search ?? ''; - const match = terms.every((term) => haystack.includes(term)); - row.hidden = !match; - if (match) visible += 1; - } - - for (const section of sections) { - section.hidden = !section.querySelector('.config-reference-row:not([hidden])'); - } - - if (meta) meta.textContent = `${visible} ${countLabel}`; - if (empty) empty.hidden = visible > 0; - }); - } -</script> diff --git a/website/src/components/DocsVersionSelect.astro b/website/src/components/DocsVersionSelect.astro deleted file mode 100644 index 5ccd856c..00000000 --- a/website/src/components/DocsVersionSelect.astro +++ /dev/null @@ -1,84 +0,0 @@ ---- -import versions from '../data/docs-versions.json'; -import { docsRoute, docsTargetHref } from '../docs-path'; - -const route = docsRoute(Astro.url.pathname); -const locale = route.locale ?? 'root'; -const labels = { - root: { accessible: 'Documentation version', latest: 'Latest', preview: 'Preview' }, - ja: { accessible: 'ドキュメントのバージョン', latest: '最新', preview: 'プレビュー' }, - 'zh-cn': { accessible: '文档版本', latest: '最新', preview: '预览版' }, -}[locale]; -const scopes = versions.scopes; ---- - -{route.isDocs && ( - <label class="docs-version-select"> - <span class="sr-only">{labels.accessible}</span> - <select autocomplete="off" aria-label={labels.accessible}> - <option - value={docsTargetHref(route, 'stable', scopes)} - selected={route.target === 'stable'} - > - {labels.latest}{' '}{versions.current} - </option> - <option - value={docsTargetHref(route, 'preview', scopes)} - selected={route.target === 'preview'} - > - {labels.preview} - </option> - {versions.versions - .filter((entry) => entry.version !== versions.current || route.target === versions.current) - .map((entry) => ( - <option - value={docsTargetHref(route, entry.version, scopes)} - selected={route.target === entry.version} - > - {entry.version} - </option> - ))} - </select> - </label> -)} - -<script> - for (const select of document.querySelectorAll<HTMLSelectElement>('.docs-version-select select')) { - select.addEventListener('change', () => { - globalThis.location.href = select.value; - }); - } -</script> - -<style> - .docs-version-select { - display: flex; - min-width: 0; - } - - select { - width: 9.5rem; - min-width: 0; - border: 1px solid var(--sl-color-hairline); - border-radius: 4px; - padding: 0.4rem 1.6rem 0.4rem 0.55rem; - background: var(--sl-color-bg-nav); - color: var(--sl-color-gray-2); - font: inherit; - font-size: var(--sl-text-xs); - font-weight: 700; - text-overflow: ellipsis; - } - - select:hover, - select:focus { - border-color: var(--sl-color-accent); - color: var(--sl-color-accent-high); - } - - @media (max-width: 38rem) { - select { - width: 7.5rem; - } - } -</style> diff --git a/website/src/components/Head.astro b/website/src/components/Head.astro deleted file mode 100644 index dac83b81..00000000 --- a/website/src/components/Head.astro +++ /dev/null @@ -1,67 +0,0 @@ ---- -import { docsVersion } from '../docs-path'; - -const { head } = Astro.locals.starlightRoute; -const version = docsVersion(Astro.url.pathname); ---- - -{head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)} -<meta data-pagefind-filter="version[content]" content={version} /> -{version !== 'stable' && <meta name="robots" content="noindex, nofollow" />} - -<script> - const labels = (() => { - const lang = document.documentElement.lang.toLowerCase(); - if (lang.startsWith('ja')) return { copy: 'リンクをコピー', copied: 'コピーしました' }; - if (lang.startsWith('zh')) return { copy: '复制链接', copied: '已复制' }; - return { copy: 'Copy link', copied: 'Copied' }; - })(); - - function labelHeadingLinks(root: ParentNode = document) { - for (const link of root.querySelectorAll<HTMLAnchorElement>('.sl-anchor-link')) { - link.title = labels.copy; - const section = link.querySelector('.sr-only')?.textContent; - if (section) link.setAttribute('aria-label', `${labels.copy}: ${section}`); - } - } - - labelHeadingLinks(); - document.addEventListener('astro:page-load', () => labelHeadingLinks()); - document.addEventListener('click', async (event) => { - if ( - event instanceof MouseEvent && - (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) - ) { - return; - } - - const target = event.target; - if (!(target instanceof Element)) return; - const link = target.closest('.sl-anchor-link'); - if (!(link instanceof HTMLAnchorElement) || !navigator.clipboard?.writeText) return; - - try { - await navigator.clipboard.writeText(link.href); - } catch { - return; - } - - link.querySelector('.heading-link-copy-feedback')?.remove(); - const feedback = document.createElement('span'); - feedback.className = 'heading-link-copy-feedback'; - feedback.setAttribute('aria-hidden', 'true'); - feedback.textContent = labels.copied; - link.append(feedback); - window.setTimeout(() => feedback.remove(), 1400); - - let status = document.querySelector<HTMLElement>('[data-heading-link-copy-status]'); - if (!status) { - status = document.createElement('span'); - status.className = 'sr-only'; - status.setAttribute('role', 'status'); - status.dataset.headingLinkCopyStatus = ''; - document.body.append(status); - } - status.textContent = labels.copied; - }); -</script> diff --git a/website/src/components/Header.astro b/website/src/components/Header.astro deleted file mode 100644 index cfa3d759..00000000 --- a/website/src/components/Header.astro +++ /dev/null @@ -1,99 +0,0 @@ ---- -import config from 'virtual:starlight/user-config'; - -import LanguageSelect from 'virtual:starlight/components/LanguageSelect'; -import Search from 'virtual:starlight/components/Search'; -import SiteTitle from 'virtual:starlight/components/SiteTitle'; -import SocialIcons from 'virtual:starlight/components/SocialIcons'; -import ThemeSelect from 'virtual:starlight/components/ThemeSelect'; -import DocsVersionSelect from './DocsVersionSelect.astro'; -import { docsRoute } from '../docs-path'; - -const shouldRenderSearch = - config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro'; -const isDocs = docsRoute(Astro.url.pathname).isDocs; ---- - -<div class="header"> - <div class="title-wrapper sl-flex"> - <SiteTitle /> - </div> - <div class="sl-flex print:hidden search-wrapper"> - {isDocs && <DocsVersionSelect />} - {shouldRenderSearch && <Search />} - </div> - <div class="sl-hidden md:sl-flex print:hidden right-group"> - <div class="sl-flex social-icons"> - <SocialIcons /> - </div> - <ThemeSelect /> - <LanguageSelect /> - </div> -</div> - -<style> - @layer starlight.core { - .header { - display: flex; - gap: var(--sl-nav-gap); - justify-content: space-between; - align-items: center; - height: 100%; - } - - .title-wrapper { - overflow: clip; - padding: 0.25rem; - margin: -0.25rem; - min-width: 0; - } - - .search-wrapper { - align-items: center; - gap: 0.75rem; - min-width: 0; - } - - .right-group, - .social-icons { - gap: 1rem; - align-items: center; - } - - .social-icons::after { - content: ""; - height: 2rem; - border-inline-end: 1px solid var(--sl-color-gray-5); - } - - @media (min-width: 50rem) { - :global(:root[data-has-sidebar]) { - --__sidebar-pad: calc(2 * var(--sl-nav-pad-x)); - } - - :global(:root:not([data-has-toc])) { - --__toc-width: 0rem; - } - - .header { - --__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x)); - --__main-column-fr: calc( - ( - 100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) - - (2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) - - var(--sl-content-width) - ) / 2 - ); - display: grid; - grid-template-columns: - minmax( - calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))), - auto - ) - 1fr - auto; - align-content: center; - } - } - } -</style> diff --git a/website/src/components/MarketingLayout.astro b/website/src/components/MarketingLayout.astro deleted file mode 100644 index 6db7e00d..00000000 --- a/website/src/components/MarketingLayout.astro +++ /dev/null @@ -1,274 +0,0 @@ ---- -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; -const canonicalUrl = new URL(canonicalPath, 'https://herdr.dev').toString(); -const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString(); ---- - -<!doctype html> -<html lang="en"> - <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="#f0eee9" /> - <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" href="/favicon.ico" sizes="48x48" /> - <link rel="icon" type="image/png" sizes="192x192" href="/assets/favicon.png" /> - <link rel="apple-touch-icon" href="/assets/logo.png" /> - <link rel="preload" href="/assets/jbm.woff2" as="font" type="font/woff2" crossorigin /> - <script> - (function () { - var saved = localStorage.getItem("herdr-palette"); - var system = window.matchMedia("(prefers-color-scheme: dark)").matches - ? "tokyo-night" - : "tokyo-night-day"; - document.documentElement.setAttribute("data-palette", saved || system); - })(); - </script> - <link rel="stylesheet" href="/css/style.css?v=41" /> - </head> - <body> - <header class="site-header"> - <nav class="nav" aria-label="Primary navigation"> - <a class="brand" href="/" aria-label="Herdr home"> - <span class="brand-logo" aria-hidden="true"></span> - <span>herdr</span> - </a> - <details class="nav-menu"> - <summary class="nav-menu-button" aria-label="Open navigation"> - <span></span> - <span></span> - <span></span> - </summary> - <div class="nav-menu-panel"> - <a href="/">Home</a> - <a href="/compare/">Compare</a> - <a href="/plugins/">Plugins</a> - <a href="/blog/">Blog</a> - <a href="/docs/quick-start/">Quick start</a> - <a href="/docs/">Docs</a> - <a href="/docs/socket-api/">API</a> - <a href="/docs/install/">Install</a> - <a href="https://github.com/herdrdev/herdr">GitHub</a> - </div> - </details> - <div class="nav-links"> - <a href="/">Home</a> - <a href="/compare/">Compare</a> - <a href="/plugins/">Plugins</a> - <a href="/blog/">Blog</a> - <a href="/docs/quick-start/">Quick start</a> - <a href="/docs/">Docs</a> - <a href="/docs/socket-api/">API</a> - </div> - <div class="nav-tools"> - <a class="nav-icon-link" 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="nav-icon-link" 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 class="nav-cta" href="/docs/install/">Install</a> - </div> - </nav> - </header> - - <slot /> - - <div class="theme-hint" id="theme-hint" title="Press / or t to switch theme"> - <span class="theme-hint-dot"></span> - <span class="theme-hint-label" id="theme-hint-label">light</span> - <span class="theme-hint-key">[/]</span> - </div> - - <div class="theme-overlay" id="theme-overlay" hidden> - <div class="theme-overlay-panel" role="dialog" aria-label="Theme switcher" aria-modal="true"> - <div class="theme-overlay-title">settings</div> - <div class="theme-overlay-tabs"><span class="active">theme</span></div> - <div class="theme-overlay-list" id="theme-overlay-list" role="listbox" aria-label="Themes"> - <div class="theme-sep"><span>dark</span></div> - <button type="button" role="option" data-value="catppuccin">catppuccin</button> - <button type="button" role="option" data-value="terminal">terminal</button> - <button type="button" role="option" data-value="tokyo-night">tokyo night</button> - <button type="button" role="option" data-value="dracula">dracula</button> - <button type="button" role="option" data-value="nord">nord</button> - <button type="button" role="option" data-value="gruvbox">gruvbox</button> - <button type="button" role="option" data-value="one-dark">one dark</button> - <button type="button" role="option" data-value="solarized">solarized</button> - <button type="button" role="option" data-value="kanagawa">kanagawa</button> - <button type="button" role="option" data-value="rose-pine">rose pine</button> - <button type="button" role="option" data-value="vesper">vesper</button> - <div class="theme-sep"><span>light</span></div> - <button type="button" role="option" data-value="catppuccin-latte">catppuccin latte</button> - <button type="button" role="option" data-value="tokyo-night-day">tokyo day</button> - <button type="button" role="option" data-value="gruvbox-light">gruvbox light</button> - <button type="button" role="option" data-value="one-light">one light</button> - <button type="button" role="option" data-value="solarized-light">solarized light</button> - <button type="button" role="option" data-value="kanagawa-lotus">kanagawa lotus</button> - <button type="button" role="option" data-value="rose-pine-dawn">rose pine dawn</button> - </div> - <div class="theme-overlay-footer"> - <span class="theme-overlay-hints"><span class="key">↑↓</span> select</span> - <span class="theme-overlay-actions"><span class="theme-apply-btn">↵ apply</span><button class="theme-close-btn" type="button" id="theme-close-btn">esc close</button></span> - </div> - </div> - </div> - - <script> - (function () { - var overlay = document.getElementById("theme-overlay"); - var list = document.getElementById("theme-overlay-list"); - var hintLabel = document.getElementById("theme-hint-label"); - if (!overlay || !list) return; - var options = Array.prototype.slice.call(list.querySelectorAll("[role='option']")); - var media = window.matchMedia("(prefers-color-scheme: dark)"); - var saved = localStorage.getItem("herdr-palette") || ""; - var committed = saved || defaultPalette(); - - function defaultPalette() { - return media.matches ? "tokyo-night" : "tokyo-night-day"; - } - - function setPalette(value) { - document.documentElement.setAttribute("data-palette", value || defaultPalette()); - } - - function updateUI(value) { - var activeValue = value || defaultPalette(); - options.forEach(function (opt) { - opt.setAttribute("aria-selected", String(opt.dataset.value === activeValue)); - }); - var active = options.find(function (opt) { return opt.dataset.value === activeValue; }); - if (hintLabel && active) hintLabel.textContent = active.textContent.trim(); - } - - function openOverlay() { - overlay.hidden = false; - document.body.style.overflow = "hidden"; - committed = localStorage.getItem("herdr-palette") || defaultPalette(); - updateUI(committed); - var active = list.querySelector("[aria-selected='true']") || options[0]; - if (active) active.focus(); - } - - function closeOverlay() { - overlay.hidden = true; - document.body.style.overflow = ""; - } - - function toggleOverlay() { - if (overlay.hidden) openOverlay(); - else closeOverlay(); - } - - function preview(index) { - if (index < 0 || index >= options.length) return; - setPalette(options[index].dataset.value); - updateUI(options[index].dataset.value); - options[index].focus(); - } - - function commit(value) { - setPalette(value); - localStorage.setItem("herdr-palette", value); - updateUI(value); - closeOverlay(); - } - - function revert() { - setPalette(committed); - updateUI(committed); - closeOverlay(); - } - - media.addEventListener("change", function () { - if (!localStorage.getItem("herdr-palette")) { - setPalette(""); - updateUI(""); - } - }); - - options.forEach(function (opt, idx) { - opt.addEventListener("click", function () { commit(opt.dataset.value); }); - opt.addEventListener("mouseenter", function () { preview(idx); }); - }); - - document.addEventListener("keydown", function (e) { - if ((e.key === "t" || e.key === "/") && !e.ctrlKey && !e.metaKey && !e.altKey) { - var tag = document.activeElement && document.activeElement.tagName; - if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; - e.preventDefault(); - toggleOverlay(); - return; - } - if (overlay.hidden) return; - var idx = options.indexOf(document.activeElement); - if (e.key === "ArrowDown") { - e.preventDefault(); - preview(idx >= 0 ? Math.min(options.length - 1, idx + 1) : 0); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - preview(idx >= 0 ? Math.max(0, idx - 1) : options.length - 1); - } else if (e.key === "Enter" && idx >= 0) { - e.preventDefault(); - commit(options[idx].dataset.value); - } else if (e.key === "Escape") { - e.preventDefault(); - revert(); - } - }); - - var themeHint = document.getElementById("theme-hint"); - if (localStorage.getItem("herdr-theme-hint-seen") === "1") { - themeHint.classList.add("is-seen"); - } - themeHint.addEventListener("mouseenter", function () { - localStorage.setItem("herdr-theme-hint-seen", "1"); - themeHint.classList.add("is-seen"); - }, { once: true }); - themeHint.addEventListener("click", toggleOverlay); - document.getElementById("theme-close-btn").addEventListener("click", revert); - setPalette(saved); - updateUI(saved); - })(); - </script> - </body> -</html> diff --git a/website/src/components/Search.astro b/website/src/components/Search.astro deleted file mode 100644 index cceeed34..00000000 --- a/website/src/components/Search.astro +++ /dev/null @@ -1,52 +0,0 @@ ---- -import StarlightSearch from '@astrojs/starlight/components/Search.astro'; -import { docsVersion } from '../docs-path'; - -const version = docsVersion(Astro.url.pathname); ---- - -<version-search data-version={version}> - <StarlightSearch /> -</version-search> - -<script> - class VersionSearch extends HTMLElement { - observer?: MutationObserver; - - connectedCallback() { - const version = this.dataset.version; - if (!version) return; - - const selectVersion = () => { - const checkbox = this.querySelector<HTMLInputElement>( - `.pagefind-ui__filter-checkbox[name="version"][value="${version}"]`, - ); - if (!checkbox) return false; - if (!checkbox.checked) checkbox.click(); - this.observer?.disconnect(); - return true; - }; - - if (selectVersion()) return; - // Starlight does not expose its PagefindUI instance, so wait for Pagefind's filter control. - this.observer = new MutationObserver(selectVersion); - this.observer.observe(this, { childList: true, subtree: true }); - } - - disconnectedCallback() { - this.observer?.disconnect(); - } - } - - customElements.define('version-search', VersionSearch); -</script> - -<style> - version-search { - display: contents; - } - - version-search :global(.pagefind-ui__filter-panel) { - display: none; - } -</style> diff --git a/website/src/components/Sidebar.astro b/website/src/components/Sidebar.astro deleted file mode 100644 index 08d31f79..00000000 --- a/website/src/components/Sidebar.astro +++ /dev/null @@ -1,53 +0,0 @@ ---- -import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter'; -import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro'; -import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro'; -import versions from '../data/docs-versions.json'; -import { docsRoute, docsTargetHref } from '../docs-path'; - -const { sidebar } = Astro.locals.starlightRoute; -const pathname = Astro.url.pathname; -const route = docsRoute(pathname); -const scopedSidebar = route.target === 'stable' ? sidebar : rewriteSidebar(sidebar); - -function rewriteSidebar(entries) { - return entries.flatMap((entry) => { - if (entry.type === 'link') { - const linkRoute = docsRoute(entry.href); - if (!linkRoute.isDocs) return [entry]; - if (linkRoute.target !== 'stable') { - return linkRoute.target === route.target - ? [{ ...entry, isCurrent: samePath(entry.href, pathname) }] - : []; - } - - const localeKey = route.locale ?? 'root'; - const availablePages = versions.scopes[route.target]?.locales[localeKey] ?? []; - if (!availablePages.includes(linkRoute.page)) return []; - - const href = docsTargetHref({ ...linkRoute, locale: route.locale }, route.target, versions.scopes); - return [{ ...entry, href, isCurrent: samePath(href, pathname) }]; - } - - const rewrittenEntries = rewriteSidebar(entry.entries); - if (rewrittenEntries.length === 0) return []; - return [{ ...entry, entries: rewrittenEntries }]; - }); -} - -function samePath(left, right) { - return stripTrailingSlash(left) === stripTrailingSlash(right); -} - -function stripTrailingSlash(path) { - return path.length > 1 ? path.replace(/\/$/, '') : path; -} ---- - -<SidebarPersister> - <SidebarSublist sublist={scopedSidebar} /> -</SidebarPersister> - -<div class="md:sl-hidden"> - <MobileMenuFooter /> -</div> diff --git a/website/src/components/SiteLayout.astro b/website/src/components/SiteLayout.astro deleted file mode 100644 index f056458c..00000000 --- a/website/src/components/SiteLayout.astro +++ /dev/null @@ -1,223 +0,0 @@ ---- -/* 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" href="/favicon.ico" sizes="48x48" /> - <link rel="icon" type="image/png" sizes="192x192" href="/assets/favicon.png" /> - <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=64" /> - </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="lnk" href="/compare/">Compare</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="/compare/">Compare</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> - <a href="/compare/">compare</a> - <span>built in the terminal · © {year} Herdr, Inc.</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/components/SiteTitle.astro b/website/src/components/SiteTitle.astro deleted file mode 100644 index 3e5af0a3..00000000 --- a/website/src/components/SiteTitle.astro +++ /dev/null @@ -1,95 +0,0 @@ ---- -import { docsRoute } from '../docs-path'; - -const { siteTitle, siteTitleHref, locale } = Astro.locals.starlightRoute; -const route = docsRoute(Astro.url.pathname); -const localePrefix = locale ? `/${locale}` : ''; -const targetSegment = route.target === 'stable' ? '' : `/${route.target}`; -const docsHref = `${localePrefix}/docs${targetSegment}/`; ---- - -<div class="title-row sl-flex"> - <a href={siteTitleHref} class="site-title sl-flex"> - <span class="docs-brand-logo" aria-hidden="true"></span> - <span translate="no">{siteTitle}</span> - </a> - <nav class="title-nav" aria-label="Primary"> - <a href={docsHref}>Docs</a> - <a href="/plugins/">Plugins</a> - <a href="/blog/">Blog</a> - </nav> -</div> - -<style> - @layer starlight.core { - .title-row { - align-items: center; - gap: 1rem; - min-width: 0; - } - - .site-title { - align-items: center; - gap: 0.55rem; - font-family: var(--sl-font-mono); - font-size: var(--sl-text-h4); - font-weight: 700; - letter-spacing: -0.04em; - color: var(--sl-color-white); - text-decoration: none; - white-space: nowrap; - min-width: 0; - transition: color 120ms ease; - } - - .docs-brand-logo { - position: relative; - display: inline-block; - width: 32px; - height: 32px; - flex: 0 0 auto; - border: 1px solid var(--sl-color-hairline); - border-radius: 4px; - background: var(--sl-color-bg-nav); - } - - .docs-brand-logo::before { - content: ""; - position: absolute; - inset: 2px; - background: var(--sl-color-white); - -webkit-mask: url("/assets/agent-icons/herdr-mask.svg") center / contain no-repeat; - mask: url("/assets/agent-icons/herdr-mask.svg") center / contain no-repeat; - } - - span:not(.docs-brand-logo) { - overflow: hidden; - } - - .title-nav { - display: flex; - align-items: baseline; - gap: 0.75rem; - font-size: var(--sl-text-sm); - font-weight: 600; - line-height: 1; - } - - .title-nav a { - color: var(--sl-color-white); - text-decoration: none; - transition: color 120ms ease; - } - - .site-title:hover, - .title-nav a:hover { - color: var(--sl-color-accent-high); - } - - @media (max-width: 35rem) { - .title-nav { - display: none; - } - } - } -</style> diff --git a/website/src/content.config.ts b/website/src/content.config.ts deleted file mode 100644 index 88291349..00000000 --- a/website/src/content.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineCollection, z } from 'astro:content'; -import { glob } from 'astro/loaders'; -import { docsLoader } from '@astrojs/starlight/loaders'; -import { docsSchema } from '@astrojs/starlight/schema'; -import { docsPath } from './docs-path'; - -export const collections = { - docs: defineCollection({ loader: docsLoader({ generateId: docsPath }), schema: docsSchema() }), - blog: defineCollection({ - loader: glob({ pattern: '*.md', base: './src/content/blog' }), - schema: z.object({ - title: z.string(), - 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 deleted file mode 100644 index 3628f4ec..00000000 --- a/website/src/content/blog/coding-agents-are-becoming-runtimes.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -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 ---- - -Coding agents started as chat interfaces that could edit code. Now they live inside repositories, terminals, editors, CI jobs, remote machines, mobile shells, multiplexers, and custom wrappers. They read project instructions. They run commands. They ask for permission. They spawn subagents. They pause, resume, compact, fail, recover, and keep working while the developer does something else. - -That is runtime behavior. - -The model can be proprietary. The hosted API can be proprietary. The subscription can be priced however the lab needs to price it. That part belongs to the companies building the models. - -But the CLI that runs inside a developer's terminal is part of the developer environment. It sits next to shells, language servers, package managers, test runners, terminals, editors, and multiplexers. Developers will compose it with the rest of their tools whether vendors plan for that or not. - -That is where the industry needs to be more careful. - -## We have seen this drift before - -Software already has too many places where the same idea fractured into incompatible files and conventions. - -JavaScript has `package.json`, then layers of package-manager lockfiles and workspace formats. Rust has `Cargo.toml`, which is refreshingly predictable. Python has lived through `requirements.txt`, `setup.py`, `setup.cfg`, `Pipfile`, `poetry.lock`, `pyproject.toml`, and tool-specific sections inside the same TOML file. Editors have their own settings. CI systems have their own YAML. Formatters and linters each add another little contract. - -Some of that is natural. Tools have different jobs. - -But the cost is real. Every repository becomes a map of overlapping conventions. Every new tool needs to learn which file matters, which one wins, and which one is legacy but still active. - -Agent CLIs are young enough that we can avoid repeating the worst version of that. - -Project instructions are the clearest example. [AGENTS.md](https://agents.md/) is trying to give coding agents one predictable place to read repository guidance. It describes itself as "a simple, open format for guiding coding agents" and says it is used by more than 60,000 open-source projects. - -The shape is simple: - -```text -one repository -one shared instruction file -many coding agents -``` - -Claude Code still stays on [`CLAUDE.md`, not `AGENTS.md`](https://docs.anthropic.com/en/docs/claude-code/memory#agents-md). The official workaround is to create a `CLAUDE.md` that imports `AGENTS.md`, or to symlink one file to the other. - -That workaround works, and it is how standards drift begins. - -First there is one extra file. Then every agent has a native file. Then every repository grows a compatibility layer. Then teams spend time keeping duplicated intent in sync. - -Nobody loses a production database because a repo has two instruction files. The cost is smaller and more constant: duplicated intent, stale guidance, and another convention every tool has to learn. - -## The terminal is the shared surface - -There is another debate happening around access and third-party tools. - -Coding agents made per-token pricing feel very different. A chat session can be expensive. A coding agent can run tools, read files, loop, spawn work, and burn through a lot more context. So the labs started selling subscriptions with usage limits: some explicit, some soft, some hourly, some weekly, some hard to reason about from the outside. - -Then developers started doing what developers always do. They put the model behind the tool they preferred. - -That is where the Claude ecosystem became a useful case study. People used Claude subscriptions through third-party tools such as OpenClaw and OpenCode. Anthropic pushed back. Today Anthropic documents that [Claude Code can use Pro and Max subscriptions](https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan), while [Claude paid plans and API access are separate products](https://support.claude.com/en/articles/9876003-i-have-a-paid-claude-subscription-pro-max-team-or-enterprise-plans-why-do-i-have-to-pay-separately-to-use-the-claude-api-and-console). Anthropic also introduced a separate [Agent SDK credit](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) for programmatic and third-party Agent SDK usage. Zed summarized the practical effect well: first-party Claude usage and third-party agent or SDK usage now draw from different pools. - -Anthropic can make that decision. They own the API. They own the subscription terms. Compute is expensive, and a first-party interactive CLI has different economics from arbitrary programmatic tools running through an SDK or protocol adapter. - -The developer-tooling question remains: what does a developer actually own—the subscription, the API spend, the model access, or the runtime where the agent works? - -When an agent runs in a terminal, it is no longer only a hosted product experience. It is a process in a developer environment. Developers will put that process in Herdr, Zed, tmux, SSH sessions, CI jobs, containers, and scripts. They will supervise it. They will route it. They will build workflows around it. - -The terminal is where ownership gets shared. - -That does not mean vendors lose the right to build first-party experiences. It means the CLI surface needs stable contracts because it has become infrastructure other tools depend on. - -## Hooks are a symptom - -Hooks show the same tension. - -Most serious agent CLIs have hooks, plugins, or extensions now. That is good. The problem is that "hooks" can mean almost anything. - -Some hooks are policy gates. Some are notifications. Some modify tool calls. Some inject context. Some observe messages. Some run at session boundaries. Some run around tools but miss interruptions, permission resolutions, or the exact state an external supervisor needs. - -This matters because agent work is no longer a single request and response. A coding agent has a lifecycle. - -For external tools, the minimum lifecycle is small: - -```text -working -> actively doing work -blocked -> needs permission, input, recovery, or attention -idle -> ready for the next instruction -``` - -The concrete shape could be small: - -```json -{ - "session_id": "abc123", - "state": "blocked", - "reason": "permission_request", - "source": "agent" -} -``` - -Permission prompts, failed tools, interrupted generations, automated reviews, subagent handoffs, and waiting for the user all need to land somewhere coherent. - -Claude Code has a serious hook system, and Codex has its own shape too. Both are substantial, but each agent still defines its own boundary. A hook surface can be powerful and still fail to provide a portable lifecycle contract. - -OpenCode is closer to the shape orchestration tools need. Its [plugin documentation](https://opencode.ai/docs/plugins/) exposes session-level events such as `session.idle`, `session.status`, `session.error`, `permission.asked`, and `permission.replied`. - -pi, meanwhile, treats extensibility as a broader system. Its [extension documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md) describes TypeScript extensions that can subscribe to lifecycle events, register tools, add commands, intercept behavior, render UI, persist state, and hot-reload from local extension directories. - -Those designs are worth praising because they treat the agent as something other tools can understand. - -## What Herdr sees from below - -Herdr sits below the agent. - -It owns terminal panes. It keeps processes alive. It lets developers switch between agents, inspect them, recover sessions, and operate multiple agent processes as one workspace. - -From that layer, runtime drift becomes very visible. - -Herdr's first useful agent detection path was screen reading. If an agent drew "working", "waiting for approval", or "done" in the terminal, Herdr could read the terminal state and classify the pane. - -That matched what humans saw, which made it useful. It also broke when agent UIs changed. If the fix lived inside the Herdr binary, users needed a full Herdr update just to recognize a new prompt or status line. - -Another tempting signal was terminal activity. - -```text -screen changing -> probably working -screen still -> maybe idle or blocked -``` - -Real agents break that quickly. A spinner can redraw while the meaningful state is blocked. A permission prompt can sit still while it is the most important thing in the pane. A subagent can produce activity while the parent session is waiting. - -Terminal activity is evidence. Lifecycle is stronger. - -Herdr now uses better signals where agents expose them, keeps screen evidence as a fallback, and ships hot-reloadable detection manifests so compatibility fixes do not always require a new Herdr binary. That is the practical bridge for today's ecosystem. The durable contract should come from agents themselves. - -## Runtime support should be visible - -Herdr can support agents in two broad ways. - -Some agents expose reliable lifecycle signals. Those can have verified lifecycle support. - -Other agents require observed detection through terminal output, screen evidence, manifests, and heuristics. That can work well, but it depends on UI behavior staying recognizable. - -Herdr will publish this as a per-agent support matrix: which agents expose verified lifecycle signals, and which agents require observed detection. - -This distinction should be public because it helps everyone. - -Users get honest expectations. Agent authors get a target. Tool builders get a vocabulary. The ecosystem rewards agents that expose their runtime state cleanly. - -Agents can keep their own plugin systems while standardizing the parts that should never have been product differentiation. - -## Standardize the boring parts - -Use one predictable project instruction format. Expose a small lifecycle contract. Make permission state observable. Make interruption state observable. Tie state to the session that is actually running. Document what external tools can rely on. - -Keep competing on models, UX, planning, context management, speed, pricing, safety, and taste. - -Do not compete on whether a repository needs five instruction files. - -Do not compete on whether an external tool can tell if an agent is blocked. - -Do not make every terminal runtime reverse-engineer every other terminal runtime. - -Coding agents are becoming infrastructure. The companies building them still own their products, but developers own their environments. - -Herdr is built from that position. The terminal is where these agents actually run. The runtime surface belongs to the developer workflow, and vendors share that space whether they design for it or not. - -Agents should make their basic contracts visible, durable, and boring. - -Then the rest of us can build. diff --git a/website/src/content/blog/herdr-0-6-3.md b/website/src/content/blog/herdr-0-6-3.md deleted file mode 100644 index 3abb5f5f..00000000 --- a/website/src/content/blog/herdr-0-6-3.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: "Herdr 0.6.3: session persistence, safer updates, and workspace navigation" -description: "This one adds new persistence paths for updates, server restarts, and larger sessions." -date: 2026-05-27 -draft: false ---- - -## Persistence for the hard parts - -Keeping a session alive means handling several different cases: a detached client, a stopped server, terminal history replay, agent conversation restore, and an update handoff. Each protects a different part of the workspace. - -Herdr 0.6.3 improves three of those paths. - -### Agent restore after server restart - -When a Herdr server stops, normal pane processes stop with it. Native agent restore is the path for getting supported agent conversations back after that kind of restart. - -With `[session] resume_agents_on_restore = true`, Herdr can use official session references from supported integrations to relaunch Pi, Claude Code, Codex, OpenCode, and Hermes panes into their previous conversations. - -[Read the session restore docs](/docs/session-state/#native-agent-session-restore). - -### Pane screen history - -Pane screen history solves the visual half of restart recovery. When enabled, Herdr saves recent pane output and replays it into restored panes so the workspace does not come back as a set of empty terminals. - -It is useful for shells, logs, dev servers, and agent panes where recent output explains what was happening before the server stopped. - -[Read how pane history differs from live persistence](/docs/session-state/#what-survives). - -### Live handoff for updates - -Live handoff is the update path for supported running sessions. Instead of stopping the old server and killing pane processes, Herdr can ask the old server to transfer live PTY ownership to the updated server. - -It is currently experimental and opt-in with `herdr update --handoff` and `herdr --remote <host> --handoff`. That keeps the default path conservative while the feature gets more real-world coverage. - -[Read the live handoff docs](/docs/session-state/#live-handoff). - -For the technical background, read the deep dive: [Live updates without killing your terminal processes](/blog/live-updates-without-killing-your-terminal-processes/). - -## Session navigator - -Large Herdr sessions need fast movement. The new session navigator opens with `prefix+g` and gives you a searchable workspace, tab, and pane tree. - -At a glance, you can see the full tree of workspaces, tabs, and panes. Press `/` to fuzzy find by text. Press `b`, `w`, `i`, or `d` to filter by blocked, working, idle, or done panes. Press `a` or Backspace to clear the state filter. - -Use it to jump straight to the pane that needs attention without manually scanning the sidebar. - -<figure class="release-media"> - <video src="/assets/releases/v0.6.3/session-navigator.mp4" controls playsinline preload="metadata"></video> - <figcaption> - The session navigator shows workspaces, tabs, panes, and agent states in one searchable tree. - </figcaption> -</figure> - -## More terminal workflow polish - -Direct agent terminal attaches now have scrollback, so PageUp, PageDown, and mouse wheel scrolling work in the attach viewport when the running terminal app has not requested that input for itself. - -Herdr also gained better update and remote-bootstrap behavior for package-manager installs, more reliable event delivery under load, and fixes across agent detection, terminal colors, sound alerts, and integration lifecycle handling. - -## Pane metadata reporting - -The new `pane.report_metadata` socket method and `herdr pane report-metadata` CLI command let integrations and local hooks customize what Herdr shows for a pane. - -Once installed, an integration can report a title, visible agent name, compact status label, state label, and native session metadata without taking over unrelated lifecycle behavior. - -You can also use it from your own scripts. For example, a hook can mark a pane as running a deploy, rename a long-lived task, or ask an agent to report a more specific state for the work it is doing. - -```bash -herdr pane report-metadata \ - --title "api deploy" \ - --agent-name "codex" \ - --state-label "reviewing logs" \ - --compact-status "deploy" -``` - -This gives integrations a supported way to make panes more readable while keeping Herdr's session and agent ownership rules intact. - -[Read the socket API docs](/docs/socket-api/). - -## Full notes - -This page highlights the product changes. The complete release notes remain on GitHub. - -[Open the 0.6.3 GitHub release](https://github.com/herdrdev/herdr/releases/tag/v0.6.3). diff --git a/website/src/content/blog/herdr-is-joining-y-combinator.md b/website/src/content/blog/herdr-is-joining-y-combinator.md deleted file mode 100644 index 3a9cfa3e..00000000 --- a/website/src/content/blog/herdr-is-joining-y-combinator.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Herdr is joining Y Combinator. The runtime stays open. -description: Herdr is joining Y Combinator. The runtime is Apache-2.0 and stays open, and here is what the funding is actually for. -date: 2026-08-06 -draft: false -lede: false -ogImage: /assets/og-blog-yc-v1.png ---- - -Hey everyone, it's Can, the only person behind Herdr. This post is about how I decided to change that. - -Four months ago I was looking for another job. Thinking about where to go career-wise. Thinking about software engineering and its future in general, my hobby projects, applications, whiteboard interviews (I still can't believe we do those). And at some point it hit me: I am the bottleneck. - -This is a management and engineering problem. I don't want to install an app or learn a new tool just to manage my agents and my development style. I want something that fits me. You know how every company releases their own agent these days? No, don't do that. Let my agent integrate with your product, don't offer me another agent. - -Same idea here. I didn't like what I tried, so why not build it? So I built a runtime. But what is a runtime? - -## Runtime - -CLI coding agents run in terminals, and terminals have always been our home: editing code (yes, nvim chads), running servers, navigating projects, maintaining CI, machines, configurations. It's the root. It's the connection. So agents need first-class primitives there. - -A terminal pane belongs to an agent. A pane belongs to a tab. Tabs belong to a project. - -They need to be persistent. We have agents running for hours now, sometimes days. And once you have the runtime, where they run stops mattering. **You should be able to run them anywhere and keep them running.** - -That's where Herdr was born. But a runtime needs an interface, so I built the TUI. - -## TUI - -The TUI is the UI I use every day, and it carried Herdr this far because it shows what I believe Herdr should do: track agents at a glance, divide work between projects instead of getting lost in a pile of agents, and alert you only when an agent actually needs you. - -Its biggest advantage, and the reason it stays first-class forever, is that it's bundled. Install Herdr on your VPS, ssh in, and your UI is already there. Or run `herdr --remote user@host` and it installs itself. Depending on your network speed, you're a couple of seconds away from an agent running. One command and you're in. Nothing beats that. - -Still, the TUI was never meant to be the only client. A runtime means people can build on top of it, and people did! A [Raycast extension](https://x.com/vladscale/status/2080195067088871582), a [Stream Deck with buttons for herdr](https://x.com/timvdhoorn/status/2067907258260795808), an [iOS app](https://x.com/imnotchalk/status/2077647387414384936) that drives a whole session from a phone. More than 500 plugins, one month after the marketplace released. I didn't build any of these. - -The TUI has its limits too. Some of the ideas in my head are hard to build inside a terminal, and I trust terminals, but Herdr will need more clients. More on that later. - -## Where we are now - -So a solo project reached **25k stars and 340k downloads**, and it became more than one person can carry. - -Herdr is joining <span style="color:#fb651e">Y Combinator, F26 batch</span>. I'm excited to make Herdr a company that builds a developer tool for anyone juggling agents all day. I want to build a small team: people who keep the runtime healthy, reliable, fast, easy to run anywhere, and more extensible, so you can do more while Herdr stays small. - -The runtime, what you use right now, stays free. Apache-2.0. That's why I recently switched it from AGPL to Apache: I want everyone to use Herdr freely. - -## Where we're going - -I want to build on top of the open Herdr runtime like everyone else. I'll keep supporting the open source while building the features people really need. The demand is already visible: multiple clients, a laptop, a VPS for the six-hour job, a sandbox for risky code and ephemeral agents. Herdr can run anywhere today, but those machines are disconnected. They should be connected. - -There are many more features in my head, but I don't want to rush. People keep telling me they love that Herdr stays lean, and that's what I want to protect. In an age where adding one more feature costs nothing, choosing what goes in the core is the most important decision, I believe. So the core stays small, and everything else stays possible through extensions: your style, your flows, your company's setup, your themes, your crazy ideas. - -Thank you for getting Herdr this far. Thanks for the support, for sharing the love. You have no idea how grateful I am. - -Anyway, back to work. - - 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 deleted file mode 100644 index 6658c488..00000000 --- a/website/src/content/blog/live-updates-without-killing-your-terminal-processes.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -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 ---- - -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. - -Terminal multiplexers are supposed to protect long-running work from interruption. You start a server, run shells and tools inside panes, detach clients, reconnect later, and the work keeps going. - -Updating the multiplexer itself usually breaks that. - -That is normal for tools like tmux and Zellij. They are mature, stable, and their core job changes slowly. If the server/client protocol changes, the usual answer is to keep using the old server until you can restart it. That is annoying, but often acceptable. - -Herdr has a different problem. It is a terminal multiplexer, sure, but it is also an AI development workspace. It is new. I released it a few months ago, and we are at 2.6k stars on GitHub. It has more than 15k downloads according to GitHub release stats. - -It gets frequent fixes for agent detection (isn't it cool that we have all the labs building their own CLIs, plugins, and hooks in their own broken way?), terminal behavior, remote workflows, worktrees, mobile use, update flows, and integrations. Users should not have to choose between getting those fixes and killing every pane in a running session. - -Herdr needed to update like an app while preserving processes like a terminal multiplexer. - -## The update problem - -A persistent terminal multiplexer usually has at least two sides. - -The server owns the session. It owns pane state, PTYs, readers, writers, layout, buffers, sockets, and child process lifetime. The client is mostly a view and input path into that server. - -When a new binary changes the client/server protocol, the new client may not be able to talk to the old server. Restarting the server fixes that, but it also risks killing the pane processes. For a terminal multiplexer, that is the worst possible update path. - -This is not only a Herdr problem. I looked around because I assumed somebody had already solved it. - -Zellij has multiple long-running issues about upgrade pain: sessions appearing lost after upgrade, users being unable to attach to sessions from a previous version, requests for an `upgrade-session` flow, and requests to import sessions from previous versions. See [zellij#1255](https://github.com/zellij-org/zellij/issues/1255), [zellij#2943](https://github.com/zellij-org/zellij/issues/2943), [zellij#3371](https://github.com/zellij-org/zellij/issues/3371), [zellij#3420](https://github.com/zellij-org/zellij/issues/3420), [zellij#3449](https://github.com/zellij-org/zellij/issues/3449), and [zellij#4305](https://github.com/zellij-org/zellij/issues/4305). Zellij 0.44 added forward-compatible client/server support, which helps future clients connect to older running sessions, but that is still not replacing the running server while preserving live PTYs. - -Tmux has the same class of version-skew behavior. Users hit protocol mismatch errors, and the practical answer is to restart tmux, delay the update, or attach with the old binary through `/proc/<pid>/exe` where that exists. See [tmux#99](https://github.com/tmux/tmux/issues/99), [tmux#2189](https://github.com/tmux/tmux/issues/2189), [tmux#4356](https://github.com/tmux/tmux/issues/4356), and [tmux#4890](https://github.com/tmux/tmux/issues/4890). - -As far as I can tell, mainstream terminal multiplexers do not do in-place server binary replacement while keeping arbitrary live pane processes attached. - -That is a small breakthrough, but a real one. - -## The important detail is PTY ownership - -At first, the problem sounds like process migration. But no, that would be much harder. - -Herdr does not move the child processes. It moves ownership of the terminals those processes are already attached to. - -Each pane process talks to the slave side of a PTY. Herdr's server owns the master side. If the final master FD disappears, the child can lose I/O, receive HUP, or exit. But if the master FD stays alive and a new server takes over reading and writing it, the child process does not need to know the server changed. - -That changes the problem from “move a process” to “transfer open file descriptors and rebuild runtime ownership around them.” - -On Unix, that is possible with `SCM_RIGHTS`: one process can send open file descriptors to another process over a Unix socket. - -## The handoff - -The final shape is a two-server transaction. - -```text -old server: pause PTY readers -old server: snapshot pure app state -old server: duplicate PTY master FDs -old server: spawn new Herdr in import mode -handoff: send manifest + FDs over a private Unix socket -new server: restore state and rebuild pane runtimes -new server: bind public sockets and report ready -old server: commit and exit without killing pane process groups -new server: unpause readers and continue -``` - -The old server does not just dump state and hope. It enters handoff mode, rejects new mutations, disconnects clients, and pauses every transferred PTY reader. That pause acknowledgement matters because only one server may actively read from a PTY at a time. If both servers read from the same master, output can be stolen by the wrong runtime. - -After readers are paused, the old server captures Herdr's normal session snapshot. That works because Herdr already separates pure application state from runtime ownership. `AppState` can be serialized. Pane runtimes, PTY handles, readers, writers, and detector loops are separate runtime objects. - -Then the old server creates a handoff manifest and sends the duplicated PTY master FDs to the new server. - -```text -manifest = app snapshot + pane metadata + optional replay bytes -FDs = duplicated PTY master file descriptors -transport = private Unix socket + SCM_RIGHTS -``` - -The new server validates the manifest, checks the expected protocol and version, receives the FDs, restores `AppState`, constructs imported pane runtimes around the received PTY masters, binds the normal Herdr sockets, and reports ready. - -Only then does the old server commit. Before commit, any failure rolls back to the old server. After commit, the new server owns the panes and the old server exits without signaling the pane process groups. - -That commit boundary is the safety line. - -## Why Herdr could do it - -Herdr had the pieces already, mostly because of earlier architecture choices. - -The state model is separated from runtime ownership. Workspaces, tabs, panes, labels, focus, layout, and agent metadata live in serializable state. PTYs, child handles, reader threads, writer paths, resize state, and detectors live in runtime objects. - -The server/client boundary is explicit. Herdr already has a persistent server, reconnecting clients, local sockets, named sessions, remote attach flows, and documented [session state paths](/docs/session-state/). That made it possible to add a hidden import mode instead of rewriting the app around live upgrades. - -The terminal engine also helped with visual continuity. Herdr uses `libghostty-vt` for pane terminal state. For normal primary-screen panes, the old server can export recent ANSI history and the new server can seed that history before it starts reading future PTY bytes. That is not required for process survival, but it makes shells and dev servers look continuous after reconnect. - -Full-screen terminal apps are different. Herdr does not fake replay for alternate-screen panes like `nvim`, `btop`, or `htop`. Their processes survive the handoff, but the visual state is best effort until the app redraws. That is the honest trade-off. - -## The invariants - -Sending FDs was easier than making the transaction safe. - -These were the rules: - -- exactly one server may read each PTY at a time -- every transferred reader must acknowledge pause before snapshot and replay capture -- before commit, failure leaves the old server alive and owning the panes -- after commit, the old server must not destructively drop transferred runtimes -- no failure path may close the final PTY master FD -- agent restore must not run during [live handoff](/docs/session-state/#live-handoff) -- alternate-screen panes must stay live without pretending their screen was perfectly serialized - -The implementation was tested with shell panes, a Python HTTP server, `btop`, and `nvim` on Linux and macOS. The HTTP server kept serving requests across handoff. Shell panes accepted input after handoff. Full-screen TUIs stayed alive and redrew on a best-effort basis. - -## What this is not - -This is not checkpoint/restore. It does not survive a reboot. It does not migrate processes across machines. It does not preserve arbitrary process memory. It does not make every future internal state change automatically compatible. - -It is a same-machine Unix handoff. The running pane process stays attached to its PTY slave. The old Herdr server transfers the PTY master to the new Herdr server. The server binary changes; the pane process keeps running. - -## Why this is cool - -Herdr users often keep the exact kind of work open that should not be casually restarted: coding agents, dev servers, shells, REPLs, SSH sessions, debugging sessions, long builds, and local experiments. - -A terminal multiplexer should make that work safer, not make updates feel dangerous. - -The process inside the pane does not have to know an update happened. 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 deleted file mode 100644 index 6aef6af1..00000000 --- a/website/src/content/blog/ten-agents-three-clients-95-percent-less-cpu.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: Ten agents, three clients, 95% less CPU -description: Herdr stopped animating its sidebar, drawing hidden panes, and repainting unchanged frames during mouse movement. The busiest sessions gained the most. -date: 2026-08-03 -ogImage: /assets/og-blog-frames-v1.png -draft: false ---- - -In workloads dominated by unnecessary rendering, total CPU for the Herdr server and its attached clients fell by 89 to 95 percent. The renderer still runs at the same speed. Herdr now asks it to draw fewer frames. - -The savings grow with the session. With one agent and one client, total CPU fell 91 percent. With ten agents producing background output and three clients, it fell 95 percent. Every frame the server skips is also one less frame to serialize, transmit, and apply in each client. - -We found all three changes without a profiler. The first started as a complaint and gave us a rule: 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 get twenty things moving at once, all saying the same thing, none saying it is the one that needs you. The workspace list already carried state as coloured dots. The sidebar was the noisy half of the same information. People told us. We agreed. - -The first fix isolated spinner redraws to the status rectangle. Instead of rebuilding pane contents for every tick, the server reused the client's retained frame, patched the smaller result, and sent it. We had made the spinner about as cheap as possible. Then we removed it anyway. It was a design decision first; the CPU saving was a side effect. - -<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 when it happens. Remaining in the state schedules no animation. - -That design change saved more CPU than we expected. The spinner woke the server on a timer, rendered the status surface, compared and prepared the frame, serialized it, sent it to every attached client, and made each client apply it. This happened roughly eight times a second for as long as any agent was working, even when the rest of the screen stayed unchanged. - -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. - -## The same rule, applied to output you cannot see - -The same rule applies to output from panes nobody can see. - -When a pane produced output, the PTY reader set a shared flag to say something had happened. That coalesced wakeups efficiently but discarded the source pane. By the time the server picked up the flag, it could not tell whether the pane was on screen. A background agent writing to a tab nobody was viewing still produced frames for every attached client. - -<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 the render does not drop output. 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 the current state. Where the mapping is unknown, Herdr treats the pane as visible, because a wrong skip leaves a stale screen while a wrong render only costs some CPU. - -## Mouse movement should not redraw an unchanged frame - -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 tracks which of its own modes respond to hover: the global menu, context menus and the navigator. Outside those modes, movement alone repaints nothing. The events still reach 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 - -Skipping one server render also skips frame preparation, serialization, transmission, and frame handling in every attached client. - -More clients multiply the saving. Ten silent working panes with one client improved 78 percent. The same panes with three clients improved 91 percent. - -<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">macOS followed the same shape: 22.958 percent to 1.673 in the hidden-output case, and 14.857 to 13.498 with forty-nine hidden writers at 60Hz.</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, in a pane you are watching, improved 33.9 percent on Linux and 2.7 on macOS. Idle sat at the measurement floor on both builds and deserves no percentage claim at all. - -The optimization targets unnecessary rendering. Output-heavy cases remain dominated by PTY reads and terminal parsing. - -## How this was measured - -We compared official 0.7.5 release binaries with 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 show total CPU for the Herdr server plus every attached client. - -We paired every headline CPU result with a behavioural counter: scheduled frames per second for the sidebar, hidden-source skips for background output, and 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. - -This compares a shipped release against master, so intervening changes and build environments are part of the comparison. The profiler evidence matches the mechanisms closely, but not every CPU point can be assigned to one commit. - -The work started with people saying the sidebar was too busy, along with bug reports and performance reports from people running far stranger sessions than we test. Keep them coming, and thank you to everyone helping make Herdr better. diff --git a/website/src/data/plugins-fixture.json b/website/src/data/plugins-fixture.json deleted file mode 100644 index c41ab764..00000000 --- a/website/src/data/plugins-fixture.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "schemaVersion": 1, - "generatedAt": "2026-06-20T12:00:00.000Z", - "pluginCount": 10, - "repositoryCount": 7, - "source": { - "provider": "github", - "query": "topic:herdr-plugin", - "totalCount": 8, - "collectedCount": 8, - "truncated": false - }, - "plugins": [ - { - "id": 123456789, - "fullName": "ogulcancelik/herdr-plugin-github-start", - "owner": "ogulcancelik", - "name": "herdr-plugin-github-start", - "description": "Start Codex or Claude from a GitHub issue, PR, or discussion without leaving the terminal.", - "url": "https://github.com/ogulcancelik/herdr-plugin-github-start", - "stars": 42, - "forks": 3, - "openIssues": 1, - "language": "TypeScript", - "topics": ["herdr-plugin", "github", "agents"], - "createdAt": "2026-05-01T00:00:00Z", - "updatedAt": "2026-06-19T19:58:26Z", - "pushedAt": "2026-06-19T19:58:26Z", - "archived": 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, - "fullName": "ogulcancelik/herdr-plugin-examples", - "owner": "ogulcancelik", - "name": "herdr-plugin-examples", - "description": "Cookbook of example Herdr plugins: telegram notify, link preview, dev layout bootstrap.", - "url": "https://github.com/ogulcancelik/herdr-plugin-examples", - "stars": 28, - "forks": 6, - "openIssues": 0, - "language": "JavaScript", - "topics": ["herdr-plugin", "examples"], - "createdAt": "2026-04-12T00:00:00Z", - "updatedAt": "2026-06-18T08:10:00Z", - "pushedAt": "2026-06-18T08:10:00Z", - "archived": 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, - "fullName": "lindqvist/herdr-board", - "owner": "lindqvist", - "name": "herdr-board", - "description": "A live project board pane for Herdr that tracks agent tasks across worktrees.", - "url": "https://github.com/lindqvist/herdr-board", - "stars": 19, - "forks": 1, - "openIssues": 4, - "language": "Rust", - "topics": ["herdr-plugin", "pane", "kanban"], - "createdAt": "2026-05-20T00:00:00Z", - "updatedAt": "2026-06-17T14:02:00Z", - "pushedAt": "2026-06-17T14:02:00Z", - "archived": 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, - "fullName": "mira-dev/herdr-layout-presets", - "owner": "mira-dev", - "name": "herdr-layout-presets", - "description": "Apply opinionated split-pane layouts per project with one keybinding.", - "url": "https://github.com/mira-dev/herdr-layout-presets", - "stars": 11, - "forks": 0, - "openIssues": 2, - "language": "Lua", - "topics": ["herdr-plugin", "layout"], - "createdAt": "2026-06-01T00:00:00Z", - "updatedAt": "2026-06-15T09:30:00Z", - "pushedAt": "2026-06-15T09:30:00Z", - "archived": 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, - "fullName": "okabe/herdr-jira-bridge", - "owner": "okabe", - "name": "herdr-jira-bridge", - "description": "Open Jira tickets as Herdr workspaces and push agent summaries back as comments.", - "url": "https://github.com/okabe/herdr-jira-bridge", - "stars": 7, - "forks": 2, - "openIssues": 0, - "language": "Python", - "topics": ["herdr-plugin", "jira", "integration"], - "createdAt": "2026-05-28T00:00:00Z", - "updatedAt": "2026-06-10T11:45:00Z", - "pushedAt": "2026-06-10T11:45:00Z", - "archived": 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, - "fullName": "noor/herdr-notify", - "owner": "noor", - "name": "herdr-notify", - "description": "Desktop and mobile notifications when an agent finishes, blocks, or needs input.", - "url": "https://github.com/noor/herdr-notify", - "stars": 4, - "forks": 0, - "openIssues": 1, - "language": "Go", - "topics": ["herdr-plugin", "notifications"], - "createdAt": "2026-06-05T00:00:00Z", - "updatedAt": "2026-06-08T16:20:00Z", - "pushedAt": "2026-06-08T16:20:00Z", - "archived": 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, - "fullName": "small-tools/herdr-snippet-runner", - "owner": "small-tools", - "name": "herdr-snippet-runner", - "description": null, - "url": "https://github.com/small-tools/herdr-snippet-runner", - "stars": 0, - "forks": 0, - "openIssues": 0, - "language": null, - "topics": ["herdr-plugin"], - "createdAt": "2026-06-14T00:00:00Z", - "updatedAt": "2026-06-14T10:00:00Z", - "pushedAt": "2026-06-14T10:00:00Z", - "archived": 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/docs-path.test.ts b/website/src/docs-path.test.ts deleted file mode 100644 index 0fadb26e..00000000 --- a/website/src/docs-path.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { docsPath, docsRoute, docsTargetHref, docsVersion } from './docs-path'; - -const scopes = { - stable: { locales: { root: ['', 'install'], ja: ['', 'install'] } }, - preview: { locales: { root: ['', 'install', 'new-page'], ja: ['', 'install'] } }, - '0.7.4': { locales: { root: ['', 'install'], ja: [''] } }, -}; - -describe('docsRoute', () => { - test.each([ - ['/docs/', { isDocs: true, locale: undefined, target: 'stable', page: '' }], - ['/ja/docs/install/', { isDocs: true, locale: 'ja', target: 'stable', page: 'install' }], - ['/docs/preview/new-page/', { isDocs: true, locale: undefined, target: 'preview', page: 'new-page' }], - ['/zh-cn/docs/preview/', { isDocs: true, locale: 'zh-cn', target: 'preview', page: '' }], - ['/docs/0.7.4/install/', { isDocs: true, locale: undefined, target: '0.7.4', page: 'install' }], - ['/ja/docs/0.7.4/', { isDocs: true, locale: 'ja', target: '0.7.4', page: '' }], - ['/blog/', { isDocs: false, locale: undefined, target: 'stable', page: '' }], - ])('parses %s', (pathname, expected) => { - expect(docsRoute(pathname)).toEqual(expected); - }); -}); - -describe('docsVersion', () => { - test.each([ - ['/docs/', 'stable'], - ['/ja/docs/preview/install/', 'preview'], - ['/zh-cn/docs/0.7.4/', '0.7.4'], - ])('maps %s', (pathname, version) => { - expect(docsVersion(pathname)).toBe(version); - }); -}); - -describe('docsTargetHref', () => { - test.each([ - ['/docs/install/', 'preview', '/docs/preview/install/'], - ['/ja/docs/install/', '0.7.4', '/ja/docs/0.7.4/'], - ['/docs/0.7.4/install/', 'stable', '/docs/install/'], - ['/docs/preview/new-page/', '0.7.4', '/docs/0.7.4/'], - ])('maps %s to %s', (pathname, target, expected) => { - expect(docsTargetHref(docsRoute(pathname), target, scopes)).toBe(expected); - }); -}); - -describe('docsPath', () => { - test.each([ - ['index.mdx', 'docs'], - ['install.mdx', 'docs/install'], - ['ja/index.mdx', 'ja/docs'], - ['ja/install.mdx', 'ja/docs/install'], - ['zh-cn/install.mdx', 'zh-cn/docs/install'], - ['preview/index.mdx', 'docs/preview'], - ['preview/install.mdx', 'docs/preview/install'], - ['preview/ja/index.mdx', 'ja/docs/preview'], - ['preview/ja/install.mdx', 'ja/docs/preview/install'], - ['_versions/0.7.4/index.mdx', 'docs/0.7.4'], - ['_versions/0.7.4/install.mdx', 'docs/0.7.4/install'], - ['_versions/0.7.4/ja/index.mdx', 'ja/docs/0.7.4'], - ['_versions/0.7.4/zh-cn/install.mdx', 'zh-cn/docs/0.7.4/install'], - ])('maps %s to %s', (entry, expected) => { - expect(docsPath({ entry })).toBe(expected); - }); -}); diff --git a/website/src/docs-path.ts b/website/src/docs-path.ts deleted file mode 100644 index 53ace41d..00000000 --- a/website/src/docs-path.ts +++ /dev/null @@ -1,87 +0,0 @@ -const docsLocales = new Set(['ja', 'zh-cn']); -const docsVersionPattern = /^\d+\.\d+\.\d+$/; - -export type DocsTarget = 'stable' | 'preview' | string; - -export interface DocsRoute { - isDocs: boolean; - locale: string | undefined; - target: DocsTarget; - page: string; -} - -export interface DocsScope { - locales: Record<string, string[]>; -} - -export function docsRoute(pathname: string): DocsRoute { - const segments = pathname.split('/').filter(Boolean); - let locale: string | undefined; - - if (segments[0] && docsLocales.has(segments[0])) { - locale = segments.shift(); - } - - if (segments.shift() !== 'docs') { - return { isDocs: false, locale, target: 'stable', page: '' }; - } - - let target: DocsTarget = 'stable'; - if (segments[0] === 'preview') { - target = 'preview'; - segments.shift(); - } else if (segments[0] && docsVersionPattern.test(segments[0])) { - target = segments.shift()!; - } - - return { - isDocs: true, - locale, - target, - page: segments.join('/'), - }; -} - -export function docsVersion(pathname: string) { - return docsRoute(pathname).target; -} - -export function docsTargetHref( - route: DocsRoute, - target: DocsTarget, - scopes?: Record<string, DocsScope>, -) { - if (!route.isDocs) throw new Error('cannot build a documentation target from a non-docs route'); - - const localeKey = route.locale ?? 'root'; - const availablePages = scopes?.[target]?.locales[localeKey]; - const page = availablePages?.includes(route.page) === false ? '' : route.page; - const prefix = route.locale ? `/${route.locale}` : ''; - const targetSegment = target === 'stable' ? '' : `/${target}`; - return `${prefix}/docs${targetSegment}${page ? `/${page}` : ''}/`; -} - -export function docsPath({ entry }: { entry: string }) { - const slug = entry.replace(/\.(md|mdx|markdown|mdown|mkdn|mkd|mdwn)$/i, ''); - const normalized = slug.replace(/\/index$/, ''); - const segments = normalized.split('/'); - - let targetSegment = ''; - if (segments[0] === 'preview') { - targetSegment = 'preview'; - segments.shift(); - } else if (segments[0] === '_versions' && segments[1] && docsVersionPattern.test(segments[1])) { - segments.shift(); - targetSegment = segments.shift()!; - } - - let locale: string | undefined; - if (segments[0] && docsLocales.has(segments[0])) { - locale = segments.shift(); - } - - const page = segments.join('/'); - const prefix = locale ? `${locale}/` : ''; - const target = targetSegment ? `/${targetSegment}` : ''; - return `${prefix}docs${target}${page && page !== 'index' ? `/${page}` : ''}`; -} diff --git a/website/src/pages/blog/[slug].astro b/website/src/pages/blog/[slug].astro deleted file mode 100644 index 3c33a38c..00000000 --- a/website/src/pages/blog/[slug].astro +++ /dev/null @@ -1,95 +0,0 @@ ---- -import { getCollection, render } from 'astro:content'; -import SiteLayout from '../../components/SiteLayout.astro'; -import AuthorCard from '../../components/AuthorCard.astro'; - -export async function getStaticPaths() { - const posts = await getCollection('blog'); - /* dev only, see blog/index.astro */ - return posts - .filter((post) => import.meta.env.DEV || !post.data.draft) - .map((post) => { - const slug = post.id.replace(/\.(md|mdx)$/i, ''); - return { params: { slug }, props: { post, slug } }; - }); -} - -const { post, slug } = Astro.props; -const { Content, headings } = await render(post); -const sectionHeadings = headings.filter((heading) => heading.depth === 2); ---- - -<SiteLayout - title={`${post.data.title} — Herdr blog`} - description={post.data.description} - canonicalPath={`/blog/${slug}/`} - ogType="article" - ogImage={post.data.ogImage} - ogImageAlt={post.data.ogImage ? post.data.title : undefined} -> - {/* a post with no h2s renders no sidebar, so the grid must stop reserving - a column for it */} - <div class:list={['article', sectionHeadings.length === 0 && 'article-solo']}> - <article class="article-body"> - <p class="article-meta"> - <a href="/blog/">← Blog</a> - <time datetime={post.data.date.toISOString()}> - {post.data.date.toLocaleDateString('en', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - </time> - </p> - <h1>{post.data.title}</h1> - {post.data.lede && post.data.description && ( - <p class="article-lede">{post.data.description}</p> - )} - <hr class="article-rule" /> - <div class="prose"> - <Content /> - </div> - <AuthorCard /> - </article> - - {sectionHeadings.length > 0 && ( - <aside class="toc" aria-label="Article sections"> - <p>Sections</p> - <nav> - {sectionHeadings.map((heading) => ( - <a href={`#${heading.slug}`} data-heading-link={heading.slug}>{heading.text}</a> - ))} - </nav> - </aside> - )} - </div> - - <script> - const headings = Array.from(document.querySelectorAll('.prose h2[id]')); - const links = new Map( - Array.from(document.querySelectorAll('[data-heading-link]')).map((link) => [ - link.getAttribute('data-heading-link'), - link, - ]), - ); - - function setActive(id) { - for (const link of links.values()) link.removeAttribute('aria-current'); - links.get(id)?.setAttribute('aria-current', 'true'); - } - - if (headings.length > 0 && links.size > 0) { - setActive(headings[0].id); - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((entry) => entry.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; - if (visible?.target instanceof HTMLElement) setActive(visible.target.id); - }, - { rootMargin: '-18% 0px -68% 0px', threshold: 0 }, - ); - for (const heading of headings) observer.observe(heading); - } - </script> -</SiteLayout> diff --git a/website/src/pages/blog/index.astro b/website/src/pages/blog/index.astro deleted file mode 100644 index de9349ea..00000000 --- a/website/src/pages/blog/index.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -import { getCollection } from 'astro:content'; -import SiteLayout from '../../components/SiteLayout.astro'; - -/* Drafts render locally and never in a build, so an unpublished post can be - reviewed on the dev server without being one deploy away from going live. */ -const posts = (await getCollection('blog')) - .filter((post) => import.meta.env.DEV || !post.data.draft) - .map((post) => ({ ...post, slug: post.id.replace(/\.(md|mdx)$/i, '') })) - .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); - -const stamp = (date) => - date.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }); ---- - -<SiteLayout - title="Herdr blog" - description="Notes on the agent runtime — product decisions, implementation writeups, and what we learned running agents on machines nobody is watching." - canonicalPath="/blog/" -> - <header class="ph"> - <div class="hd-eyebrow"><s></s>the blog</div> - <h1>Notes from the herd.</h1> - <p> - Product decisions, implementation writeups, and what we learn running agents on - machines nobody is watching. - </p> - </header> - - <div class="posts"> - {posts.map((post) => ( - <a href={`/blog/${post.slug}/`}> - <time datetime={post.data.date.toISOString()}>{stamp(post.data.date)}</time> - <div> - <h2>{post.data.title}</h2> - {post.data.description && <p>{post.data.description}</p>} - </div> - <span class="go-arrow" aria-hidden="true">→</span> - </a> - ))} - </div> -</SiteLayout> diff --git a/website/src/pages/compare.astro b/website/src/pages/compare.astro deleted file mode 100644 index 315d6a1b..00000000 --- a/website/src/pages/compare.astro +++ /dev/null @@ -1,265 +0,0 @@ ---- -import SiteLayout from '../components/SiteLayout.astro'; ---- - -<SiteLayout - title="Compare Herdr vs tmux, Zellij, cmux, Warp & agent managers" - description="Compare Herdr with tmux, Zellij, cmux, Warp, Solo, Conductor, Emdash, and Superset. Multiplexers persist terminals and apps manage agents. Herdr is the runtime agents live on, with clients on top." - canonicalPath="/compare/" -> - <main> - <header class="ph ph-split"> - <div> - <div class="hd-eyebrow"><s></s>compare</div> - <h1>Apps manage the herd. Herdr <em>runs</em> it.</h1> - <p> - Most tools Herdr gets compared to are apps: a window you open to - manage coding agents, and a window the work depends on. Herdr is a - different kind of thing: a runtime. A server holds real terminals - open on the machine, the agents live in those, and every UI, ours - included, is just a client that attaches. Keep that split in mind - and the matrix below mostly explains itself. - </p> - </div> - <aside class="ph-side"> - <b>9</b> - <span>tools compared · one runtime</span> - </aside> - </header> - - <section class="cmp" id="matrix"> - <div class="fighd"> - <span>herdr against the field</span> - <span class="r">scrolls sideways on a phone</span> - </div> - <div class="cmp-scroll"> - <table> - <thead> - <tr> - <th>capability</th> - <th class="h">herdr</th> - <th>tmux · zellij</th> - <th>cmux · warp</th> - <th>solo</th> - <th>conductor · emdash · superset</th> - </tr> - </thead> - <tbody> - <tr> - <th scope="row">Kind of thing</th> - <td class="h">runtime + clients</td> - <td>terminal multiplexer</td> - <td>terminal app</td> - <td>process dashboard</td> - <td>manager app</td> - </tr> - <tr> - <th scope="row">Work survives its own UI closing</th> - <td class="h">yes, the server owns the terminals</td> - <td>yes, detach</td> - <td>session restore</td> - <td>while the app is open</td> - <td>while the app is open</td> - </tr> - <tr> - <th scope="row">Runs inside your existing terminal</th> - <td class="h">yes</td> - <td>yes</td> - <td>replaces it</td> - <td class="no">no, desktop app</td> - <td class="no">no, desktop app</td> - </tr> - <tr> - <th scope="row">Semantic agent state</th> - <td class="h">blocked · working · done · idle</td> - <td class="no">—</td> - <td>attention cues</td> - <td>process status</td> - <td>workspace status</td> - </tr> - <tr> - <th scope="row">Detach, reattach, SSH in</th> - <td class="h">yes, any tty</td> - <td>yes</td> - <td>partial</td> - <td class="no">—</td> - <td>remote projects</td> - </tr> - <tr> - <th scope="row">Direct attach to one agent</th> - <td class="h">yes</td> - <td class="no">—</td> - <td class="no">—</td> - <td class="no">—</td> - <td class="no">—</td> - </tr> - <tr> - <th scope="row">API agents drive themselves</th> - <td class="h">read · send · wait · split · attach</td> - <td>terminal scripting</td> - <td>app APIs</td> - <td>MCP for processes</td> - <td>workflow APIs</td> - </tr> - <tr> - <th scope="row">Worktree and diff review flow</th> - <td class="h">pairs with it</td> - <td class="no">—</td> - <td>partial</td> - <td class="no">—</td> - <td>yes, their core</td> - </tr> - <tr> - <th scope="row">Clients on the same runtime</th> - <td class="h">TUI · CLI · plain SSH, more coming</td> - <td>its own client</td> - <td>the app only</td> - <td>the app only</td> - <td>the app only</td> - </tr> - </tbody> - </table> - </div> - <p class="figcap"> - The columns are categories, not enemies: Herdr pairs happily with a - worktree manager and runs fine inside a fancy terminal. - <b>The row that sorts the field is the second one:</b> what happens to - the agents when the thing you're looking at goes away. - </p> - </section> - - <div class="caps"> - <div class="cap cap-flat"> - <div class="n"><span>01</span></div> - <div class="cap-in"> - <h3>Herdr is the runtime.</h3> - <p> - A server owns the terminals; every UI is a client of it. The TUI in - the screenshots attaches, detaches and even crashes without the - agents noticing. Same for the CLI, plain SSH, and whatever - attaches next. The TUI is the first client, not the last. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>herdr-server</span><b><i class="beat"></i>up</b></div> - <div class="ev-note">4 agents · 0 clients attached · still working</div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>02</span></div> - <div class="cap-in"> - <h3>Multiplexers persist terminals, not agents.</h3> - <p> - tmux gave humans persistence, and Herdr keeps that whole - inheritance: real PTYs, detach, SSH. What it adds is the part - multiplexers never had: knowing which pane is an agent, whether - it's blocked, and how to wait on it instead of polling it. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>tmux ls</span><b>3 sessions</b></div> - <div class="ev-note">which one is waiting on you? no idea</div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>03</span></div> - <div class="cap-in"> - <h3>Terminal apps replace your terminal.</h3> - <p> - cmux and Warp can be genuinely polished, but the deal is that the - work moves into their window, with their renderer, on the machines - they support. Herdr changes nothing about your terminal. It runs - inside it, wherever a terminal and SSH already reach. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>ghostty · kitty · iterm · alacritty</span><b>kept</b></div> - <div class="ev-note">herdr runs in the one you already chose</div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>04</span></div> - <div class="cap-in"> - <h3>Manager apps are clients without a runtime.</h3> - <p> - Conductor, Emdash and Superset manage a herd from a window: - worktrees, diffs, review queues. Useful, and Herdr pairs fine with - that flow. But a window that manages agents is not a place agents - live: quit the app and the herd goes with it. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>their app quits</span><b>agents stop</b></div> - <div class="ev-row"><span>every herdr client quits</span><b>agents keep going</b></div> - </div> - </div> - <div class="cap cap-flat"> - <div class="n"><span>05</span></div> - <div class="cap-in"> - <h3>Dashboards watch processes.</h3> - <p> - Solo supervises a dev stack: health, restarts, logs. Herdr's job - is different: interactive agent terminals that stay open, hold - scrollback and state, and stay yours whether or not anything is - watching them. - </p> - </div> - <div class="ev" aria-hidden="true"> - <div class="ev-row"><span>process status</span><b>up / down</b></div> - <div class="ev-row"><span>agent state</span><b>blocked · working · idle</b></div> - </div> - </div> - </div> - - <section class="vs"> - <div class="fighd"> - <span>one-liners, for deciding fast</span> - </div> - <div class="vs-list"> - <article> - <b>herdr vs tmux</b> - <p>tmux keeps terminals alive; so does Herdr. The difference is Herdr knows which terminals are agents, what state each one is in, and how to wait on them. tmux sees panes.</p> - </article> - <article> - <b>herdr vs zellij</b> - <p>Zellij is a friendlier workspace for humans in terminals. Herdr is a runtime for agents in terminals: state, waits, direct attach, and an API.</p> - </article> - <article> - <b>herdr vs cmux</b> - <p>cmux is a Mac terminal app built around agents. Herdr isn't an app: it's the runtime underneath, in whatever terminal you already use, reachable over SSH.</p> - </article> - <article> - <b>herdr vs warp</b> - <p>Warp wants to be your terminal and your platform. Herdr keeps your terminal and owns what runs inside it.</p> - </article> - <article> - <b>herdr vs opencode web</b> - <p>opencode's web mode serves one agent over HTTP. Herdr runs the whole herd: any agent CLI, many at once, from any tty.</p> - </article> - <article> - <b>herdr vs solo</b> - <p>Solo is a dashboard that supervises your dev processes. Herdr holds interactive agent terminals open whether or not anything is watching them.</p> - </article> - <article> - <b>herdr vs conductor, emdash, superset</b> - <p>Manager apps put worktrees, diffs and review queues in a window. Herdr is the layer under that kind of window: quit their app and the agents stop; detach every Herdr client and they don't.</p> - </article> - </div> - </section> - - <section class="end"> - <span class="ram"></span> - <div class="in"> - <h2>A runtime, not another window.</h2> - <p> - Install it and nothing about your setup changes: same terminal, same - agent CLIs. They just stop dying when a window does. - </p> - <div class="ph-actions"> - <a class="btn-solid" href="/docs/install/">Install Herdr</a> - <a class="btn-ghost" href="/docs/quick-start/">Quick start</a> - </div> - </div> - </section> - </main> -</SiteLayout> diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro deleted file mode 100644 index 26a048d4..00000000 --- a/website/src/pages/index.astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -import { readFile } from 'node:fs/promises'; -import { agentCount, fmt, releaseVersion, siteStats } from '../site-data'; - -/* The numbers in the nav and hero strip are baked here at build time from the - snapshots and latest.json, so the page ships with real values, renders - correctly without JS, and can never be more than one deploy stale. A small - script on the page refreshes the counts live afterwards. */ -const homepage = await readFile(new URL('../../index.html', import.meta.url), 'utf8'); - -const { starCount, installCount, pluginCount } = await siteStats(); -const version = await releaseVersion(); -const facts = version ? `v${version} · Apache 2.0` : 'Apache 2.0'; - -/* the line drops out entirely rather than showing a placeholder if the - manifests can't be read — a missing sentence beats a wrong number */ -const agents = await agentCount(); -const agentsNote = agents ? `${agents} agents detected out of the box` : ''; -/* the strip carried a hardcoded 19; same source as row 04 now */ -const agentsCount = agents ? String(agents) : '—'; - -/* replaceAll, not replace: the star count appears in the nav as well as the - strip, and replace() would fill only the first of them. */ -const page = homepage - .replaceAll('<!--N_STARS-->', fmt(starCount)) - .replaceAll('<!--N_INSTALLS-->', fmt(installCount)) - .replaceAll('<!--N_PLUGINS-->', fmt(pluginCount)) - .replaceAll('<!--FACTS-->', facts) - .replaceAll('<!--AGENTS_NOTE-->', agentsNote) - .replaceAll('<!--N_AGENTS-->', agentsCount); ---- - -<Fragment set:html={page} /> diff --git a/website/src/pages/og/card-b.html b/website/src/pages/og/card-b.html deleted file mode 100644 index c508778b..00000000 --- a/website/src/pages/og/card-b.html +++ /dev/null @@ -1,60 +0,0 @@ -<!doctype html> -<html lang="en" class="og"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>herdr OG card — source</title> -<meta name="robots" content="noindex" /> -<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&display=swap" rel="stylesheet" /> -<link rel="stylesheet" href="/css/og-card.css?v=3" /> -<style> - /* Source of truth for /assets/og-card-v9.png. - Regenerate: open this page, capture the full document, crop to the - leftmost 1200x630 (the document is pinned to exactly the card size, but - the browser viewport may be wider). Bump the filename when the artwork - changes — Slack, X and LinkedIn cache unfurls by URL for weeks. */ - /* centre the headline in the band below the head rather than in the whole - card, so a four-line headline can't ride up into the lockup */ - .in { display: flex; flex-direction: column; justify-content: center; padding-top: 118px; } - h1 { - font-family: var(--disp); - font-weight: 900; - letter-spacing: -0.055em; - line-height: 0.86; - font-size: 104px; - margin: 30px 0 0; - max-width: 15ch; - color: var(--ink); - } - h1 em { font-style: normal; color: var(--spot); } - /* X, LinkedIn and Slack all draw their own attribution over the bottom of an - unfurl, so the lockup and provenance sit at the top and the bottom band is - left deliberately empty. */ - .head { - position: absolute; - left: 62px; - right: 62px; - top: 48px; - z-index: 1; - display: flex; - align-items: center; - justify-content: space-between; - } - .head .sub { color: var(--faint); font-size: 21px; letter-spacing: 0.1em; } - .head .sub b { color: var(--dim); font-weight: 400; } -</style> -</head> -<body class="og"> -<div class="card"> - <span class="ram mass"></span> - <div class="head"> - <div class="lockup"><span class="ram"></span><b>herdr</b></div> - <div class="sub"><b>herdr.dev</b> · Apache 2.0</div> - </div> - <div class="in pad"> - <div class="eyebrow"><s></s>the agent runtime</div> - <h1>Run them <em>anywhere.</em> Leave them running.</h1> - </div> -</div> -</body> -</html> diff --git a/website/src/pages/og/card-perf.html b/website/src/pages/og/card-perf.html deleted file mode 100644 index e2142252..00000000 --- a/website/src/pages/og/card-perf.html +++ /dev/null @@ -1,56 +0,0 @@ -<!doctype html> -<html lang="en" class="og"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>herdr performance OG card — source</title> -<meta name="robots" content="noindex" /> -<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&display=swap" rel="stylesheet" /> -<link rel="stylesheet" href="/css/og-card.css?v=3" /> -<style> - /* Source of truth for /assets/og-blog-frames-v1.png. Same capture recipe as - card-b: open the page, capture the full document, it is already 1200x630. - - A share card is read at thumbnail size in a timeline, so it carries the - headline and the one measurement that backs it. The caveats live in the - post, where there is room to read them. */ - .head { - position: absolute; left: 62px; right: 62px; top: 48px; z-index: 1; - display: flex; align-items: center; justify-content: space-between; - } - .head .sub { color: var(--faint); font-size: 21px; letter-spacing: 0.1em; } - .head .sub b { color: var(--dim); font-weight: 400; } - - .in { display: flex; flex-direction: column; justify-content: center; padding: 118px 62px 54px; } - h1 { - font-family: var(--disp); font-weight: 900; letter-spacing: -0.055em; - line-height: 0.9; font-size: 96px; margin: 0; color: var(--ink); - } - h1 em { font-style: normal; color: var(--spot); } - - .meas { - display: flex; align-items: baseline; gap: 22px; - margin-top: 42px; padding-top: 22px; border-top: 1px solid var(--line2); - color: var(--faint); font-size: 22px; letter-spacing: 0.04em; - } - .meas b { color: var(--ink); font-weight: 400; } - .meas .r { margin-left: auto; color: var(--dim); } -</style> -</head> -<body class="og"> -<div class="card"> - <span class="ram mass"></span> - <div class="head"> - <div class="lockup"><span class="ram"></span><b>herdr</b></div> - <div class="sub"><b>herdr.dev</b> · Apache 2.0</div> - </div> - <div class="in"> - <h1>Ten agents,<br />three clients,<br /><em>95% less CPU.</em></h1> - <div class="meas"> - <span>total cpu <b>13.014% → 0.617%</b></span> - <span class="r">v0.7.5 → v0.8.0</span> - </div> - </div> -</div> -</body> -</html> diff --git a/website/src/pages/og/card-yc.html b/website/src/pages/og/card-yc.html deleted file mode 100644 index af8c69e7..00000000 --- a/website/src/pages/og/card-yc.html +++ /dev/null @@ -1,42 +0,0 @@ -<!doctype html> -<html lang="en" class="og"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>herdr YC announcement OG card — source</title> -<meta name="robots" content="noindex" /> -<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&display=swap" rel="stylesheet" /> -<link rel="stylesheet" href="/css/og-card.css?v=3" /> -<style> - /* Source of truth for /assets/og-blog-yc-v1.png. Same capture recipe as - card-b: open the page, capture the full document, it is already 1200x630. */ - :root { --spot: #fb651e; } /* YC orange, this card only */ - .head { - position: absolute; left: 62px; right: 62px; top: 48px; z-index: 1; - display: flex; align-items: center; justify-content: space-between; - } - .head .sub { color: var(--faint); font-size: 21px; letter-spacing: 0.1em; } - .head .sub b { color: var(--dim); font-weight: 400; } - - .in { display: flex; flex-direction: column; justify-content: center; padding: 118px 62px 54px; } - /* headline is the post title verbatim */ - h1 { - font-family: var(--disp); font-weight: 900; letter-spacing: -0.055em; - line-height: 0.9; font-size: 92px; margin: 0; color: var(--ink); - } - h1 em { font-style: normal; color: var(--spot); } -</style> -</head> -<body class="og"> -<div class="card"> - <span class="ram mass"></span> - <div class="head"> - <div class="lockup"><span class="ram"></span><b>herdr</b></div> - <div class="sub"><b>herdr.dev</b> · Apache 2.0</div> - </div> - <div class="in"> - <h1>Herdr is joining<br /><em>Y Combinator.</em><br />The runtime stays open.</h1> - </div> -</div> -</body> -</html> diff --git a/website/src/pages/plugins.astro b/website/src/pages/plugins.astro deleted file mode 100644 index 57b189ea..00000000 --- a/website/src/pages/plugins.astro +++ /dev/null @@ -1,1159 +0,0 @@ ---- -import SiteLayout from '../components/SiteLayout.astro'; -import fixture from '../data/plugins-fixture.json'; -const snapshotUrl = 'https://assets.herdr.dev/plugins/index.json'; - -// Seed the page server-side from the public R2 snapshot so `bun run dev` and -// the production build both render real data immediately. If the live fetch -// fails, dev falls back to a local fixture for offline design; production seeds -// empty and lets the client fetch fill it (never fake data). -let liveData = null; -try { - const res = await fetch(snapshotUrl); - if (res.ok) liveData = await res.json(); -} catch { - // ignore: handled by the fallbacks below -} -const initialData = liveData ?? (import.meta.env.DEV ? fixture : null); -const initialIsFixture = !liveData && import.meta.env.DEV; -// A card is a repository; a plugin is a validated manifest inside it. A monorepo -// with three manifests is one card and three plugins, so the two counts differ -// and both are stated. `source.totalCount` is the raw GitHub topic search and -// still includes blacklisted and manifest-less repositories, so it is not used. -const repos = initialData?.plugins ?? []; -const repositoryCount = initialData?.repositoryCount ?? repos.length; -const pluginCount = - initialData?.pluginCount ?? - repos.reduce((n, repo) => n + (repo.manifests?.length || 1), 0); -const apiUrl = import.meta.env.DEV ? null : snapshotUrl; ---- - -<SiteLayout - title="Herdr plugins: community plugin marketplace" - description="Discover community Herdr plugins published on GitHub. An automatic index of public repositories tagged with the herdr-plugin topic." - canonicalPath="/plugins/" -> - <main class="plugins-page"> - <header class="ph ph-split"> - <div> - <div class="hd-eyebrow"><s></s>the marketplace</div> - <h1>Built by the herd.</h1> - <p> - Community plugins published on GitHub, discovered automatically from the - <code>herdr-plugin</code> topic. Install any of them with - <code>herdr plugin install</code>, or publish your own and get listed. - </p> - <p class="plugins-trust"> - <span class="status-dot dot-idle"></span> - Listings aren't reviewed by Herdr, so install at your own discretion. - <a href="/docs/plugins/#trust-and-security">Trust guidance →</a> - </p> - </div> - <aside class="ph-side"> - <b id="plugins-hero-count">{pluginCount.toLocaleString('en-US')}</b> - <span id="plugins-hero-summary"> - plugins across {repositoryCount.toLocaleString('en-US')} repositories - </span> - <a href="/docs/plugins/">Publish yours →</a> - </aside> - </header> - - <div id="plugins-shelves" hidden> - <section class="section plugins-shelf" id="shelf-trending" hidden> - <div class="shelf-head"> - <div> - <h2 class="shelf-title">Trending this week</h2> - <p class="shelf-sub">fastest-growing repositories, relative to their own size</p> - </div> - <button class="shelf-all" type="button" data-sort="trending">see all →</button> - </div> - <div id="trend-grid" class="trend-grid" role="list"></div> - </section> - - <section class="section plugins-shelf" id="shelf-new" hidden> - <div class="shelf-head"> - <div> - <h2 class="shelf-title">New to the herd</h2> - <p class="shelf-sub">fresh projects that joined recently</p> - </div> - <button class="shelf-all" type="button" data-sort="newest">see all →</button> - </div> - <div class="new-rows" role="list"></div> - </section> - </div> - - <section class="section plugins-results"> - <div class="shelf-head plugins-browse-head" id="plugins-browse-head"> - <div> - <h2 class="shelf-title">Browse everything</h2> - <p class="shelf-sub">the full index — search or pick a sort</p> - </div> - </div> - <div class="plugins-controls"> - <div class="plugins-search-wrap"> - <span class="plugins-search-icon" aria-hidden="true">/</span> - <input - id="plugins-search" - type="search" - class="plugins-search" - placeholder="search plugins, repositories, owners, languages…" - autocomplete="off" - spellcheck="false" - aria-label="Search plugins" - /> - </div> - <label class="plugins-sort-wrap"> - <span class="plugins-sort-label">sort</span> - <select id="plugins-sort" class="plugins-sort" aria-label="Sort plugins"> - <option value="popular">Popular</option> - <option value="trending">Trending</option> - <option value="recent">Recently updated</option> - <option value="newest">Newest</option> - <option value="name">Name</option> - </select> - </label> - </div> - <p id="plugins-meta" class="plugins-meta" aria-live="polite"></p> - <div id="plugins-grid" class="plugins-grid" role="list"></div> - <div id="plugins-status" class="plugins-status">Loading plugins…</div> - <button id="plugins-more" class="button button-secondary plugins-more" hidden></button> - </section> - - <section class="section plugins-publish"> - <div class="plugins-publish-card"> - <p class="section-kicker"># publish</p> - <h3>Want your plugin listed?</h3> - <p> - Add the GitHub topic <code>herdr-plugin</code> to a public repository - with a <code>herdr-plugin.toml</code> manifest. The index picks it up - on the next refresh. No submission, no review queue. - </p> - <div class="plugins-publish-actions"> - <a class="button button-primary" href="/docs/marketplace/">How publishing works</a> - <a class="button button-secondary" href="/docs/plugins/">Author a plugin</a> - </div> - </div> - </section> - </main> - - <script define:vars={{ initialData, initialIsFixture, apiUrl }}> - const grid = document.getElementById("plugins-grid"); - const statusEl = document.getElementById("plugins-status"); - const searchInput = document.getElementById("plugins-search"); - const sortSelect = document.getElementById("plugins-sort"); - const metaEl = document.getElementById("plugins-meta"); - const moreButton = document.getElementById("plugins-more"); - const heroCountEl = document.getElementById("plugins-hero-count"); - const heroSummaryEl = document.getElementById("plugins-hero-summary"); - const shelvesWrap = document.getElementById("plugins-shelves"); - const trendingShelf = document.getElementById("shelf-trending"); - const trendGrid = document.getElementById("trend-grid"); - const newShelf = document.getElementById("shelf-new"); - - // 24 fills complete rows in both the 3-column and 2-column grid layouts. - const PAGE_SIZE = 24; - let visibleLimit = PAGE_SIZE; - - // A shelf earns its place: percent growth only counts once a repository - // gained real stars this week, so the long tail (median: 1 star) can't - // flood trending with 1-to-3-star noise, while big repositories must grow - // at their own scale to appear. - const TRENDING_MIN_GAIN = 10; - // "New" means new to everyone: recently indexed AND a recently created - // repository. Established projects that just adopted the topic surface - // through trending instead of burying actual newcomers. - const NEW_SEEN_MAX_DAYS = 14; - const NEW_CREATED_MAX_DAYS = 60; - // Six cards fill complete shelf rows in the 3, 2, and 1 column layouts. - const SHELF_SIZE = 6; - // A shelf with one or two cards reads as a struggling marketplace, which - // is worse than no shelf at all. - const SHELF_MIN_CARDS = 3; - - const LANG_COLORS = { - TypeScript: "#3178c6", - JavaScript: "#f1e05a", - Rust: "#dea584", - Python: "#3572A5", - Go: "#00ADD8", - Lua: "#000080", - Shell: "#89e051", - "C++": "#f34b7d", - Ruby: "#701516", - }; - - // One entry per repository. Its `manifests` are the plugins it ships. - let repos = []; - let usingFallback = false; - - function timeAgo(iso) { - if (!iso) return null; - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return null; - const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); - const units = [ - ["y", 31536000], - ["mo", 2592000], - ["w", 604800], - ["d", 86400], - ["h", 3600], - ["m", 60], - ]; - for (const [label, size] of units) { - const v = Math.floor(secs / size); - if (v >= 1) return v + label + " ago"; - } - return "just now"; - } - - function isGithubUrl(url) { - return typeof url === "string" && /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(url); - } - - function daysSince(iso) { - if (!iso) return null; - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return null; - return (Date.now() - then) / 86400000; - } - - function trendingScore(p) { - const delta = p.starsDelta7d; - if (typeof delta !== "number" || delta < TRENDING_MIN_GAIN) return null; - return delta / Math.max((p.stars ?? 0) - delta, 1); - } - - function isNewToHerd(p) { - const seen = daysSince(p.firstSeenAt); - const created = daysSince(p.createdAt); - return ( - seen !== null && seen <= NEW_SEEN_MAX_DAYS && - created !== null && created <= NEW_CREATED_MAX_DAYS - ); - } - - function trendingBadge(p) { - const delta = p.starsDelta7d ?? 0; - const base = Math.max((p.stars ?? 0) - delta, 0); - return "▲ " + base + " → " + (p.stars ?? 0) + " ★ this week"; - } - - function joinedBadge(p) { - const ago = timeAgo(p.firstSeenAt); - return ago === "just now" ? "✦ just joined" : ago ? "✦ joined " + ago : null; - } - - function avatar(p, size) { - const img = el("img", "plugin-avatar"); - img.src = "https://github.com/" + encodeURIComponent(p.owner || "") + ".png?size=" + size; - img.alt = ""; - img.loading = "lazy"; - img.width = size / 2; - img.height = size / 2; - return img; - } - - function cardShell(p, className) { - const url = isGithubUrl(p.url) ? p.url : null; - const card = el(url ? "a" : "div", className); - card.setAttribute("role", "listitem"); - if (url) { - card.href = url; - card.target = "_blank"; - card.rel = "noopener noreferrer"; - } - return card; - } - - function titleOf(p) { - const title = el("div", "plugin-title"); - title.appendChild(el("span", "plugin-owner", (p.owner || "?") + "/")); - title.appendChild(el("span", "plugin-name", p.name || "unknown")); - return title; - } - - function buildTrendCard(p) { - const card = cardShell(p, "plugin-card plugin-trend-card"); - const head = el("div", "plugin-card-head"); - const lead = el("div", "trend-lead"); - lead.appendChild(avatar(p, 64)); - lead.appendChild(titleOf(p)); - head.appendChild(lead); - head.appendChild(el("span", "plugin-go", "↗")); - card.appendChild(head); - card.appendChild(el("div", "plugin-badge", trendingBadge(p))); - const desc = el( - "p", - "plugin-desc" + (p.description ? "" : " is-empty"), - p.description || "No description provided.", - ); - card.appendChild(desc); - return card; - } - - function buildRow(p, note) { - const row = cardShell(p, "plugin-row"); - row.appendChild(avatar(p, 64)); - const body = el("div", "plugin-row-body"); - body.appendChild(titleOf(p)); - const desc = el( - "span", - "plugin-row-desc" + (p.description ? "" : " is-empty"), - p.description || "No description provided.", - ); - body.appendChild(desc); - row.appendChild(body); - const side = el("div", "plugin-row-side"); - if (note) side.appendChild(el("span", "plugin-row-note", note)); - const stars = el("span", "plugin-stat"); - stars.appendChild(el("span", "plugin-star", "★")); - stars.appendChild(el("span", null, String(p.stars ?? 0))); - side.appendChild(stars); - row.appendChild(side); - return row; - } - - function el(tag, className, text) { - const node = document.createElement(tag); - if (className) node.className = className; - if (text != null) node.textContent = text; - return node; - } - - function buildCard(p, badge) { - const url = isGithubUrl(p.url) ? p.url : null; - const card = el(url ? "a" : "div", "plugin-card"); - card.setAttribute("role", "listitem"); - if (url) { - card.href = url; - card.target = "_blank"; - card.rel = "noopener noreferrer"; - } - - const head = el("div", "plugin-card-head"); - const lead = el("div", "trend-lead"); - lead.appendChild(avatar(p, 64)); - lead.appendChild(titleOf(p)); - head.appendChild(lead); - head.appendChild(el("span", "plugin-go", "↗")); - card.appendChild(head); - if (badge) card.appendChild(el("div", "plugin-badge", badge)); - - const desc = el( - "p", - "plugin-desc" + (p.description ? "" : " is-empty"), - p.description || "No description provided.", - ); - card.appendChild(desc); - - const meta = el("div", "plugin-meta-row"); - - const stars = el("span", "plugin-stat"); - stars.appendChild(el("span", "plugin-star", "★")); - stars.appendChild(el("span", null, String(p.stars ?? 0))); - meta.appendChild(stars); - - if (p.language) { - const lang = el("span", "plugin-stat"); - const dot = el("span", "plugin-lang-dot"); - dot.style.background = LANG_COLORS[p.language] || "var(--muted)"; - lang.appendChild(dot); - lang.appendChild(el("span", null, p.language)); - meta.appendChild(lang); - } - - // Nearly every repository ships exactly one plugin whose name restates the - // repository name, so only its version earns a place in the meta row. - const manifests = manifestsOf(p); - if (manifests.length === 1 && manifests[0].version) { - meta.appendChild(el("span", "plugin-stat plugin-version", "v" + manifests[0].version)); - } - - const updated = timeAgo(p.pushedAt); - if (updated) meta.appendChild(el("span", "plugin-stat plugin-updated", "updated " + updated)); - - card.appendChild(meta); - - // A monorepo is the case the card title can't answer on its own, so it - // gets the extra row: how many plugins, and which. - if (manifests.length > 1) { - const ships = el("div", "plugin-ships"); - ships.appendChild(el("span", "plugin-ship-count", manifests.length + " plugins")); - ships.appendChild( - el("span", "plugin-ship-list", manifests.map(manifestLabel).join(" · ")), - ); - card.appendChild(ships); - } - - return card; - } - - function manifestsOf(p) { - return Array.isArray(p.manifests) ? p.manifests : []; - } - - function manifestLabel(m) { - return m.name || m.id || m.path || "plugin"; - } - - function matches(p, q) { - if (!q) return true; - const hay = [ - p.fullName, - p.owner, - p.name, - p.description, - p.language, - ...(p.topics || []), - ...manifestsOf(p).flatMap((m) => [m.name, m.id, m.description]), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return q.split(/\s+/).every((term) => hay.includes(term)); - } - - function sortRepos(list, mode) { - const by = [...list]; - const t = (v) => new Date(v || 0).getTime() || 0; - if (mode === "recent") by.sort((a, b) => t(b.pushedAt) - t(a.pushedAt)); - // "Newest" means newest to the marketplace, matching the shelf's order; - // GitHub's createdAt is the fallback for snapshots without firstSeenAt. - else if (mode === "newest") - by.sort( - (a, b) => t(b.firstSeenAt ?? b.createdAt) - t(a.firstSeenAt ?? a.createdAt), - ); - else if (mode === "name") by.sort((a, b) => (a.fullName || "").localeCompare(b.fullName || "")); - else if (mode === "trending") - // Qualified movers first by growth rate, then everything else by raw - // weekly gain so the list stays fully ordered past the shelf criteria. - by.sort( - (a, b) => - (trendingScore(b) ?? -1) - (trendingScore(a) ?? -1) || - (b.starsDelta7d ?? 0) - (a.starsDelta7d ?? 0) || - (b.stars ?? 0) - (a.stars ?? 0), - ); - else - by.sort( - (a, b) => (b.stars ?? 0) - (a.stars ?? 0) || t(b.pushedAt) - t(a.pushedAt), - ); - return by; - } - - function renderShelves() { - const trending = repos - .map((p) => [p, trendingScore(p)]) - .filter(([, score]) => score !== null) - .sort((a, b) => b[1] - a[1]) - .slice(0, SHELF_SIZE) - .map(([p]) => p); - trendGrid.replaceChildren(); - if (trending.length >= SHELF_MIN_CARDS) { - for (const p of trending) trendGrid.appendChild(buildTrendCard(p)); - } - trendingShelf.hidden = trending.length < SHELF_MIN_CARDS; - - const fresh = repos - .filter(isNewToHerd) - .sort((a, b) => new Date(b.firstSeenAt).getTime() - new Date(a.firstSeenAt).getTime()) - .slice(0, SHELF_SIZE); - const newRows = newShelf.querySelector(".new-rows"); - newRows.replaceChildren(); - for (const p of fresh) newRows.appendChild(buildRow(p, joinedBadge(p))); - newShelf.hidden = fresh.length < SHELF_MIN_CARDS; - - const anyShelf = !trendingShelf.hidden || !newShelf.hidden; - shelvesWrap.dataset.available = anyShelf ? "1" : ""; - updateShelfVisibility(); - } - - function updateShelfVisibility() { - shelvesWrap.hidden = shelvesWrap.dataset.available !== "1"; - } - - function render() { - const q = searchInput.value.trim().toLowerCase(); - const visible = sortRepos(repos.filter((p) => matches(p, q)), sortSelect.value); - const shown = visible.slice(0, visibleLimit); - - grid.replaceChildren(); - for (const p of shown) grid.appendChild(buildCard(p)); - - // Grow a page at a time, and name the real remainder on the last step so - // the button never promises 24 and delivers 5. - const remaining = visible.length - shown.length; - moreButton.hidden = remaining === 0; - if (remaining > 0) { - moreButton.textContent = "show " + Math.min(PAGE_SIZE, remaining) + " more"; - } - - if (visible.length === 0) { - statusEl.textContent = q - ? "No plugins match your search." - : "No plugins are listed yet."; - statusEl.hidden = false; - } else { - statusEl.hidden = true; - } - } - - function setMeta(data) { - const parts = []; - const repoCount = (data && data.repositoryCount) ?? repos.length; - const total = - (data && data.pluginCount) ?? - repos.reduce((n, repo) => n + (manifestsOf(repo).length || 1), 0); - parts.push(total + (total === 1 ? " plugin" : " plugins")); - parts.push(repoCount + (repoCount === 1 ? " repository" : " repositories")); - heroCountEl.textContent = total.toLocaleString("en-US"); - heroSummaryEl.textContent = - (total === 1 ? "plugin" : "plugins") + - " across " + - repoCount.toLocaleString("en-US") + - (repoCount === 1 ? " repository" : " repositories"); - const starsGained = repos.reduce( - (n, repo) => n + Math.max(repo.starsDelta7d ?? 0, 0), - 0, - ); - if (starsGained > 0) parts.push("+" + starsGained.toLocaleString("en-US") + " ★ this week"); - const joined = repos.filter((repo) => { - const seen = daysSince(repo.firstSeenAt); - return seen !== null && seen <= 7; - }).length; - if (joined > 0) parts.push(joined + " joined this week"); - const ago = timeAgo(data && data.generatedAt); - if (ago) parts.push("updated " + ago); - if (usingFallback) parts.push("preview data"); - metaEl.textContent = parts.join(" · "); - } - - function apply(data) { - repos = Array.isArray(data && data.plugins) ? data.plugins : []; - setMeta(data); - renderShelves(); - render(); - } - - function showError() { - repos = []; - metaEl.textContent = ""; - grid.replaceChildren(); - moreButton.hidden = true; - statusEl.textContent = - "The plugin marketplace is warming up. Check back in a few minutes."; - statusEl.hidden = false; - } - - async function load() { - // Render the server-seeded data immediately (live in prod and dev; fixture - // only as a dev offline fallback). - if (initialData) { - usingFallback = initialIsFixture; - apply(initialData); - } - // Refresh from the same-origin function in production for the freshest snapshot. - if (apiUrl) { - try { - const res = await fetch(apiUrl, { headers: { accept: "application/json" } }); - if (!res.ok) throw new Error("status " + res.status); - usingFallback = false; - apply(await res.json()); - } catch (err) { - if (!initialData) showError(); - } - } else if (!initialData) { - showError(); - } - } - - // A new query or order is a new list, so paging starts over. - function resetAndRender() { - visibleLimit = PAGE_SIZE; - render(); - } - - searchInput.addEventListener("input", resetAndRender); - sortSelect.addEventListener("change", resetAndRender); - moreButton.addEventListener("click", () => { - visibleLimit += PAGE_SIZE; - render(); - }); - for (const button of document.querySelectorAll(".shelf-all")) { - button.addEventListener("click", () => { - sortSelect.value = button.dataset.sort; - resetAndRender(); - grid.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - } - load(); - </script> - - <style is:global> - .plugins-page { - padding-bottom: 5rem; - --plugins-clear-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M1.5 1.5l9 9M10.5 1.5l-9 9' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); - } - - /* Reuse .section only for max-width centering, not its 5rem padding + top divider. */ - .plugins-page .section { - padding-block: 0; - border-top: none; - } - - /* Everything below the hero sits inside the same horizontal gutter the - hero uses, so text never touches the page's column lines. Selectors - outrank site.css's `body.site .plugins-page .section` padding reset. */ - .plugins-page #plugins-shelves, - body.site .plugins-page .section.plugins-results, - body.site .plugins-page .section.plugins-publish { - padding-inline: var(--gut); - } - - .plugins-page .plugins-hero { - padding-top: 3rem; - } - - .plugins-hero h2 { - margin: 0; - color: var(--ink); - font-family: var(--mono); - font-weight: 400; - letter-spacing: -0.04em; - line-height: 1.05; - font-size: clamp(2rem, 4vw, 3rem); - } - - .plugins-lede { - max-width: 640px; - margin: 1.1rem 0 0; - color: var(--ink-soft); - font-size: clamp(1rem, 1.6vw, 1.15rem); - line-height: 1.6; - } - - .plugins-lede code, - .plugins-trust code, - .plugins-publish-card code { - font-family: var(--mono); - font-size: 0.85em; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-sm); - padding: 0.1em 0.35em; - white-space: nowrap; - } - - .plugins-trust { - max-width: 640px; - margin: 1.1rem 0 0; - color: var(--muted); - font-size: 0.85rem; - line-height: 1.55; - } - - .plugins-trust .status-dot { - display: inline-block; - margin-right: 0.4rem; - vertical-align: middle; - } - - .plugins-trust a { - color: var(--accent); - text-decoration: underline; - text-underline-offset: 2px; - } - - /* Controls */ - .plugins-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; - margin-top: 1.5rem; - } - - .plugins-search-wrap { - position: relative; - flex: 1 1 18rem; - display: flex; - align-items: center; - } - - .plugins-search-icon { - position: absolute; - left: 0.7rem; - color: var(--muted-2); - font-family: var(--mono); - font-size: 0.9rem; - pointer-events: none; - } - - .plugins-search { - width: 100%; - padding: 0.6rem 0.8rem 0.6rem 1.7rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-md); - color: var(--ink); - font-family: var(--mono); - font-size: 0.85rem; - } - - .plugins-search:focus { - outline: none; - border-color: var(--accent); - } - - /* Chromium paints the native clear button in the OS accent colour, which - belongs to no ground the site defines. Redraw it from our own tokens. */ - .plugins-search::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; - width: 0.7rem; - height: 0.7rem; - cursor: pointer; - background: var(--muted-2); - -webkit-mask: var(--plugins-clear-icon) center / contain no-repeat; - mask: var(--plugins-clear-icon) center / contain no-repeat; - } - - .plugins-search::-webkit-search-cancel-button:hover { - background: var(--ink); - } - - .plugins-sort-wrap { - display: inline-flex; - align-items: center; - gap: 0.5rem; - } - - .plugins-sort-label { - color: var(--muted); - font-family: var(--mono); - font-size: 0.78rem; - text-transform: lowercase; - letter-spacing: 0.06em; - } - - .plugins-sort { - padding: 0.55rem 0.7rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-md); - color: var(--ink); - font-family: var(--mono); - font-size: 0.82rem; - cursor: pointer; - } - - .plugins-sort:focus { - outline: none; - border-color: var(--accent); - } - - .plugins-meta { - margin: 1rem 0 0; - color: var(--muted); - font-family: var(--mono); - font-size: 0.78rem; - letter-spacing: 0.04em; - } - - /* Shelves */ - #plugins-shelves { - margin-top: 3.5rem; - } - - body.site .plugins-page .section.plugins-shelf { - margin-top: 4.5rem; - } - - body.site .plugins-page #plugins-shelves > .section.plugins-shelf:first-child { - margin-top: 0; - } - - .shelf-head { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1.25rem; - } - - /* Outranks style.css's `.section h2` mono heading rule. */ - body.site .plugins-page h2.shelf-title { - margin: 0; - color: var(--ink); - font-family: var(--disp); - font-weight: 800; - font-size: 21px; - letter-spacing: -0.03em; - line-height: 1.2; - } - - .shelf-sub { - margin: 0.35rem 0 0; - color: var(--muted); - font-size: 0.82rem; - } - - .shelf-all { - flex: 0 0 auto; - padding: 0; - background: none; - border: none; - color: var(--accent); - font-family: var(--mono); - font-size: 0.82rem; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 3px; - } - - .shelf-all:hover { - color: var(--ink); - } - - .plugin-badge { - align-self: flex-start; - padding: 0.15rem 0.5rem; - background: color-mix(in srgb, var(--accent) 10%, transparent); - border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); - border-radius: var(--radius-sm); - color: var(--accent); - font-family: var(--mono); - font-size: 0.74rem; - letter-spacing: 0.02em; - white-space: nowrap; - } - - .plugin-avatar { - width: 1.5rem; - height: 1.5rem; - flex: 0 0 auto; - border-radius: var(--radius-sm); - background: var(--bg); - border: 1px solid var(--line); - } - - .trend-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.85rem; - } - - .plugin-trend-card { - min-height: 0; - gap: 0.65rem; - padding: 1.1rem 1.2rem; - } - - .trend-lead { - display: flex; - align-items: center; - gap: 0.6rem; - min-width: 0; - } - - .new-rows { - display: flex; - flex-direction: column; - } - - .plugin-row { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.65rem 0.5rem; - border-top: 1px solid var(--line); - border-radius: var(--radius-sm); - transition: background 0.12s ease; - min-width: 0; - } - - .plugin-row:last-child { - border-bottom: 1px solid var(--line); - } - - a.plugin-row:hover { - background: var(--bg-elevated); - } - - .plugin-row-body { - display: flex; - flex-direction: column; - gap: 0.1rem; - min-width: 0; - } - - .plugin-row-body .plugin-title { - font-size: 0.85rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .plugin-row-desc { - color: var(--muted); - font-size: 0.78rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .plugin-row-desc.is-empty { - color: var(--muted-2); - font-style: italic; - } - - .plugin-row-side { - display: flex; - align-items: center; - gap: 0.75rem; - margin-left: auto; - flex: 0 0 auto; - color: var(--muted); - font-family: var(--mono); - font-size: 0.75rem; - } - - .plugin-row-note { - color: var(--accent); - white-space: nowrap; - } - - .plugins-browse-head { - margin-bottom: 1rem; - padding-top: 2.25rem; - border-top: 1px solid var(--line); - } - - .plugins-browse-head[hidden] { - display: none; - } - - @media (max-width: 56rem) { - .trend-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - @media (max-width: 38rem) { - .trend-grid { - grid-template-columns: minmax(0, 1fr); - } - } - - /* Grid */ - body.site .plugins-page .section.plugins-results { - margin-top: 4.5rem; - } - - .plugins-results .plugins-grid { - margin-top: 1.5rem; - } - - .plugins-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); - gap: 0.85rem; - } - - .plugin-card { - display: flex; - flex-direction: column; - gap: 0.7rem; - padding: 1.25rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-md); - transition: border-color 0.12s ease, transform 0.12s ease; - min-height: 8.5rem; - } - - a.plugin-card:hover { - border-color: var(--accent); - transform: translateY(-2px); - } - - .plugin-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.5rem; - } - - .plugin-title { - font-family: var(--mono); - font-size: 0.95rem; - line-height: 1.3; - word-break: break-word; - } - - .plugin-owner { - color: var(--muted); - } - - .plugin-name { - color: var(--ink); - font-weight: 600; - } - - .plugin-go { - color: var(--muted-2); - font-family: var(--mono); - flex: 0 0 auto; - transition: color 0.12s ease; - } - - a.plugin-card:hover .plugin-go { - color: var(--accent); - } - - .plugin-desc { - margin: 0; - color: var(--ink-soft); - font-size: 0.88rem; - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .plugin-desc.is-empty { - color: var(--muted-2); - font-style: italic; - } - - .plugin-meta-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.9rem; - margin-top: auto; - color: var(--muted); - font-family: var(--mono); - font-size: 0.78rem; - } - - .plugin-stat { - display: inline-flex; - align-items: center; - gap: 0.35rem; - } - - .plugin-star { - color: var(--yellow, #d4a72c); - } - - .plugin-lang-dot { - width: 0.6rem; - height: 0.6rem; - border-radius: 50%; - flex: 0 0 auto; - } - - .plugin-updated { - margin-left: auto; - color: var(--muted-2); - } - - /* What the repository ships. Sits below the repo stats, separated by a rule, - so the repository stays the primary identity of the card. */ - .plugin-ships { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.45rem; - padding-top: 0.7rem; - border-top: 1px solid var(--line); - font-family: var(--mono); - font-size: 0.78rem; - line-height: 1.5; - min-width: 0; - } - - .plugin-version { - color: var(--muted-2); - } - - .plugin-ship-count { - flex: 0 0 auto; - padding: 0.05rem 0.4rem; - border: 1px solid var(--line); - border-radius: var(--radius-sm); - color: var(--muted-2); - } - - .plugin-ship-list { - align-self: stretch; - min-width: 0; - color: var(--muted); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .plugins-status { - padding: 3rem 1rem; - text-align: center; - color: var(--muted); - font-family: var(--mono); - font-size: 0.85rem; - } - - .plugins-more { - display: flex; - margin: 2rem auto 0; - cursor: pointer; - } - - .plugins-more[hidden] { - display: none; - } - - /* Publish */ - .plugins-publish { - margin-top: 3.5rem; - } - - .plugins-publish-card { - padding: 2rem; - background: var(--bg-elevated); - border: 1px solid var(--line); - border-radius: var(--radius-lg); - } - - .plugins-publish-card h3 { - margin: 0.25rem 0 0; - color: var(--ink); - font-family: var(--mono); - font-weight: 400; - font-size: 1.4rem; - letter-spacing: -0.02em; - } - - .plugins-publish-card p { - max-width: 620px; - margin: 0.8rem 0 0; - color: var(--ink-soft); - line-height: 1.6; - } - - .plugins-publish-actions { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - margin-top: 1.5rem; - } - - @media (max-width: 38rem) { - .plugins-meta { - margin-left: 0; - width: 100%; - } - } - </style> -</SiteLayout> diff --git a/website/src/site-data.ts b/website/src/site-data.ts deleted file mode 100644 index 94836fe7..00000000 --- a/website/src/site-data.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 39eef15f..00000000 --- a/website/src/styles/starlight.css +++ /dev/null @@ -1,273 +0,0 @@ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400 700; - font-display: swap; - src: url("/assets/jbm.woff2") format("woff2"); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, - U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -:root { - --sl-font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --sl-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - --sl-content-width: 54rem; - --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: #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: #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 { - font-family: var(--sl-font-mono); - font-weight: 700; - letter-spacing: -0.04em; -} - -.docs-brand-logo { - position: relative; - display: inline-block; - width: 32px; - height: 32px; - flex: 0 0 auto; - border: 1px solid var(--sl-color-hairline); - border-radius: 4px; - background: var(--sl-color-bg-nav); -} - -.docs-brand-logo::before { - content: ""; - position: absolute; - inset: 2px; - background: var(--sl-color-white); - -webkit-mask: url("/assets/agent-icons/herdr-mask.svg") center / contain no-repeat; - mask: url("/assets/agent-icons/herdr-mask.svg") center / contain no-repeat; -} - -.docs-hero-logo { - display: block; - width: min(20rem, 62vw); - aspect-ratio: 1; - background: var(--sl-color-white); - -webkit-mask: url("/assets/agent-icons/herdr-mask.svg") center / contain no-repeat; - mask: url("/assets/agent-icons/herdr-mask.svg") center / contain no-repeat; -} - -.sl-markdown-content { - font-size: 1rem; - line-height: 1.65; -} - -.sl-markdown-content h1, -.hero h1 { - letter-spacing: -0.03em; - line-height: 1.1; - font-weight: 900; - font-size: clamp(2rem, 5vw, 3.2rem); - font-family: var(--sl-font-display); - letter-spacing: -0.045em; -} - -.sl-markdown-content h2 { - margin-top: 2.4rem; - letter-spacing: -0.03em; - font-family: var(--sl-font-display); - font-weight: 800; -} - -.sl-markdown-content h3 { - letter-spacing: -0.01em; - font-family: var(--sl-font-mono); - font-weight: 400; -} - -.sl-markdown-content > p:first-child { - font-size: 1.05rem; - color: var(--sl-color-gray-1); -} - -#starlight__sidebar a[aria-current="page"] { - background: color-mix(in srgb, var(--sl-color-accent) 8%, transparent); - color: var(--sl-color-accent-high); - box-shadow: inset 2px 0 0 var(--sl-color-accent); -} - -.mobile-doc-shots { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); - gap: 1rem; - margin: 1.25rem 0; -} - -.mobile-doc-shots figure { - margin: 0; -} - -.mobile-doc-shots img { - display: block; - width: 100%; - border: 1px solid var(--sl-color-hairline); - border-radius: 4px; -} - -.mobile-doc-shots figcaption { - margin-top: 0.45rem; - color: var(--sl-color-gray-3); - font-size: var(--sl-text-sm); - text-align: center; -} - -.sl-markdown-content a:not(:where(.not-content *)) { - text-decoration-thickness: 1px; - text-underline-offset: 0.18em; - color: var(--sl-color-accent); -} - -.sl-markdown-content .heading-link-copy-feedback { - position: absolute; - z-index: 10; - bottom: calc(100% + 0.4rem); - inset-inline-start: 50%; - padding: 0.25rem 0.45rem; - border: 1px solid var(--sl-color-hairline-light); - border-radius: 3px; - background: var(--sl-color-bg-nav); - color: var(--sl-color-white); - font-family: var(--sl-font); - font-size: var(--sl-text-xs); - font-weight: 500; - line-height: 1.2; - white-space: nowrap; - pointer-events: none; - transform: translateX(-50%); - animation: heading-link-copied 1.4s ease both; -} - -@keyframes heading-link-copied { - 0% { opacity: 0; transform: translate(-50%, 0.25rem); } - 15%, 75% { opacity: 1; transform: translate(-50%, 0); } - 100% { opacity: 0; transform: translate(-50%, -0.15rem); } -} - -@media (prefers-reduced-motion: reduce) { - .sl-markdown-content .heading-link-copy-feedback { - animation: none; - } -} - -.sl-markdown-content code:not(:where(.not-content *)) { - border: 1px solid var(--sl-color-hairline); - border-radius: 2px; - font-size: 0.88em; - background: var(--sl-color-bg-inline-code); -} - -.sl-markdown-content pre { - border: 1px solid var(--sl-color-hairline); - border-radius: 4px; -} - -.expressive-code { - margin: 1rem 0 1.35rem; -} - -.expressive-code .frame { - border: 1px solid var(--sl-color-hairline) !important; - border-radius: 4px !important; - box-shadow: none !important; - overflow: hidden; -} - -.expressive-code .frame.is-terminal { - --button-spacing: 0.4rem; -} - -.expressive-code .frame.is-terminal .header { - display: none; -} - -.expressive-code .frame pre { - border: 0 !important; - border-radius: 0 !important; -} - -.expressive-code .frame.has-title:not(.is-terminal) .header { - background: var(--sl-color-bg-nav) !important; - border-color: var(--sl-color-hairline) !important; -} - -.sl-markdown-content .card { - border-color: var(--sl-color-hairline); - background: - linear-gradient(180deg, color-mix(in srgb, var(--sl-color-accent) 4%, transparent), transparent 58%), - var(--sl-color-black); - border-radius: 4px; -} - -.sl-markdown-content .card .title { - color: var(--sl-color-white); -} - -.hero img { - image-rendering: auto; - border-radius: 4px; -} |
