cyberine
UsageRun recipes

Website rebuild target (Next.js + Fumadocs) — site/

The rebuild (T666) lives in site/ as a separate buildable target, and since the T670 cutover (2026-09-25) it is the only docs site.

npm run site:install      # install site/ deps ONCE (its own node_modules -- see the note below)
npm run site:dev          # sync USAGE.md, then next dev on http://localhost:41800
npm run site:build        # sync USAGE.md, then a static export into site/out
npm run site:start        # serve the built export on :41800
npm run site:typecheck    # tsc --noEmit inside site/
npm run site:lint:gate    # eslint --quiet inside site/

site/ has its own node_modules and its own package-lock.json, and that is required, not untidy. Next 16 needs React 19; the root pins React 18 because Ink 5 requires it. A shared dependency tree resolves one of the two wrong, and the symptom is either the TUI or the site breaking at runtime with no type error. For the same reason turbopack.root is pinned in site/next.config.mjs — Turbopack otherwise picks the outermost lockfile (the root's) and would resolve the site against React 18.

The root gates deliberately do NOT cover site/: root tsconfig.json includes only src and examples, and root eslint.config.mjs ignores site/**. The site's gates are the site:* scripts above, run alongside the root's.

Markdown stays the source of truth, and NOTHING under site/content/docs is committed. Two sync scripts feed the site at build time, both run by npm run site:sync (which site:build, site:dev and site:qa all call first):

  • scripts/sync-docs.mjs copies the repo-root USAGE.md into docs/usage.md (which sync-site-docs.mjs then reads with the rest of docs/) and site/content/docs/usage.md (with a prepended YAML frontmatter block, since plain USAGE.md carries none).
  • scripts/sync-site-docs.mjs migrates the whole tracked docs/**/*.md tree into site/content/docs/, deriving title and description per page and emitting a meta.json per section for deliberate nav order.

The generated tool/env catalogs keep generating into the same root USAGE.md, so npm run docs:catalog:check — which gates CI — measures exactly the file both sites render.

docs/**/*.md remains the ONE tracked copy. Two tracked copies of a 6500-line recipe book and ~40 reference pages is a fork waiting to happen, and the drift would stay invisible until a provider page disagreed with itself. The T670 cutover deleted docs/.vitepress and kept docs/**/*.md, because that tree is the content.

This also means docs/providers/README.md keeps its filename, so the GitHub folder view and the cyberine-project-provider-docs skill both keep working. website-spec.md warned that renaming it to index.md would break that skill; syncing at build time instead of moving files makes the warning moot rather than something to work around — only the generated copy is renamed.

The Fumadocs copy is .md, not .mdx, and that is load-bearing: MDX parses <...> as JSX, and this file is full of bare angle brackets in command syntax ("<task>", --model <id>). As .mdx the build fails with Unexpected character `/` ... expected a character that can start a name. Fumadocs picks the processor format from the extension, so .md compiles it as the plain Markdown it is — which is what "the canonical file must not have to be MDX-safe" requires.

The six marketing routes (/, /how-it-works/, /judges/, /cost/, /providers/, /surfaces/) are declared once in site/src/lib/routes.ts with each route's angle, which the footer readout prints beside the same runId on every route. Since 2026-10-01 they share one design language, the "run record" (see docs/design/website-master.md): Fraunces / IBM Plex Sans / IBM Plex Mono, a light paper mode and a dark screen mode, and one blue accent reserved for the kept result. Motion is a hero replay of a recorded run (Replay button), scroll-scrubbed worktree diagrams, hover and focus links between a figure and its printed lines, and a cross-fade between routes; every sequence has a finished still state under prefers-reduced-motion.

Colour mode: marketing routes follow the OS until the visitor picks one; docs open dark for everyone. One choice, made with the mode control on either surface, is stored in localStorage['cyb-theme'] and honoured on both from then on. The pre-paint bootstrap (site/src/lib/theme.ts, THEME_BOOTSTRAP) stamps data-surface and data-theme on <html> before first paint, so no route flashes the wrong mode, and ThemeSync re-resolves on client navigation between the two surfaces. To check it: open / with the OS in light mode (light), open /docs/ (dark), flip the control, and reload either surface.

Known limitations: with JavaScript off the bootstrap cannot run, so every route renders the marketing light mode; the hero's bar lengths and timing are drawn, not measured (its caption says so).

Design QA gate — npm run site:qa

The MASTER's quality bar is only real if it is repeatable, so the whole pass is one committed script (site/scripts/qa.mjs, kx7dm) rather than a checklist. It exits non-zero on any HIGH finding and is wired into ci.yml's site job:

npm run site:qa            # build + audit; the full pass
npm --prefix site run qa -- --no-build   # audit the existing site/out
npm --prefix site run qa -- --json       # machine-readable
npm run site:fonts         # re-download the self-hosted woff2 + regenerate fonts.css

These checks run with no browser: the static export builds; detect-slop over BOTH surfaces with the right --surface flag each (two runs, because the display-type boldness floor is a marketing gate and not a docs one); the locked palettes' contrast, every text role against each ground it sits on across 4 modes (marketing light and dark, docs light and dark) plus focus and non-text marks against the 3:1 minimum, and every locked hex present in tokens.css; contrast measured from the REAL stylesheets with opacity composited; token discipline (no hex below the raw tier, no hardcoded #fff/#000); font self-host drift; a dead-internal-link scan over the emitted files; a third-party-request scan over the shipped bytes; per-route social-metadata coverage; and a static accessibility pass over the built export.

T707 rebuilt three of those, because they were reporting PASS while inspecting nothing:

  • detect-slop is vendored at site/scripts/detect-slop.mjs. It used to be resolved only from ~/.claude/skills/nf-design-system/scripts/, a machine-local uncommitted directory, so on any CI runner the script took its if (!existsSync(detector)) branch and the MASTER's first hard gate ("zero HIGH slop") had never executed in CI once — while the CI step was named after it. qa.mjs prefers the vendored copy and still falls back to the skill path. Re-vendor by hand when the detector gains a rule; never edit the copy to silence a finding.
  • cssContrast() reads the stylesheets. The palette check compares the MASTER's own hex table and never opens a CSS file, which makes it a sound guard on the MASTER and a blind one on the site: no CSS change of any kind could make it fail. It reported PASS while .nav-set a rendered at 4.17:1 — --cyb-ink IS AA on --cyb-base, and opacity: .58 on top of it is what fell below. The new check resolves every custom property per mode (four modes since 2026-10-01: each :root[data-surface][data-theme] combination, the more specific block winning), measures each stylesheet only in the modes of the surface it paints (docs.css on docs, base.css on both, the rest on marketing), inherits a nested rule's ground from its ancestor selector in the same file, walks every rule under site/src, composites opacity and color-mix(..., transparent) alpha, and measures against the background the rule actually sits on, at the WCAG large-text threshold where the font size earns it.
  • metadataCoverage() asserts the tags exist. socialUrls() only ever regex-matched pages that ALREADY emitted og: / twitter: / canonical elements and then localhost-tested the matches — so a page emitting NONE produced zero matches and was counted clean. That is exactly how 43 docs routes shipped with no card at all. Coverage is asserted first; the URL check then means something.

The accessibility pass is HIGH on what no page may ship without — a document language, one <main>, at least one <h1>, a "skip to content" link whose target id actually exists, alt on every image, an accessible name on every form control — and MEDIUM on heading ORDER and duplicate <h1>s, which the docs surface trips by pairing fumadocs' DocsTitle with each MDX file's own top heading.

Known limitations and deferred work: cssContrast() is a static read of cascade-free rules, not a browser's computed style — inheritance is resolved by walking a selector's own ancestor prefixes within the same file (.foot a inherits .foot's colour), specificity conflicts between unrelated selectors are not modelled, and a colour set by JS is invisible to it. Its color-mix maths is exact against transparent (every mix in this stylesheet) and approximate for a future opaque-to-opaque mix, because it interpolates in sRGB rather than oklab. Rules that are exempt rather than broken live in the short, justified CONTRAST_EXEMPT list at the top of the script (WCAG's inactive-component exception) — keep it short, and never add an entry to turn a red build green. metadataCoverage() and the accessibility pass skip Next's error shells (404.html, _not-found/), named explicitly so a real route cannot fall through. Canonical is required only when CYBERINE_DOCS_ORIGIN is set, because with no origin Next emits none by design.

It reports what it could not verify rather than passing silently. These checks need a real browser — the layout-audit sweep (3 viewports x 2 modes x 7 routes = 42 combinations), 60fps frame timing, and reduced-motion behaviour. Browser automation needs the user's explicit in-turn permission, which a script cannot grant itself, so those are printed under NOT VERIFIED with the command to run each by hand. A false green on unmeasured layout would be worse than a gap.

The script also exports the two verified layout-audit false-positive classes as LAYOUT_AUDIT_FILTERS, each with its measured justification, so the 40-of-46 findings they produced on 2026-08-27 are not re-litigated: stack-overlap fires on grid cells that sit side by side horizontally (the check only compares vertical rects), and misaligned-top fires on center-aligned row siblings whose boxes differ in height.

Brand asset kit — npm run site:assets

The kit is authored vector, regenerated from tracked sources (site/assets/src/), not image-generated. site/assets/MANIFEST.md records every asset's source and method.

npm run site:assets       # regenerate mark, app icons, and the 7 per-route OG cards
npm --prefix site run assets:check   # verify every generated asset is present

Three things worth knowing before touching it:

  • The mark is one definition, correct in both modes. src/components/BrandMark.tsx is inline SVG reading the role tokens, so light renders verified as #006E50 and dark as sulfur #D6E600. The app icon carries its own dark plate so it is legible against light and dark browser chrome without a second export.
  • Social-card type is glyph OUTLINES, not <text>. librsvg on macOS resolves families through Pango's CoreText backend, so a repo-local fontconfig never reaches it — verified with fc-match, otool -L, and an inline @font-face attempt, all three of which said the config was right while the render came out in a generic grotesque. scripts/gen-assets.mjs therefore uses fontkit to instance the variable axes (wght: 850, which ships in no static Archivo instance) and emits paths. Renders identically on a CI runner with no fonts installed.
  • site/assets/fonts/*.ttf is a build input, not a shipped asset — it exists only for that outline extraction and is gitignored. The site serves the woff2 in site/public/fonts/.

Product captures are deliberately NOT in the kit (T697). T672 requires real captures of the running TUI, chat REPL, and dashboard, never mockups. The two Ink surfaces need a real TTY — script -q /dev/null npm run tui fails with tcgetattr/ioctl: Operation not supported on socket from a background session — and the dashboard needs browser permission. A mockup would satisfy the checklist and violate the requirement, so the gap is named and tracked instead.

Social metadata and the launch gate

Every marketing route emits its own og:image, so sharing /judges does not preview as the homepage. Absolute URLs are gated on one env var, exactly as the VitePress site proved:

CYBERINE_DOCS_ORIGIN=https://<domain> npm run site:build

Unset — as in every local site:build — Next absolutises og:image and canonical against http://localhost:3000. That is expected for a locally-reviewed build, but a crawler cannot fetch a localhost URL, so qa.mjs reports it as unverified and turns it into a hard HIGH the moment CYBERINE_DOCS_ORIGIN is set and a localhost URL still survives. That is the T671/T675 launch gate, mechanically enforced rather than remembered.

Production deploy: cyberine.dev (T671)

The site is live at https://cyberine.dev, served by the Worker cyberine (Cloudflare Workers static assets, the current form of Cloudflare Pages) on the operator's Niafam account. Deploy config is site/wrangler.jsonc: assets from site/out, auto-trailing-slash HTML handling, the export's own 404.html for unknown paths, and cyberine.dev as the Worker's custom domain.

npm run site:deploy   # build + QA with CYBERINE_DOCS_ORIGIN=https://cyberine.dev, then wrangler deploy

Wrangler needs a login on the Niafam account (npx wrangler login). An exported CLOUDFLARE_API_TOKEN/CLOUDFLARE_API_KEY without Workers write on that account overrides the login and fails the deploy; unset it for the command.

Rollback is a version switch, not a rebuild (drilled 2026-10-01: rolled back to the previous version, confirmed the live response changed, rolled forward):

cd site && npx wrangler deployments list          # version ids, newest last
cd site && npx wrangler rollback <version-id> --message "<why>" -y

Redirects live in the Cloudflare zones, not in the site: www.cyberine.dev, cyberinecore.com and www.cyberinecore.com answer 301 to the same path on https://cyberine.dev (redirect rules on proxied placeholder AAAA 100:: records), and both zones have Always Use HTTPS on.

Search-facing behaviour of the build (SEO pass, 2026-10-01):

  • USAGE.md is split into section pages on the site. scripts/sync-docs.mjs (via scripts/splitUsage.mjs) writes one page per ## section under /docs/usage/<section>/, and any section over 300,000 characters (today only Run recipes) one page per ### under it. Googlebot reads only the first 2 MB of an HTML file and the single page was 7.2 MB. In-page ](#anchor) links in USAGE.md are rewritten to the page that now holds the heading; an anchor that matches no heading fails the sync. USAGE.md itself stays one file in the repo.
  • /docs/design/* and /docs/eval/* are noindex, follow and left out of the sitemap (NOINDEX_DOCS_SECTIONS in site/src/lib/metadata.ts). They stay reachable and linked.
  • The home page carries WebSite JSON-LD (name, url, description) when CYBERINE_DOCS_ORIGIN is set.
  • Sitemap <lastmod> is the last commit date of a page's tracked source (the docs/ file, or the marketing route's files); usage section pages carry none, since one USAGE.md date would be wrong for most sections.
  • site/public/_headers marks the content-hashed /_next/static/* files immutable for a year.

Known limitations and deferred work. There is no llms.txt and no dedicated quickstart page yet, both of which T671's done-when listed; the site has never had either, so they are new pages, not launch steps. The layout, frame-timing and reduced-motion checks site:qa skips still need a browser pass against the live origin. No CI job deploys: a push to main does not change the live site until site:deploy runs.

Known limitations and deferred work (T666 is the scaffold, not the site). The six routes are SHELLS: each renders its eyebrow, heading, lede and readout, and the spatial idea it is supposed to carry is recorded in a comment at the top of its page.tsx. Implementing those skeletons and the four-move signature suite is T668; the token/type/state system and the self-hosted fonts are T667; the four-move signature suite is T668. T670 retired VitePress once parity was measured.

Docs content is migrated (T669). All 41 pages from docs/** reach the new site at their mapped URLs — verified against website-spec.md's own mapping table over the emitted files, plus 0 dead internal links across 52 pages. Search is Fumadocs' STATIC index (staticGET + staticClient), which is required under output: 'export': the default fetch client asks a server that does not exist and returns zero results for every query with no error to notice.