Applications

The four apps in the monorepo — web, e2e, Storybook and docs — what each is, how to run it and how it is built.

2026-11-07

Overview

The monorepo ships four applications under apps/. They divide cleanly by job: one is the product, one exercises it in a real browser, one documents the design system, and one documents everything (this site). None of them owns the design system itself — that lives in the packages/* component packages, which these apps consume. For how the whole site fits together, see How this site is built.

App Package Port Purpose Run command
apps/web @v4/web 3000 The Euro League website — chrome + composed route packages pnpm dev (or pnpm --filter @v4/web dev)
apps/e2e @v4/e2e — Playwright e2e — drives Storybook story iframes in real browsers pnpm --filter @v4/e2e test
apps/storybook @v4/storybook 6006 The single Storybook — component reference, colour-set toolbar, story tests pnpm --filter @v4/storybook dev
apps/docs @v4/docs 4400 This documentation site — serves content/, plus an embedded chat agent pnpm --filter @v4/docs dev

apps/e2e has no port of its own: it builds and serves (or reuses) Storybook on 6006 — E2E_PORT moves it — and drives its story iframes, so the two are always paired. The one exception is tests/basketball-app/data-areas.e2e.ts, which runs against a live web app named by BASKETBALL_APP_URL and skips when that is unset.

The run model: pnpm workspaces + Turborepo

Every app is a private workspace package ("private": true, workspace:* internal deps — nothing is published). Two commands drive them:

  • pnpm --filter <package> <script> runs one app's own script — e.g. pnpm --filter @v4/storybook dev, pnpm --filter @v4/e2e test. This is the scalpel; use it whenever you want exactly one app.
  • pnpm dev / pnpm build / pnpm lint / pnpm check-types / pnpm test at the repo root fan out through Turborepo, which runs each package's matching script in dependency order and caches the results. A second pnpm build with nothing changed is a cache replay measured in seconds.

Turbo dependencies are why apps/e2e's Playwright webServer can call pnpm turbo run build --filter=@v4/storybook and get a fully-cached storybook-static output when nothing upstream moved (see e2e).

Which doc to read

  • The web app — a summary of apps/web and pointers into its deep docs (Web app, App & rendering).
  • e2e — the component-e2e app in depth: browser projects, the Storybook webServer, the helpers, and what specs assert.
  • Storybook — the Storybook app: the colour-set toolbar, fonts, the Vitest browser story tests, and the visual-diff / contrast-table scripts.
  • Docs — how this documentation app itself works.

The web app (apps/web)

apps/web is the Euro League website — the app that is deployed. It is deliberately thin: it owns no page bodies. Its job is the shell (chrome, theming, the model client) plus the composition that wires per-domain route packages into Next's app router.

pnpm dev                     # all dev servers; web is on :3000
pnpm --filter @v4/web dev    # just the web app

The dev script is next dev --webpack with no -p flag, so it serves on Next's default port 3000. build runs next build --webpack with a 12 GB Node heap (NODE_OPTIONS='--max-old-space-size=12288') — the composed route set and the club theme are large enough to need it. start is next start.

What it owns, in one breath

  • Composition — apps/web/club.config.ts (identity, the bought domains content, video and basketball, the env markers, sections, features) drives pnpm compose, which reads each composed route package's routes.json — @v4/content-app, @v4/video-app and @v4/basketball-app — and generates the shims under src/app/[locale]/…: 48 files (34 pages, 12 loading.tsx, a segment layout and a route-shims test), each headed // GENERATED by compose-club. A drift gate fails CI if a shim is stale. Only two pages are hand-written: [locale]/code-of-conduct/page.tsx (@v4/code-of-conduct) and a QA-only error-500 page.

  • Chrome — navigation, footer and site settings through cached fetches (src/lib/chrome.ts, with a per-competition chrome beside the site-wide one), rendered by @v4/ghost-core components.

  • Theming — the club theme is emitted in PARTS with createClubThemePart, so each route loads only the component themes it renders:

    • src/theme/tokens.css.ts — every page: the club's global layer, declared once on :root, plus the ghost-core and ghost-content themes;
    • tokens-video.css.ts and tokens-basketball.css.ts — imported by the /video and data-area segment layouts;
    • tokens-home-hero.css.ts — the homepage hero widgets' basketball themes;
    • competition-themes.css.ts — the EuroCup and NextGen layers on :root.<class>;
    • fonts.css.ts — the brand faces.

    next.config.ts sets PH_THEME_EMISSION=club, so the club theme is the one emitter and the packages only register their themes. Detail: Theme the site.

  • Data — everything Contentful-shaped flows through @v4/model; the app never writes raw Contentful selections. Competition data comes from the Competition Engine through @v4/competition-engine.

  • Middleware — src/middleware.ts redirects addresses without a locale segment, writes the Cloudflare Cache-Tag header per path, and answers 404 for the basketball areas when BASKETBALL_ROUTES=false.

  • API routes — src/app/api/: revalidate (the Contentful webhook and the engine's post-ingest call), preview and disable-preview (draft mode), auth (Cortex sign-on), code-of-conduct, games, live, players, stats, health, geolocation and download.

  • SEO — sitemap.xml (an index of chunked children under sitemaps/, built from Contentful and Competition Engine queries), robots.ts and global-error.tsx.

  • Club integrations — the app mounts the club-only packages: @v4/analytics (GTM and OneTrust), @v4/cortex-sso, @v4/wsc, @v4/eltv, @v4/ecal and @v4/code-of-conduct, each switched on by its environment variables (Environment variables).

Where the depth lives

This page is a summary on purpose. apps/web has two dedicated homes:

For the broader picture of how the app relates to the route packages and the model, see Domains & bolt-ons; for which of its files the template owns, Take template updates.

The e2e app (apps/e2e)

@v4/e2e is a Playwright app whose subject is mainly the component library, not the web app. It drives the Storybook story iframes in real browsers and asserts the things only a real browser pass can settle: the computed output of the token chain, per-instance overrides, interaction states, RTL, and the Ghost breakpoints. Unit and story tests stay in Vitest; e2e asserts what needs an end-to-end browser.

Every component ships a spec, foldered by the package that owns it: apps/e2e/tests/<folder>/<kebab-name>.e2e.ts. There are 395 specs:

Folder Specs What they drive
tests/ghost-core/ 154 @v4/ghost-core stories
tests/ghost-content/ 80 @v4/ghost-content stories
tests/ghost-video/ 27 @v4/ghost-video stories
tests/ghost-basketball/ 130 @v4/ghost-basketball stories
tests/shared/ 3 cross-package specs (theme emission, the page measure, the docs-page sweep)
tests/basketball-app/ 1 the data areas, against a RUNNING web app (BASKETBALL_APP_URL; skips if unset)

Each folder belongs to one package, so a package's specs live and go with it. Helper imports are therefore two levels up (../../helpers/*).

Running it

# Builds and serves (or reuses) Storybook on :6006, then runs the suite
pnpm --filter @v4/e2e test

# Install the browser engines once
pnpm --filter @v4/e2e exec playwright install chromium webkit firefox

Other scripts from apps/e2e/package.json:

pnpm --filter @v4/e2e test:chromium        # desktop + ghost-s only
pnpm --filter @v4/e2e test:cross-browser   # webkit + firefox only
pnpm --filter @v4/e2e test:full-matrix     # E2E_FULL_MATRIX=1 — full set on WebKit and Firefox too
pnpm --filter @v4/e2e test:ui              # Playwright UI mode

test passes --pass-with-no-tests, so a run whose filter matches no spec does not fail.

The six browser projects

Defined in apps/e2e/playwright.config.ts:

Project Engine Viewport Scope
desktop Chromium 1280 × 800 Full spec set — the wide breakpoint rung
ghost-s Chromium 360 × 800 Full spec set — the Ghost S breakpoint rung
webkit WebKit 1280 Cross-browser risk subset (or the full set)
firefox Firefox 1280 Cross-browser risk subset (or the full set)
tablet Chromium 834 × 1112 Only INTERMEDIATE_RUNG_SPECS (**/page-measure.e2e.ts, **/*-bleed.e2e.ts)
laptop Chromium 1180 × 820 Only INTERMEDIATE_RUNG_SPECS

The Chromium projects are the breakpoint matrix: 360 / 834 / 1180 / 1280, one project inside each band the page measure steps at. Every component's token and manifest assertions run at 360 and 1280, so a value that changes across the mediaUp('lg') boundary is asserted at both rungs. tablet and laptop run only the specs that derive their expectations from the viewport — a spec that asserts "1264px at desktop" encodes the 1280 project's value and would fail them for no defect. The two bands the design does not draw are explained in Responsive rungs.

WebKit and Firefox run a curated CROSS_BROWSER_SPECS subset rather than the whole suite. The reasoning is cost/benefit: the suite is ~1,100 tests per project, the overwhelming majority are computed-style readbacks of the token chain (padding-inline-start resolving to 4px is not where engines disagree), and a full four-project matrix roughly doubles the wall clock against a 25-minute CI timeout. So the subset is not a sample — it is exactly the specs that exercise the surfaces engines do diverge on: sequential focus and :focus-visible, focus traps built on checkVisibility(), scrolling / scroll-snap / RTL scrollLeft, dvh sizing, :has(), inset-box-shadow rings, and sub-pixel layout rounding. Each entry in the list names the surface that put it there.

To run the full set on WebKit and Firefox as well (tablet and laptop keep their subset) — a release/nightly gate, or whenever a new engine version lands or a broad refactor touches layout:

E2E_FULL_MATRIX=1 pnpm --filter @v4/e2e test

CROSS_BROWSER_SPECS is also filtered by existsSync at config load: an entry whose spec file is gone is dropped, so webkit/firefox never match nothing.

How Storybook gets served (the static path)

The specs address http://localhost:${PORT} — PORT is E2E_PORT, default 6006 — and the Playwright webServer brings it up:

command:
  "pnpm turbo run build --filter=@v4/storybook && pnpm --filter @v4/storybook serve-static",
env: { PORT: String(PORT) },
url: `http://localhost:${PORT}`,
reuseExistingServer: true,
timeout: 600000,
cwd: "../..",

This is a static Storybook build, served — not the dev server. turbo run build makes the storybook-static output fully cached (seconds when nothing changed), and serve-static is a plain http-server over it. Every story then loads in milliseconds with zero compile contention, which retired the whole cold-Vite / worker-starvation failure class the suite's timeouts were once provisioned against.

reuseExistingServer: true still honours a server you started by hand on 6006 — including a dev server, which is slower and can wedge the run — and including another checkout's Storybook. Stop any manually-run Storybook before a suite run, or run on a free port: E2E_PORT=6016 pnpm --filter @v4/e2e test.

Which theme the specs measure

use.storageState seeds two localStorage keys into every page:

  • ph-club-theme=ghost — the e2e asserts the Ghost contract values. The Storybook UI defaults to the Euro League club theme, so a value you read in the browser can legitimately differ from the one a spec asserts. (The Vitest story suite sets the same flag in .storybook/vitest.setup.ts but still renders the club theme — see /reference/applications#storybook.) When you rebind a token, the e2e asserts Ghost's value for the new token name.
  • ph:table-nudge=1 — marks the basketball tables' scroll nudge as already seen, so it never animates under a measurement.

Because the compile contention is gone, workers are set generously (4 in CI, 8 locally). The expect timeout is raised to 15 s at the config root — a statement about the harness, not about any assertion: the residual sb-preparing-story hang reports itself as element(s) not found, moves to a different test each run, and only affects auto-retrying locator assertions. The value comparisons (expect(await computed(…)).toBe(…)) have no retry loop, so a real defect still fails in about a second.

The helpers

Shared harness code lives in apps/e2e/helpers/. Two are load-bearing enough to be house rules:

  • isDesktopProject() (viewport.ts) — asks "is this a desktop-class viewport?" by checking the project is not in the narrow set ({ "ghost-s" }), never by matching the string "desktop". The earlier project.name === "desktop" check silently asserted the 360px Small-set values against the 1280px WebKit and Firefox renders the moment those projects landed, failing ~90 assertions for a reason unrelated to the engine. Always ask about the viewport, never the project name.

  • tabTo(page, target) (keyboard.ts) — presses Tab for real, and falls back to a programmatic .focus() only if the engine declined to move focus. This exists because WebKit honours the macOS "Full Keyboard Access" setting (off by default), under which Tab reaches text fields only. The fallback is behavioural, not platform-sniffed, so Chromium, Firefox and WebKit-on-Linux still assert genuine tab reachability (a component that drops out of the tab order still fails), while WebKit-on-macOS keeps running the ring and roving-tabindex assertions instead of dying at the first Tab. Every fallback is recorded as a test annotation so a run that quietly stops exercising real Tab navigation is visible in the report.

The rest of helpers/ handles timing and geometry races that read as component defects, and the page-level checks: media.ts waits for every <img> to actually decode (a zero-height height: auto media well throws off every block-axis measurement), animation.ts waits for entrance animations to finish before measuring (getComputedStyle reports the mid-flight value), and canvas.ts computes the content width actually available to an element (its parent's content box) so a fixed-width cap can be checked against the real container at each rung. measure.ts derives the expected page measure from the viewport; bleed.ts reads where a full-bleed rail and its slides sit; cell-text.ts and mid-word.ts catch table text spilling past its cell and words split across two lines; motion.ts lists the animations running after a trigger (and checks there are none under reduced motion); and page-audit.ts runs the scripts/page-audit.mjs checks (page overflow, clipping, broken images) inside a page-template story.

What a spec asserts

A spec is generated from the component's tokens/_manifest.json and its {name}.figma.json, and reads token values back off the rendered element by computed style. Taking button-common.e2e.ts as the pattern:

const computed = async (locator: Locator, property: string): Promise<string> =>
  locator.evaluate((el, prop) => getComputedStyle(el).getPropertyValue(prop), property);

test("the primary default button resolves the manifest token chain", async ({ page }) => {
  await page.goto("/iframe.html?id=ghost-core-atoms-buttoncommon--label-only&viewMode=story");
  const root = page.locator('[data-component="ButtonCommon"]');
  await expect(root).toBeVisible();

  expect(await computed(root, "background-color")).toBe("rgb(25, 118, 220)"); // #1976DC
  expect(await computed(root, "padding-inline-start")).toBe("2px"); // S.Spacer.2
  expect(await computed(root, "border-radius")).toBe("0px"); // S.BorderRadius.None
});

Across the suite that covers:

  • Resolved token values — colours, spacing (as logical properties: padding-inline-start), radii, typography — read back as computed style and compared to the manifest's expected chain. This is the bulk of every spec.
  • Per-instance overrides — the tokens prop (override level 5) wins over the emitted theme values on the element it targets.
  • Interaction states — for interactive components: real hover/active diffs, keyboard Tab + Enter/Space, the :focus-visible ring, and aria-disabled semantics under real clicks (Playwright's toBeDisabled() is blind to the aria-disabled distinction, so specs assert the DOM property and a real focus check alongside it).
  • RTL — a dir="rtl" rendering confirms logical properties mirror and the directionalIcon convention flips inline-axis glyphs while physical-by-design gradients do not.
  • Breakpoints — the same assertions run at desktop (1280) and ghost-s (360), and specs that switch layout use page.setViewportSize to assert intermediate rungs.
  • Motion — one frame after the trigger, getAnimations() lists the expected animation with its token duration; under emulated reduced motion it lists none and the element is already in its final state.

Where to go next

  • Component e2e (testing guide) — the day-to-day authoring conventions: viewport branching, tabTo, the token-change ↔ assertion-change discipline, and the diagnosis protocols for a "stuck" suite.
  • Browser sweeps — the page-level gates that run against the live app rather than stories.
  • To add a spec by hand, use the /write-e2e-test skill, which encodes the harness traps. Specs are also scaffolded by the e2e-test-generator quality-gate agent (.claude/agents/quality-gates/e2e-test-generator.md, Phase 2.9 of the design-to-code pipeline) — that is the tool to reach for when adding a component's spec, rather than hand-authoring from scratch.

Opt-in suites

  • E2E_DOCS_SWEEP=1 — tests/shared/docs-pages-render.e2e.ts renders every docs page.
  • BASKETBALL_APP_URL=<origin> — tests/basketball-app/data-areas.e2e.ts runs against a live web app's data areas.

The Storybook app (apps/storybook)

@v4/storybook is the single Storybook for the whole design system. It is the live component reference, the source of the story iframes apps/e2e drives, and the home of the Vitest browser-mode story tests. It depends on the four component packages — @v4/ghost-core, @v4/ghost-content, @v4/ghost-video and @v4/ghost-basketball — plus @v4/code-of-conduct (whose screens it renders as on the site), @v4/tokens and the club's @v4/tokens-euro-league, because a story renders the real component.

pnpm --filter @v4/storybook dev    # dev server on :6006

The dev script is storybook dev -p 6006 --no-open (preceded by stage-refs, below), so it always serves on 6006 — the same port apps/e2e expects.

Layout

Stories live in apps/storybook/stories/, grouped by owning package first, atomic level second:

stories/
├── ghost-core/{atoms,molecules,organisms,templates}/{kebab-name}/   148 components
├── ghost-content/…                                                  72
├── ghost-video/…                                                    27
├── ghost-basketball/{atoms,molecules,organisms,templates}/…         139 (25 / 56 / 48 / 10, plus _shared/)
├── code-of-conduct/      ← the Code of Conduct screens (@v4/code-of-conduct)
└── foundations/          ← the token layer (@v4/tokens)

The sidebar's top-level groups follow the same ownership: Ghost-Core/, Ghost-Content/, Ghost-Video/, Ghost-Basketball/ and Code-of-Conduct/.

Each component directory holds {name}.stories.tsx, a Storybook-specific {name}.mock.tsx, and three companion MDX pages (.figma.mdx, .accessibility.mdx, .theme.mdx). Story IDs follow the title (ghost-core-atoms-buttoncommon--label-only), which is why a retitle is a breaking change to the e2e specs that address stories by ID.

The toolbars: colour set, club theme, direction

The Ghost theming axis is colour sets, not an onDark prop, and the preview decorator (.storybook/preview.tsx) exposes that as toolbars driven by globalTypes:

  • Colour set — derived live from globalTheme.themeContract.colourSets, so the toolbar always matches the tokens. Picking a set forces it onto every story (overriding the story's own arg) and paints the decorator background with that set's own background.main token, so each set is previewed on the surface it is designed for. The default is "Per story".
  • Club theme — Ghost 2.0 (contract) or Euro League. The default is Euro League, because that is what this repo ships; the Ghost contract is chosen when localStorage["ph-club-theme"] is ghost, and a URL global (&globals=clubTheme:…) wins over both. The club layer is emitted at a scoped selector by .storybook/euro-league-theme.css.ts (createScopedTheme(clubThemeSelector(…))), from the same @v4/tokens-euro-league the app uses. The e2e pins the Ghost contract through Playwright's storageState; the story suite renders the club theme (the setup sets ph-club-theme=ghost, but it imports ./preview statically, and that import — which reads the flag for the toolbar's initial value — runs before the setup's body; measured 29 Sep 2026: ELB's #FA5500 where Ghost's #1976DC was expected). So an e2e value can differ from what the UI shows, and an ELB palette change moves story-suite results.
  • Direction — ltr / rtl, so the logical-properties and directionalIcon behaviour can be eyeballed without editing a story.

The decorator also applies the global theme class and every component theme class, and imports the four packages' init.css (core, content, video, basketball) — each package is its own vanilla-extract compilation unit, so all four must be imported for their --ph__* custom properties to be emitted.

Fonts, viewports, a11y

  • Fonts — the Ghost face, Open Sans (400/600/700), is self-hosted via @fontsource under the exact "Open Sans" family name the Ghost tokens reference. The Euro League brand faces are served from apps/web/public/fonts (a staticDirs entry mapped to /fonts) and declared with globalFontFace from @v4/tokens-euro-league/fonts — the same table the app declares them from (apps/web/src/theme/fonts.css.ts).
  • Viewports — the five Ghost breakpoints are preconfigured presets: Ghost S (360), M (768), L (1024), XL (1440), XXL (1920).
  • Accessibility — @storybook/addon-a11y runs axe; component stories set parameters: { a11y: { test: "error" } } so violations fail the browser story tests.

The Vitest browser story tests

pnpm --filter @v4/storybook test

test is vitest run || vitest run (a retry to absorb a first-run flake). Powered by @storybook/addon-vitest and @vitest/browser-playwright, this runs each story's play interactions and render assertions in a real browser — the interaction-and-a11y gate that complements the e2e app's token-resolution gate.

.storybook/vitest.setup.ts prepares every run before the annotations load:

  • it sets ph-club-theme=ghost, but too late to take effect — the statically imported preview has already read it, so the suite renders the club theme — and marks the basketball tables' scroll nudge as already played (ph:table-nudge=1);
  • it sets data-motion-final on the document, so the motion spec's entrances stand down and the suite reads final states — a play function never meets a card held at opacity 0, and axe never measures text mid-fade. The motion itself is tested by the e2e, which never sets the mark;
  • it registers the a11y addon's annotations alongside the preview's. setProjectAnnotations() opts out of automatic provisioning, so without them every a11y: { test: "error" } would silently be a no-op.

The scripts

From apps/storybook/scripts/:

  • visual-diff (node scripts/visual-diff.mjs) — a pixelmatch score of a rendered story against a Figma reference PNG. Run it as pnpm --filter @v4/storybook visual-diff -- --story <id> --ref <png>.
  • contrast-table (scripts/contrast-table.mjs) — renders a story and emits the WCAG contrast markdown table for the accessibility MDX, compositing scrims and recovering the pairs axe skips over a background image. Always use it rather than transcribing ratios by hand.
  • stage-refs (scripts/stage-reference-images.mjs) — stages the committed Figma reference PNGs into the served static dir so the .figma.mdx embeds and visual-diff resolve them; it runs automatically before dev and build.
  • serve-static — http-server storybook-static -p ${PORT:-6006}, the static path apps/e2e builds and serves (the e2e passes its E2E_PORT as PORT). chromatic publishes the visual-regression baselines.

Where to go next

The docs app (apps/docs)

@v4/docs is this site. It is a small Next 16 app whose entire content is the Markdown and HTML under apps/docs/content/ — the page you are reading is one of those files rendered at build time.

pnpm --filter @v4/docs dev      # dev server on :4400 (Turbopack)
pnpm --filter @v4/docs build    # a Next server build (DOCS_STATIC_EXPORT=1 for static files)

dev is next dev --turbopack -p 4400 and start is next start -p 4400, so the app runs on 4400 — not 3000 (that is the web app). Every page is prerendered from the committed content/ tree either way; next.config.ts chooses what the build ships:

Build Ships The chat Deployed as
next build (the default) a Node server (next start on 4400) answers, with the service's ANTHROPIC_API_KEY Flightcontrol euro-league-docs-web-development (web, Fargate, 0.25 vCPU / 0.5 GB)
DOCS_STATIC_EXPORT=1 next build static files in apps/docs/out cannot answer: there is no server for /api/chat Flightcontrol euro-league-docs-development (static), and Vercel (apps/docs/vercel.json)

DOCS_STATIC_EXPORT is in turbo.json's globalEnv, so turbo passes it to the build (and keys its cache on it). The static service stays until the docs' domain points at the web service; then it can go.

How content maps to pages

src/lib/content.ts owns a three-level model — category → doc → section:

  • Categories are a fixed list of eight (CATEGORIES), each with one role: Start here (orientation), Guides (how to do a task), Concepts (what the site does and how it is built), UI components, Reference (look-up tables), Packages, Workflow & CI, and AI tooling. Each is a top-level directory under content/, and a category with no docs is left out of the sidebar. A category may declare groups — Guides, Concepts, UI components and Packages do — which head the sidebar and the home index.
  • A doc is a directory holding a doc.json plus its section files. The fields: title; description (one sentence, shown under the title and as the home index's tooltip); group (one of its category's groups); order (position within the group); keywords (words a reader might search for that the page does not use); tickets (the Jira keys behind it, shown as a footer — never in the prose); and optional status and date. Docs sort by group, then order.
  • A section is a file named NN-kebab.(md|html) — the NN prefix orders sections within the doc, and the file's first ## Heading is the section title. This "Applications" doc is exactly that shape: a doc.json plus 01-overview.md … 05-docs.md.

The route is src/app/(docs)/[category]/[doc]/page.tsx — two dynamic segments, so a doc addresses as /reference/applications and its sections are anchors on that one page. Markdown is rendered with marked, and code fences are syntax- highlighted at build time via marked-highlight + highlight.js (hljs token classes are baked into the HTML, so the static pages carry highlighted code with zero client JS). The sidebar is generated from the same category tree.

Links between docs are app routes — /<category>/<doc>, with #<section> for a section, where the section's anchor is its file name without the NN- prefix (/reference/applications#storybook). Every ### heading gets an anchor too — its text, slugified — which the sidebar lists for the section in view. A relative link to a .md file does not resolve. The first use of each glossary term on a page links to its entry automatically (linkGlossaryTerms), so prose need not.

Search is the ⌘K modal (src/app/(docs)/search-modal.tsx). On first open it fetches /api/search-index — a force-static route, so the index is generated once at build time and exported with the pages — and searches it in the browser. The index holds one entry per section opening and per ### heading, with the doc's keywords and tickets. src/lib/search.ts ranks it: each query word is matched on its own at word starts, with a few synonyms, and an entry must match every word (or all but one, when nothing does). search.content.test.ts runs the real questions people ask against the real content.

The docs-drift gate

Docs rot silently because nothing imports them, so scripts/hooks/docs-drift.mjs guards it. It reads docs-map.json (a map of code globs → the docs that describe them); when a commit or PR touches mapped code paths, a headless Claude reads the mapped docs against the changed code and answers one question: does any doc now misdescribe reality?

  • --staged (pre-commit) is advisory — it warns and exits 0 so a work-in-progress commit is never blocked. Set DOCS_DRIFT=block to make it fail the commit instead, as CI does.
  • --range=A..B (CI) is the enforcement point — it exits 1 on drift.
  • Skip locally with DOCS_DRIFT=off.

docs-map.json lives at the repo root, and the hook warns when it names a page that no longer exists. The check runs the claude CLI headless (claude -p); where that is unavailable it skips rather than blocks. In CI it authenticates with the ANTHROPIC_API_KEY secret.

This is separate from the standing rule that any change under .claude/ updates the AI-tooling catalogue in the same commit — that one is a human discipline, not an automated gate.

The embedded chat agent

The chat widget (src/app/(docs)/chat/, mounted in the (docs) layout, with a full page at /chat) is a documentation assistant. src/lib/retrieval.ts searches the app's own content/ — the same files the site renders, with stopword filtering so a long doc cannot outrank a short on-topic one — and src/lib/chat.ts passes the retrieved chunks to the Anthropic SDK (model claude-sonnet-5) via the /api/chat route. It answers only from what a reader could see in the docs.

It needs an API key: ANTHROPIC_API_KEY on the server, or a reader can supply their own through the ⚙ control. With no key the app degrades gracefully to plain browsing and search.

A password keeps the docs private. Set DOCS_PASSWORD on the web service and src/proxy.ts sends anyone without the sign-in cookie to /sign-in (an API call gets 401). The cookie is HttpOnly and holds an HMAC derived from the password, never the password, so changing the password signs everyone out; POST /api/sign-out signs out early. The sign-in page sits outside the (docs) route group, so it shows no sidebar and no page titles. Unset — as it is locally — the docs are open. A static export has no proxy, so the static service cannot be gated.

The chat runs wherever the docs run as a server. /api/chat is a streaming POST route, so it needs next start (or dev): on the Flightcontrol web service it answers once ANTHROPIC_API_KEY is set on that service, and on the static service it cannot answer at all.

The docs over MCP

@v4/docs-mcp (packages/docs-mcp, registered in .mcp.json as v4-docs) serves the same content/ tree — plus .claude/rules and CLAUDE.md — to agents as search and read tools. It reads the files directly and does not need the docs app running.

Honest scope

The docs app is intentionally simple: no CMS, no database, no client-side routing framework beyond Next's app router. Its correctness guarantee is the drift gate plus the fact that the chat agent, the MCP server and the rendered site read from the identical content/ tree — so what the assistant claims and what the page shows cannot diverge.