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.
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-parserturns 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
directionalIconopt-in mirrors inline-axis glyphs; layout mirrors for free). - British spelling in prose and token names; prefix
ph, nevergam.
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
AllColourSetsstory exercising every supported colour set; - a
Playgroundstory 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 rawimport { 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 onbackgroundColorsilently no-ops. - Parent-state repaints of composed children use
assignVarsover 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 recipecompoundVariants; interactive pseudo-selectors attach only to theenabledstate; disabled usesaria-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), andpaddlePlacementonlist-carouselandtab-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(), neverwidth. - 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, inscripts/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 returningincomplete, 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=1adds every pair below WCAG AA.node scripts/clip-sweep.mjs [path …]— content that is clipped and unreachable (overflow: hiddenwithscrollWidth > 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.