Overview

How the site's four component packages are organised, where a component's files and stories live, the theme layer and the quality gates.

2026-11-07

The component system

The Euro League site's UI is built from four component packages written in vanilla-extract with typed token contracts (CSS var prefix ph). They came from the Publishing Hub template, where they were built to the Ghost 2.0 Figma design system, and they wear ELB's brand through @v4/tokens-euro-league. Every component is generated from Figma with its Token Studio bindings preserved as an overridable contract.

The design they answer to is ELB's own Figma: ELB – Web – Core (HIjezWNeRQSBGkxx8VqmjA, the fork of Ghost Core, for the chrome and navigation family), ELB – Web – Content (9iz5GeR9syO68cmPr1ppE1, the fork of Ghost – Content 2.0), ELB – Web – Video (gR9NZ422d1RuQbdgtQ8VA9, the fork of Ghost – Video 2.0), ELB – Web – Basketball (8Jl1miPEIZslMda8jZ8w0C, new UI, not a fork) and ELB – Web – EXT (Yqy0zItrtL4UptOVW54wVg, concepts and extensions). The forks preserve Ghost's node ids, so a component bridge that still names a Ghost file resolves perfectly while measuring the wrong design; the offline gate node packages/token-parser/scripts/check-bridges.mjs checks every bridge names a file this repo is actually built from. The method is in .claude/rules/frontend/fork-refactor.md.

This section is a CURATED overview. It does not repeat the per-component detail that already lives in Storybook — each component ships its own autodocs plus companion Figma, accessibility and theme MDX pages. Treat the catalogues here as a map that deep-links into that live reference.

Atomic layers are a Storybook categorisation, not a path

A component's "atomic level" — atom, molecule, organism, template — is how it is categorised in Storybook, never a segment in its source path. Component directories stay flat inside their package; the atomic tier only shapes the Storybook sidebar and the story title prefix.

Tier What lives there
Atoms Buttons, badges, chips, text links, toggles, icons
Molecules Cards, dropdowns, trays, segmented controls, form fields
Organisms Footers, headers, navigation, overlays, carousels
Templates Full pages that compose their own outline (base-page-*)

The four component packages

The site's components are split across four sibling packages, each built the same way (typed theme contract, .css.ts structural styles, generated icons, Storybook group):

Package Components Owns
@v4/ghost-core 148 Atoms → templates: buttons, forms, nav, cards, overlays, the app shell
@v4/ghost-content 72 Article and RTE blocks, embeds, promos, listing pages (+6 internal layouts)
@v4/ghost-video 27 Video surfaces and carousels (+4 internal layouts)
@v4/ghost-basketball 139 Schedules, standings, brackets, Game Centre, player and team surfaces (+4 internal parts)

That is 386 registered component themes in total (counted as the component directories in each package's src/components/, _internal excluded). The core package is the shared foundation; the three siblings bolt domain surfaces on top of it and compose core components rather than re-implementing them. @v4/ghost-core, @v4/ghost-content and @v4/ghost-video are template-owned (their changes arrive through /sync-upstream); @v4/ghost-basketball is this project's own package (the basketball domain), built from ELB – Web – Basketball.

The catalogues: @v4/ghost-core, content and video and @v4/ghost-basketball.

The packages in numbers

Besides the 386 registered component themes: 386 component story files (plus the Code of Conduct and Foundations stories), 1,151 companion MDX docs (the Figma, accessibility and theme pages per component), 446 unit test files across the four packages (169 / 85 / 35 / 157), 395 Playwright e2e specs, and 129 generated icons in @v4/tokens/icons.

Principles that hold everywhere

  • Composition fidelity is the API. If a Figma component composes B, C and D, the React component imports the real B, C and D. A missing child is a build gap to fill — never placeholder markup, never a re-implementation. See Composition patterns.
  • Colour sets, not onDark. The theming axis is the colour-set model: ten keys — default, inverse, base1, base2, colourSet1–colourSet6 (packages/ghost-core/src/types.ts). Each component supports the subset its design authors; unknown variant values degrade to the component default rather than breaking. See Theming & colour sets.
  • Tokens are generated, contracts are typed. @v4/token-parser turns the Token Studio export into a 10,000-line typed module; hand-authored themes reference the global contract, never literals. See The token pipeline.
  • ELB's fork is the design. Refactor against the ELB file the bridge names, verified by check-bridges, and check the design is finished (design-status) before refactoring (.claude/rules/frontend/fork-refactor.md).
  • Logical properties from day one — RTL is an engineering guarantee (the directionalIcon opt-in mirrors inline-axis glyphs; layout mirrors for free).
  • British spelling in prose and token names; prefix ph, never gam.

Where things live

A component owns one flat directory inside its package. For @v4/ghost-core that is:

packages/ghost-core/src/components/{kebab-name}/

The three sibling packages follow the identical shape under their own src/components/. Nothing about the atomic tier appears in the path — badge and site-header sit side by side, an atom next to an organism.

Stories live in the Storybook app, not the package

Story files and their companion MDX pages do NOT live beside the component. They live in the single Storybook app (apps/storybook, package @v4/storybook), grouped by the package that owns them and then by atomic tier:

apps/storybook/stories/
├── ghost-core/{atoms,molecules,organisms,templates}/{kebab-name}/
├── ghost-content/{atoms,molecules,organisms,templates}/{kebab-name}/
├── ghost-video/{molecules,organisms,templates}/{kebab-name}/
├── ghost-basketball/{atoms,molecules,organisms,templates}/{kebab-name}/
├── code-of-conduct/{components,report-flow}/   ← the @v4/code-of-conduct screens
└── foundations/   ← the token layer (@v4/tokens)

A story's title follows that tree — Ghost-Core/Atoms/Badge, Ghost-Basketball/Organisms/StandingsTable — and its story id is derived from the title: Storybook lower-cases it and turns each / (and any other separator) into a hyphen. It does not split PascalCase, so Ghost-Core/Atoms/ButtonCommon becomes ghost-core-atoms-buttoncommon, never ghost-core-atoms-button-common. Read the title in the story file rather than guessing from the directory name. Story ids are addressed by the e2e specs and the visual-diff tool, so a retitle is a breaking change to them.

The Code of Conduct group renders the @v4/code-of-conduct screens (sign-in card, game picker, report, sent) as they appear on the site at /<locale>/code-of-conduct.

Storybook shows the club; only the e2e pins Ghost

Storybook opens on the Euro League theme (a toolbar switches it). The Playwright e2e pins Ghost: its storageState sets localStorage["ph-club-theme"] = "ghost" before the page loads, so it asserts the Ghost 2.0 contract. The vitest story suite sets the same flag in .storybook/vitest.setup.ts but still renders the Euro League theme — the preview reads the flag when it loads, before the setup runs (measured 29 Sep 2026: a story resolving g.primitive.color.primary["1"] rendered ELB's #FA5500, not Ghost's #1976DC). So an ELB palette change moves story-suite colour and contrast results, and the e2e's do not. A URL global (&globals=clubTheme:…) wins over the stored flag. When a value or contrast result looks wrong, check first which theme was being measured.

Storybook is the live reference

Storybook is where the per-component truth lives. Every component ships:

  • tags: ["autodocs"], so its props table is generated from the typed props (each carries JSDoc);
  • an AllColourSets story exercising every supported colour set;
  • a Playground story with interactive controls;
  • three companion MDX pages — {name}.figma.mdx (design link, node map, token bindings), {name}.accessibility.mdx (semantics, ARIA, contrast notes) and {name}.theme.mdx (the token contract table and the five override levels with a live example).

Run it locally from the repo root:

pnpm --filter @v4/storybook dev   # serves on http://localhost:6006

A component's autodocs page is then at /?path=/docs/{story-id}--docs — for example /?path=/docs/ghost-core-atoms-badge--docs. The catalogue tables in this section link there directly.

Anatomy of a component

Each component directory is a small, fixed set of files with one responsibility apiece. The full reference for each is in the frontend rules under .claude/rules/frontend/; this is the short version.

packages/ghost-core/src/components/{kebab-name}/
├── index.ts                      # barrel export
├── {kebab-name}.component.tsx     # the React 19 component
├── {kebab-name}.css.ts            # vanilla-extract structural styles
├── {kebab-name}.theme.ts          # theme contract wiring
├── {kebab-name}.types.ts          # props interface, state unions, constants
├── {kebab-name}.test.tsx          # vitest + Testing Library unit tests
├── {kebab-name}.mock.ts           # mock props (public package export)
├── {kebab-name}.figma.json        # Ghost 2.0 node map (design-to-code bridge)
└── tokens/                        # extracted Token Studio bindings + the computed coverage manifest

The sibling packages (ghost-content, ghost-video, ghost-basketball) use exactly the same shape.

File Holds
*.component.tsx The function component, accessibility attributes, data-component/data-testid, handlers
*.css.ts Structural CSS only — display, flex, position, pseudo-states, responsive @media
*.theme.ts Overridable tokens — colours, spacing, radius, opacity — wired through the theme contract
*.types.ts The exported props interface, the tokens? override prop, state unions and PH_* constants
*.test.tsx Vitest unit tests (.test.tsx, never .spec)
*.mock.ts A complete, valid props object, exported so stories and app tests can consume it

The split between *.css.ts and *.theme.ts is the key one: structure (how the element is laid out) is separate from tokens (the values the Euro League theme or a consumer can override). The .css.ts file only ever reads token values through the component's theme contract — never literal 8px or #fff. That separation is what makes the whole estate re-skinnable from a single tokens package.

A component is considered "registered" when it exists in six synchronised places — its package barrel, its theme registry entry, a side-effect import in init.css.ts, its figma.json bridge, its Storybook stories, and its generated token contract. The /validate-registrations skill audits all six. Registration is written last: one unresolvable specifier in the barrel or init.css.ts blanks every story in Storybook, not just the new component's.

The theme layer

How a value travels from Figma to the page is explained in full on The token pipeline; this is the map of it as the component estate meets it, with links to the page that owns each part.

The pipeline

Token Studio tokens.json  (Ghost: vendored via sync-tokens; Euro League: pushed straight
                           to the repo by Token Studio — never sync-tokens it)
  → @v4/token-parser      (v4-tokens build/check/coverage; resolves references AND
                           studio.tokens modifiers — darken/lighten/alpha with hsl quantisation)
  → @v4/tokens            (generated tokens.ts — the source of truth — + per-component
                           contract modules + 129 generated icons)
  → @v4/tokens-euro-league (the club layer, adapter-projected onto the Ghost contract,
                           + the EuroCup / NextGen competition themes and the motion re-tune)
  → @v4/ghost-core/theme  (buildGlobalTheme · createTheme · createClubThemePart ·
                           createPackageTheme · createScopedTheme · createScopedOverrides ·
                           registerComponentTheme; React-free subpath so .css.ts files can import it)

Each component's .theme.ts is one wiring statement: registerComponentTheme(buildGlobalTheme("badge", tokens)) emits --ph__badge-* CSS variables and registers the contract in a module-singleton registry that the emitters walk — there is no hand-maintained barrel.

The global surface carries two extensions beyond Ghost's: the page measure (g.semantic.size.pageMeasure, pageGutter.{s,md,lg,xl} — see Responsive rungs) and the basketball surface (packages/ghost-core/src/theme/basketball-surface.ts: g.components.basketball.* and the team colours g.teams.teams.<club>.*, which Ghost has no namespace for).

Where each part is explained

Topic Home
The five override levels, with a worked example of each Override levels
Colour sets, the club layer and the theme parts the app emits Theming & colour sets
Owner-keyed emission and its three guards The component system
The page measure and the undrawn bands Responsive rungs
The motion.* tokens and the hooks that read them Motion
Regenerating tokens and how ELB's brand is applied Token packages, Theme the site

Binding coverage as a machine check

v4-tokens coverage recounts every manifest's claim from the extracted Figma bindings — per node × property pair (node-property-pairs@v2): instance subtrees and hidden nodes excluded, the exclusion reported (hiddenNodePairsExcluded, hiddenSubtrees), with wholesale-namespace detection, delegated-theme declarations for shared internal layouts, and a cssConsumption.unread list (an emitted var no rule reads is a dead Euro League override). The standing lesson, proven repeatedly: no coverage number is self-certifying — the tool has been fixed three times, in both directions. And on a fork, a figure scored against an extraction cached from Ghost means "matches Ghost": re-extract from ELB's file before trusting it.

Hard-won rules (all banked in .claude/rules/frontend/)

  • Component namespaces bind through the global contract (g.components.*) — a raw import { tokens } freezes literals beyond every Euro League override (once froze 80 brand colours across 18 components).
  • Sibling paths that kebab-case identically collide on one CSS var — rename the group.
  • Gradient tokens land on backgroundImage; a gradient on backgroundColor silently no-ops.
  • Parent-state repaints of composed children use assignVars over the child's contract subtree, targeted with a selector that out-specifies the child's own theme class.
  • A stylesheet, a .theme.ts, or anything they import must never reach a package barrel or a family's .tsx — the precompile fails the build when one does.

Conventions across the packages

The rules every component in the four packages follows. Each has a fuller home, linked below; the binding versions are in .claude/rules/frontend/.

Variants and responsiveness

  • States are unions + constants (PH_*_STATES); colour × state matrices live in recipe compoundVariants; interactive pseudo-selectors attach only to the enabled state; disabled uses aria-disabled (keyboard-discoverable), never the HTML attribute.
  • Breakpoints are Ghost's 360/768/1024/1440/1920 via mediaUp(), authored mobile-first. A responsive change Figma expresses through a child's non-breakpoint axis becomes a declared mode — lgSize (the card and list families, the Game Centre's leader and head-to-head rows), lgOrientation (navigation-list, hygiene-nav-list, list-carousel), lgRootWidth (card-video, navigation-flyout-menu), and paddlePlacement on list-carousel and tab-segment. A prop cannot follow a media query, and container queries are banned for layout breakpoints.
  • The bands the design does not draw — tablet (768–1023) and laptop (1024–1439) — are handled by the page measure, a tablet step per family and carousels that page by a fraction of the column. The working guide is Responsive rungs.
  • Five-rung components are built from per-rung extractions diffed pairwise — media rules are emitted only where a value actually changes.
  • Continuously-changing values (scroll progress) are driven by transform: scaleX(), never width.
  • Motion follows ELB's motion spec: every animation names its pattern, values are motion.* tokens, and reduced motion is a complete instant page. The working guide is Motion.

Composition

The colour way resolved once at the boundary, slots only for consumer-owned artwork, and the other composition rules are on Composition patterns. Two more that apply everywhere:

  • Page templates pin the heading outline and every child's way — Omitted from child props so consumers cannot break the page.
  • Figma canvas-vs-binding drift is resolved by one test: does anything render differently? Real differences are corrected in code and logged as design questions (the campaigns produced a 260+-question dossier for design).

Refactoring against ELB's fork

The ELB files are forks of Ghost that preserve the upstream node ids, so a bridge naming the Ghost file resolves every id while measuring the wrong design. Why that happens is on Building against ELB's Figma forks; the steps and the tools (check-bridges, design-status, fork-diff, design-drift, /fork-status) are in Refactor a component.

The basketball package

@v4/ghost-basketball is built new from ELB – Web – Basketball, not forked, so every bridge names that file from birth; the ECAL schedule picker is the one exception, built from ELB – Web – EXT. What it holds and how it is built are on Catalogue: ghost-basketball.

Quality machinery

Independent gates, because each one has been shown blind to a class of defect the others catch. pnpm verify (scripts/verify.mjs) runs the local set; /preflight adds the same-commit obligations before a push. How to write and run each kind of test is in Testing; this is the map of every gate and what it catches.

Storybook (apps/storybook, :6006)

One Storybook for all packages, grouped by ownership: Ghost-Core/…, Ghost-Content/…, Ghost-Video/…, Ghost-Basketball/…, plus Code of Conduct/… and Foundations/…. A Club theme toolbar switches between the Ghost 2.0 contract and the Euro League club layer; the interactive UI defaults to Euro League. Every component ships the required story matrix: Playground, AllColourSets (with an RTL row), one story per variant axis and per slot permutation, WithTokenOverrides, plus Responsive where Figma authored a breakpoint axis. Slot demo content is real generated icons — never inline SVG. Companion docs per component: *.figma.mdx (embedded design + node map, with the addon-designs panel wired from the bridges on all stories), *.accessibility.mdx, *.theme.mdx (contract table + the five override levels) — 1,151 MDX pages in all.

Unit + story tests

446 vitest files across the four component packages (jsdom + jest-dom) covering rendering, conditional slots, token overrides, disabled semantics and colour-set safety. Browser-mode story tests run axe as an error-level gate, with play interactions mandatory on interactive components. The story suite renders the Euro League theme: apps/storybook/.storybook/vitest.setup.ts sets localStorage ph-club-theme=ghost, but its static import of the preview reads the flag first. It also marks the page data-motion-final, so entrance hooks stand down and axe never measures text mid-fade.

Component e2e (apps/e2e, 395 specs)

Playwright against the STATIC Storybook build (served on E2E_PORT, default 6006; a server already listening is reused), with specs foldered by owning package: ghost-core 154, ghost-content 80, ghost-video 27, ghost-basketball 130, basketball-app 1, shared 3. Six projects: Chromium desktop (1280) and ghost-s (360) run everything; WebKit and Firefox (1280) run the cross-browser risk subset (full matrix nightly via E2E_FULL_MATRIX=1); Chromium tablet (834) and laptop (1180) run only the specs that derive their expectations from the viewport (INTERMEDIATE_RUNG_SPECS: page-measure.e2e.ts, and the three *-bleed.e2e.ts carousel specs). The e2e pins the Ghost theme (ph-club-theme=ghost, through storageState); the story suite, despite setting the same flag, does not.

Specs assert resolved token values on the element, geometry at each rung, RTL mirroring, focus behaviour (tabTo for WebKit's keyboard model), override plumbing and — for motion — the animation present one frame after the trigger and absent under reduced motion. Standing shared gates: shared/page-measure.e2e.ts (the measure and its gutter at every rung, and the double-gutter signature) and shared/theme-emission.e2e.ts (every package's components compute real values; exactly-once rule counts per layer). The harness rules — timing-split diagnosis, never branching on project name, play-less stories for interaction specs — are banked in .claude/rules/testing/test-conventions.md.

Design and metadata gates

  • node packages/token-parser/scripts/check-bridges.mjs — offline, in scripts/verify.mjs: every component bridge names a Figma file this repo is built from, and every cached extraction agrees with its bridge. The ELB forks preserve Ghost's node ids, so this is the only check that sees a bridge measuring the wrong file.
  • v4-tokens coverage — binding coverage per node × property, recomputed rather than trusted.
  • /validate-registrations — the six registration points per component.

Visual + contrast measurement

  • visual-diff — pixelmatch score of a story against the committed Figma export PNG; the campaigns' promotion bar is ≥90–95% with reasoned exceptions.
  • contrast-table.mjs — renders a story and measures every text pair composited, recovering the pairs axe silently skips wherever a background-image is painted (measured: 220 of ~690 contrast checks were returning incomplete, i.e. unchecked, across 11 components). For text over consumer imagery it reports the design's guaranteed floor (the scrim), not the fixture.

Sweeps of the running app

Some defects exist only in the browser, on real pages. Each script runs against a live app at the rung edges and the widths real devices report:

  • node scripts/page-audit.mjs [path …] — text the colour of the surface behind it (read from the real paint stack), zero-size components, broken images, page and child overflow, console errors; CONTRAST=1 adds every pair below WCAG AA.
  • node scripts/clip-sweep.mjs [path …] — content that is clipped and unreachable (overflow: hidden with scrollWidth > clientWidth).
  • node scripts/rung-census.mjs [--list] — how many components step at each rung, per package.

What no gate can see (known residue)

Each has a banked rule and, where possible, a standing e2e: geometry outside a cropped visual-diff (the painted-wrapper cap), an emitted var no rule reads (dead club overrides), stale schema/bridge metadata (machine re-resolution on review), a design that moved after its extraction was cached (design-drift.mjs), and a page that loads a package without its theme part (classes present, zero vars declared — check in the browser). The honest position, learned repeatedly: derived summaries are claims, not data — every gate's number gets machine-recounted, never trusted from prose.