Component system
The architecture of a component: its anatomy and registration points, the theme registry, colour sets, rungs, motion and the Figma forks.
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–6maps tocolourSet1–6(resolveThemeColourSetinpackages/model/src/content/transforms/content-blocks.ts). Sinceclub/0036editors 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-leaguerestating 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 thecreateTransition()/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 ofpnpm 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--unbuiltfinds 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-basketballcomponent, 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-diffreads 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.