Component system

The architecture of a component: its anatomy and registration points, the theme registry, colour sets, rungs, motion and the Figma forks.

2026-11-07

Component anatomy

This section is the architectural view of a component — the shape of a component directory and the points it must register at. The hands-on build workflow, with the conventions for each file, lives in the UI Components section; this page cross-references it rather than repeating it.

One directory per component

Every component is a flat directory under packages/ghost-core/src/components/{kebab-name}/ (the sibling packages — ghost-content, ghost-video, ghost-basketball — follow the same layout, and each keeps private shared layouts under src/components/_internal/<name>/). Atomic level — atom, molecule, organism — is a Storybook categorisation, never a path segment.

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 (buildGlobalTheme)
├── {kebab-name}.types.ts         # props, state unions, token type
├── {kebab-name}.test.tsx         # vitest + Testing Library (never .spec)
├── {kebab-name}.mock.ts          # mock props (.tsx only if it needs JSX)
├── {kebab-name}.figma.json       # Figma node map (design-to-code bridge)
├── figma-screenshots/            # reference PNGs (optional)
└── tokens/                       # extracted Token Studio data (optional)

Each file owns one responsibility: .theme.ts holds everything overridable (colours, spacing, radii, opacity, motion); .css.ts holds structural CSS and pseudo-states, referencing theme values only through the component's themeContract; .component.tsx holds markup, accessibility attributes and the assignInlineTokens call for the per-instance tokens prop; .types.ts holds the fully-typed public surface.

The six registration points

A component exists in six places that must stay in sync. This is the crux of the architecture: nothing is wired implicitly, so every point is machine-checkable and a missing one is a build failure, not a silent gap.

# Point Why
1 packages/ghost-core/src/index.ts — package export The public API; alphabetical, no separate components/ barrel.
2 registerComponentTheme(...) in its .theme.ts Adds the theme to the registry the theme emitters walk.
3 Side-effect theme import and OWN_THEMES entry in packages/ghost-core/src/init.css.ts Registers the theme in init.css's compile unit, where it is emitted.
4 packages/ghost-core/figma.json — the Figma bridge entry The design-to-code node map; must name the file the component is built from.
5 apps/storybook — stories plus companion MDX docs Documentation and the visual-regression baseline.
6 Its token source: a generated namespace contract (@v4/tokens/components/*) or the global contract g.components.* The typed defaults, or an explicit code-only marker.

A supporting convention (not one of the six) is a TEST_ID entry in packages/ghost-core/src/constants.ts. Run /validate-registrations to audit all six across the package. The sibling packages register the same way in their own src/index.ts, src/init.css.ts (OWN_THEMES) and figma.json.

Point 4 has a check of its own. ELB's Figma files are forks of Ghost that preserve its node ids, so a bridge naming the Ghost file still resolves every id while measuring the wrong design. node packages/token-parser/scripts/check-bridges.mjs runs offline in pnpm verify and fails when a bridge names a file this site is not built from — see Building against ELB's Figma forks and .claude/rules/frontend/fork-refactor.md.

Register last

Point 1 (the barrel export) and point 3 (the init.css.ts import) must be added only after the files they point at exist. One unresolvable specifier fails the whole vanilla-extract compile and the Vite import graph, which blanks every story in Storybook — not just the new component's — and the dev server caches the failure until it is restarted. The order of work per component is therefore: write the component/theme/css files, build and type-check, then add the two registration lines, then restart Storybook.

Composition fidelity is the API

If a component composes children in Figma, the code imports and composes the real children — never a re-implementation or an inline HTML/CSS imitation. A child that does not exist in code yet is a build gap: it is built as its own standalone component first, then imported. A ReactNode slot is legitimate only for genuinely consumer-owned artwork (crests, sponsor logos, background imagery), not as a stand-in for a component that should exist. This is what keeps overrides flowing all the way down a composition — see the composition-fidelity principle in the system overview.

The theme registry

Component themes are collected in a registry rather than a hand-maintained barrel. Each .theme.ts calls registerComponentTheme(...), which records the theme so that an emitter (createPackageTheme, createClubThemePart or createTheme) can walk the registered components and emit their CSS variables. There is no themes.ts to keep in sync — the registry replaces it by construction.

Emission has to happen inside the compile unit

vanilla-extract compiles each file as its own unit, and the registry is a module singleton shared across those units. A theme only emits CSS variables if it is registered inside the compile unit that emits — which is why registration point 3 exists: a side-effect import of every component's .theme.ts in init.css.ts. Without it, the registry cannot see the theme across compile-unit boundaries and the component's --ph__* variables are never written.

Emission is owner-keyed

The registry is shared across sibling packages, so a naïve createTheme() in a sibling's init.css.ts used to re-emit the whole core layer — over a megabyte of duplicate CSS per sibling, with the global theme written twice. The fix keys emission to ownership stated at the emission site: each package's init.css.ts declares an explicit OWN_THEMES list and calls createPackageTheme(OWN_THEMES). Only @v4/ghost-core passes { withGlobal: true }, because the global layer has exactly one owner.

// packages/ghost-content/src/init.css.ts (shape)
import { createPackageTheme } from "@v4/ghost-core/theme";

const OWN_THEMES = [/* every camelName this package owns */];

createPackageTheme(OWN_THEMES);
// packages/ghost-core/src/init.css.ts — the global layer's single owner
createPackageTheme(OWN_THEMES, { withGlobal: true });

Ownership is a fact; evaluation order is not. An earlier attempt keyed emission on an "already emitted" set, which depended on compile-unit evaluation order and once left an entire package silently unstyled with every gate green. The owner-keyed model is deliberate about this: three guards make the lists safe to trust — a name in the list that never registered throws at CSS-compile time; a build check fails when the list and the package's theme modules disagree in either direction; and an e2e asserts that a component from every package computes real values, catching the under-emission direction. The global theme's name (global) must never appear in an OWN_THEMES list — it is built with the same helper but lives outside the component registry.

In the app, the club theme is the one emitter

Storybook and the e2e run on the package bootstraps above, emitting the Ghost defaults. The app does not: apps/web/next.config.ts sets PH_THEME_EMISSION=club, and createPackageTheme then registers only (it still validates the OWN_THEMES list, so a wiring error still fails the compile). The app's own stylesheets emit every theme once, with ELB's values over the defaults, and they do it in parts:

// apps/web/src/theme/tokens.css.ts — loaded by every page
import { OWN_THEMES as CORE_THEMES } from "@v4/ghost-core/init.css";
import { OWN_THEMES as CONTENT_THEMES } from "@v4/ghost-content/init.css";
import { createClubThemePart, globalTheme } from "@v4/ghost-core/theme";
import { tokens } from "@v4/tokens-euro-league";

globalTheme.createScoped(":root", tokens.global); // the global layer, once
createClubThemePart(
  { global: tokens.global, components: tokens.components },
  { components: [...CORE_THEMES, ...CONTENT_THEMES] },
);

tokens-video.css.ts and tokens-basketball.css.ts do the same for @v4/ghost-video and @v4/ghost-basketball's OWN_THEMES, and are imported by the layouts of the routes that render those packages. The global layer is declared on :root and inherited, rather than at the theme class every component root carries. The competition themes are a further layer of overrides on :root.<class> (createScopedOverrides). Why it is split this way, and the rule it brings with it — a route that renders a package must load that package's part — are on Theme emission and performance.

createTheme — the single-file entry point

createTheme(clubTokens) deliberately emits everything in one file: the global layer, the colour sets and every registered component's variables. It is what a consumer that renders everything uses, and the simplest way to theme a small app; the app here outgrew it when one 3 MB stylesheet loaded on every page.

Either way, because every component's defaults were emitted from the same contract, ELB's package supplies only what it changes: its layer re-points the corresponding --ph__* variables and every component picks up the new values without a single component file changing.

Colour sets and overrides

The design system follows the Ghost 2.0 colour model, and its first-class theming axis is the colour set. It is what makes the five override levels below compose cleanly.

Colour sets, not onDark

There is no onDark boolean prop: a themeable component takes colourSet?: ColourSet and maps it to a class. The vocabulary has ten ways:

Code key Figma name Purpose
default, inverse ColourWay=Default / Inverse A component's own light and dark ways
base1 Colour Set=Base 1 Default light surface
base2 Colour Set=Base 2 Alternate / inverted surface
colourSet1–colourSet6 Colour Set=CS 1–CS 6 Editorial / brand section themes

Most components support a subset: many take only default / inverse, the content blocks take the base and editorial sets.

Each set specifies a rich per-set surface — typography, links, backgrounds, borders, fills, buttons, form fields, tabs, page navigation — expressed in the theme contract as typed paths (colourSets.base1.buttons.primary.background). Dark surfaces are colour sets (base2, inverse or a dark editorial set); no new onDark props are introduced.

A component that supports only a subset of the ramp resolves an unsupported value to its default set before rendering — an unknown recipe variant would otherwise render the component unstyled. And a component that composes several children resolves the way once, at the boundary, because children rarely support the same subset; forwarding a parent's colourSet unchanged degrades each child independently.

The five override levels

Tokens compose at five well-defined levels; the highest wins. This is a conceptual overview — the mechanics of each live in the theming rules and the UI Components section.

Level Source What it is
1 Theme defaults Generated from Ghost 2.0 tokens.json (or hand-authored) — the contract itself.
2 ELB's global layer The brand's global overrides, from @v4/tokens-euro-league. Includes global.components.*, so component-owned colours and radii re-skin here.
3 ELB's components layer Per-component overrides, keyed by component-theme name against that component's own contract — a hand-authored escape hatch.
4 Raw CSS custom properties (--ph__button-*) The escape hatch for non-TS consumers.
5 Per-instance tokens prop DeepPartial<ComponentTokens>, applied once via assignInlineTokens — inline vars win.

ELB's brand reaches the components almost entirely at level 2: one generated token package whose global.components.* tree re-points the component namespaces exactly as it re-points the colour sets. @v4/tokens-euro-league's adapter projects ELB's Token Studio names onto the Ghost contract, and its components tree (level 3) is empty by design (packages/tokens-euro-league/README.md): level 3 is a hand-authored escape hatch a Token Studio export cannot generate.

Two further layers sit between levels 2 and 4 on this site:

  • Competition themes. A page in the EuroCup or NextGen section wears that competition's Token Studio theme — only the global leaves that differ from EuroLeague's, declared on :root.<class> over the club layer. See Competition sections.
  • Editor themes. A content block's Theme entry is its colour set: Theme ID colourTheme1–6 maps to colourSet1–6 (resolveThemeColourSet in packages/model/src/content/transforms/content-blocks.ts). Since club/0036 editors are offered Colour theme 1–3 only; 4–6 are archived, not deleted.

Level 5 is the consumer's per-instance dial:

<Button
  tokens={{
    borderRadius: "16px",
    colourSets: { base1: { enabled: { backgroundColor: "#custom" } } },
  }}
/>

What overrides cannot do

The tokens prop is fixed at render, so it cannot follow a :hover or a media query — the two cases where reaching for it silently ships something wrong. A parent that repaints a composed child on its own CSS state uses typed CSS (assignVars over the child's contract subtree) instead; a responsive step that Figma expresses through a child's size axis becomes a declared responsive mode, not a prop. And a tokens override reaches only its own component's contract — it cannot recolour a composed child, which must be driven through the child's own tokens. These boundaries are why the colour-set axis and the override levels are kept distinct: each solves the case the other cannot.

Layout rungs and the page measure

ELB's Figma files, like Ghost's, draw most components at two breakpoints only — Breakpoint=Small (360) and Breakpoint=X-Large (1440) — while the code is mobile-first with mediaUp("lg") carrying the X-Large frame from 1024. That leaves two bands no design describes: tablet (768–1023) and laptop (1024–1439).

The component system answers them from the grid rather than by taste. Grid 2.0 in the token export defines columns, gutter and container per band, and every band's outermost container binds the page measure — min(100% − 2 × gutter, S.Size.ContainerWidth.XL), with the gutter stepping per band — so at every authored viewport it resolves to exactly the authored container and between them fills the viewport less the band's gutter. Components that lay out columns or rails (card rails, reading wells, footers, carousels) declare a tablet step per family; everything else keeps stepping at lg.

How to apply the page measure, the tablet step for each family, and the e2e and sweep gates that keep them measured are in Responsive rungs; the binding rules are .claude/rules/frontend/intermediate-rungs.md.

Motion

ELB owns the site's motion. Its EuroLeague Motion Spec names every pattern and value, and where code that predates the spec disagrees with it, the spec wins. In the component system that shows up in three places:

  • Every animation names its pattern from the spec, in a comment on the rule or hook.
  • Values are tokens — durations, easings, staggers and distances come from the global motion.* contract (CSS) or the matching TS constants (hooks), with Ghost's defaults in ghost-core's default theme and @v4/tokens-euro-league restating only the leaves where the spec differs. Tuning the feel changes a token, never a component.
  • The mechanics are shared — the entrance and change hooks in @v4/ghost-core/motion, and the createTransition() / createAnimation() helpers in @v4/ghost-core/theme, which carry the reduced-motion guard.

The spec's rules, how to build a motion and how to test both sides of it are in Motion; the binding rules are .claude/rules/frontend/motion.md.

Building against ELB's Figma forks

Design for this site happens in ELB's own Figma files. Three are forks of Ghost 2.0 — design copied the Ghost file and changed components in place — and two are new. .claude/manifest.json → figma.clientFiles is the map every tool reads:

File Key Forks
ELB – Web – Core HIjezWNeRQSBGkxx8VqmjA Ghost – Core 2.0
ELB – Web – Content 9iz5GeR9syO68cmPr1ppE1 Ghost – Content 2.0
ELB – Web – Video gR9NZ422d1RuQbdgtQ8VA9 Ghost – Video 2.0
ELB – Web – Basketball 8Jl1miPEIZslMda8jZ8w0C — (new UI)
EXT – ELB – Web – Structure Yqy0zItrtL4UptOVW54wVg — (structure and concept frames, e.g. the WSC strip, the ECAL picker)

The rules for refactoring a component against a fork are in .claude/rules/frontend/fork-refactor.md; read it before touching one. The architecture that follows from them:

The preserved-id trap

A fork keeps the upstream node ids. A component bridge (*.figma.json) that names the Ghost fileKey therefore resolves every id, extracts cleanly, scores 100% coverage and renders — while measuring the upstream design, not ELB's. Every signal says healthy, and the only thing that would notice is a comparison nobody runs. So the comparison is automated:

  • node packages/token-parser/scripts/check-bridges.mjs — offline, part of pnpm verify: every bridge names a file this site is built from, and every cached extraction agrees with its bridge.
  • design-status.mjs --file <clientFileKey> [--ready] [--unbuilt] — the design and development status ELB records per component in the file. Run it before refactoring: a component whose design is still moving gets refactored twice, and --unbuilt finds finished designs with no component at all.
  • fork-diff.mjs --upstream <ghostKey> --client <clientKey> [--only <c>] — diffs a component's Token Studio bindings between Ghost and the fork, ranked by measured difference. Rank work by this, not by which file a bridge names: a Ghost bridge on a component the fork never changed is correct.
  • design-drift.mjs — has ELB's design moved since the cached extraction? Every other check compares code with the cache; this one sees the cache itself going stale.

/fork-status runs the status and diff tools together and crosses them.

What follows for a component

  • Bridges name the file the component is built from — the ELB fork for a forked component, ELB – Web – Basketball for every @v4/ghost-basketball component, EXT for the few built from concept frames (whose theme leaves are inferred, because those frames carry no Token Studio data).
  • ELB's two rungs are diffed against each other, not the Ghost→ELB delta, to decide which value belongs at Small and which at X-Large.
  • A hollow master in ELB's file is not evidence; extract from an instance and record that on the bridge.
  • A design question is never settled from a cached extraction — a cache taken from Ghost answers with Ghost's value. fork-diff reads both files live.
  • A second page for the same component (Block – X – Concept 02) is a decision for design to record, never inferred from page names or dates.

Tokens come straight from ELB

ELB's Token Studio file pushes tokens.json directly into packages/tokens-euro-league; generate projects it onto the Ghost contract (see The token pipeline). Never run sync-tokens there.