Applications
The four apps in the monorepo — web, e2e, Storybook and docs — what each is, how to run it and how it is built.
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 testat the repo root fan out through Turborepo, which runs each package's matching script in dependency order and caches the results. A secondpnpm buildwith 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/weband 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-tablescripts. - 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 domainscontent,videoandbasketball, the env markers, sections, features) drivespnpm compose, which reads each composed route package'sroutes.json—@v4/content-app,@v4/video-appand@v4/basketball-app— and generates the shims undersrc/app/[locale]/…: 48 files (34 pages, 12loading.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-corecomponents.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.tsandtokens-basketball.css.ts— imported by the/videoand 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.tssetsPH_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.tsredirects addresses without a locale segment, writes the CloudflareCache-Tagheader per path, and answers 404 for the basketball areas whenBASKETBALL_ROUTES=false.API routes —
src/app/api/:revalidate(the Contentful webhook and the engine's post-ingest call),previewanddisable-preview(draft mode),auth(Cortex sign-on),code-of-conduct,games,live,players,stats,health,geolocationanddownload.SEO —
sitemap.xml(an index of chunked children undersitemaps/, built from Contentful and Competition Engine queries),robots.tsandglobal-error.tsx.Club integrations — the app mounts the club-only packages:
@v4/analytics(GTM and OneTrust),@v4/cortex-sso,@v4/wsc,@v4/eltv,@v4/ecaland@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:
- Web App reference — route composition, link
resolution, and the data flow from
@v4/modelview models to ghost components. - App & rendering architecture — how the app mounts the route packages, rendering modes and caching.
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.tsbut 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 earlierproject.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) — pressesTabfor 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 whichTabreaches 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-tabindexassertions 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
tokensprop (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-visiblering, andaria-disabledsemantics under real clicks (Playwright'stoBeDisabled()is blind to thearia-disableddistinction, so specs assert the DOM property and a real focus check alongside it). - RTL — a
dir="rtl"rendering confirms logical properties mirror and thedirectionalIconconvention flips inline-axis glyphs while physical-by-design gradients do not. - Breakpoints — the same assertions run at
desktop(1280) andghost-s(360), and specs that switch layout usepage.setViewportSizeto 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-testskill, which encodes the harness traps. Specs are also scaffolded by thee2e-test-generatorquality-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.tsrenders every docs page.BASKETBALL_APP_URL=<origin>—tests/basketball-app/data-areas.e2e.tsruns 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 ownbackground.maintoken, so each set is previewed on the surface it is designed for. The default is "Per story". - Club theme —
Ghost 2.0 (contract)orEuro League. The default is Euro League, because that is what this repo ships; the Ghost contract is chosen whenlocalStorage["ph-club-theme"]isghost, 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-leaguethe app uses. The e2e pins the Ghost contract through Playwright'sstorageState; the story suite renders the club theme (the setup setsph-club-theme=ghost, but it imports./previewstatically, 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#FA5500where Ghost's#1976DCwas 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 anddirectionalIconbehaviour 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
@fontsourceunder the exact"Open Sans"family name the Ghost tokens reference. The Euro League brand faces are served fromapps/web/public/fonts(astaticDirsentry mapped to/fonts) and declared withglobalFontFacefrom@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-a11yruns axe; component stories setparameters: { 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-finalon 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 everya11y: { test: "error" }would silently be a no-op.
The scripts
From apps/storybook/scripts/:
visual-diff(node scripts/visual-diff.mjs) — apixelmatchscore of a rendered story against a Figma reference PNG. Run it aspnpm --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.mdxembeds andvisual-diffresolve them; it runs automatically beforedevandbuild.serve-static—http-server storybook-static -p ${PORT:-6006}, the static pathapps/e2ebuilds and serves (the e2e passes itsE2E_PORTasPORT).chromaticpublishes the visual-regression baselines.
Where to go next
- UI Components overview — the component system, its atomic tiers, and how the packages fit together.
- Stories & visual testing — the story-authoring conventions and the required story matrix.
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 undercontent/, and a category with no docs is left out of the sidebar. A category may declaregroups— Guides, Concepts, UI components and Packages do — which head the sidebar and the home index. - A doc is a directory holding a
doc.jsonplus 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 optionalstatusanddate. Docs sort by group, thenorder. - A section is a file named
NN-kebab.(md|html)— theNNprefix orders sections within the doc, and the file's first## Headingis the section title. This "Applications" doc is exactly that shape: adoc.jsonplus01-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. SetDOCS_DRIFT=blockto 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.