Token pipeline
How Token Studio tokens become typed contracts and --ph__ CSS variables, through the parser, the generated defaults and ELB's layer.
Overview
Every design token here is a typed TypeScript path, not a stringly-typed CSS custom property. The pipeline that makes that true has one authored source and a chain of generated outputs:
tokens.json ──▶ @v4/token-parser ──▶ @v4/tokens ──▶ component .theme.ts ──▶ --ph__* CSS vars
(Token Studio) (the CLI) (generated) (buildGlobalTheme) (emitted at build)
tokens.json— the Token Studio export, edited by design. It carries the Core Palette (Primary / Secondary / Neutral ramps with generated tints and shades, system colours, gradients), the eight colour sets, and the per-component token surfaces.@v4/token-parser— the CLI (v4-tokens). It parsestokens.json, resolves references, applies value modifiers, and emits typed TypeScript plus a parallel CSS-custom-property file.@v4/tokens— the generated package. It holds the defaults (src/__generated__/tokens.ts, the token source of truth) and typed contracts per Token Studio namespace (@v4/tokens/components/*).- Component themes wire those contracts through
buildGlobalThemeandregisterComponentTheme, emitting CSS custom properties prefixed--ph__*.
The defaults define the contract
The important inversion: the generated defaults are not just values, they are
the contract. The override type @v4/tokens-euro-league must satisfy is derived from the shape of the defaults:
// packages/tokens (illustrative)
export type ClubTokens = MapTokensContract<typeof defaultTokens>;
export type ClubTokenOverrides = DeepPartial<ClubTokens>;
So if ELB's token layer lags a structure change, references a renamed token, or misspells a path, it fails to compile — attributed to the exact token path — at the moment the tokens PR is opened, rather than shipping as a silently wrong colour.
Generation is a build step, not a release
@v4/tokens and @v4/tokens-euro-league run the CLI in their build script.
Turbo caches the result keyed on tokens.json and the CLI version, so it only
re-runs when either changes — and a parser change regenerates the defaults and
ELB's layer together in one atomic PR. Generated output is committed, because a
reviewable generated diff is the best review surface for a token change: the
reviewer sees both the token diff and its downstream effect.
ELB's layer: its tokens through an adapter
@v4/tokens-euro-league is not a second Ghost export. ELB's Token Studio file is
pushed straight into the package by Token Studio (so sync-tokens must never be
run here — it would revert the palette). Its generate script runs
v4-tokens build -n euro-league, then an adapter (src/adapter.ts,
src/aliases.ts) projects ELB's token names onto the Ghost contract, and
scripts/generate-competition-themes.mjs emits the EuroCup and NextGen
competition themes. Run generate after each Token Studio push;
pnpm --filter @v4/tokens-euro-league coverage reports how much of the contract
ELB's layer re-points.
Motion values come from ELB's motion spec. Ghost's motion.* defaults already
carry most of them; src/motion.ts restates only the leaves where the spec
differs, until the spec's developer pack is vendored. See
Motion and .claude/rules/frontend/motion.md.
British spelling and the ph prefix
Token names and prose use British spelling — "colour set", never "color set" —
and every emitted CSS variable is prefixed ph, e.g. --ph__badge-*. The prefix
is never gam (the gamification-era prefix); see the token reference for the full
Token Studio → contract mapping.
The parser
@v4/token-parser is a self-contained CLI, v4-tokens. It is a pure Token Studio
engine — parse, resolve references, transform values, emit — that knows nothing
about the site. It takes a tokens.json and flags and writes typed
TypeScript (and a parallel CSS file). CI and every package build script invoke it
directly, so there is no wrapper code between the pipeline and the engine.
Commands
| Command | Purpose |
|---|---|
build |
Parse, resolve and emit tokens.ts / tokens.css into an output directory. |
check |
Parse and validate only; non-zero exit on errors. This is the CI drift gate. |
icons |
Discover icon components in a Figma file, export SVGs, and generate React icon components. |
drift |
Compare the Figma file's latest version against the version stamped in each component's figma.json. |
coverage |
Recompute token-binding coverage as node × property pairs (instance subtrees excluded). |
A build looks like this:
v4-tokens build \
-i packages/tokens-euro-league/tokens.json \
-o packages/tokens-euro-league/src/__generated__ \
-n euro-league
v4-tokens check -i <tokens.json> is the gate: it validates without emitting and
exits non-zero on any error, so a malformed or drifted token file reddens CI
rather than shipping a subtly wrong palette. The icon and coverage commands are
surfaced through the package scripts (pnpm --filter @v4/tokens sync-icons,
v4-tokens coverage -c <components-dir>). In practice each token package runs the
CLI from its generate script (node ./node_modules/@v4/token-parser/dist/cli.js build -i tokens.json -o src/__generated__ -n <name>). sync-icons pulls the 130
icons from several files — Ghost Core, ELB – Web – Core (HIjezWNeRQSBGkxx8VqmjA)
and ELB – Web – Content (9iz5GeR9syO68cmPr1ppE1), the competition marks included.
Beside the CLI, packages/token-parser/scripts/ holds the Figma-side tools:
check-bridges.mjs (offline, in pnpm verify: every component bridge names a
file this site is built from), design-status.mjs, fork-diff.mjs and
design-drift.mjs — see Building against ELB's Figma forks.
Two emitters, one resolved graph
The CLI's primary output is TypeScript — the typed defaults and the per-component
contracts. A secondary emitter writes the matching --ph__* CSS custom
properties for escape hatches and any non-TS consumers. Both come from the same
resolved token graph, so the two can never disagree.
The modifier maths for brand tints and shades
Ghost authors the brand tints and shades not as hex literals but as a
reference plus a transform — a Token Studio modify extension:
"P.Color.Shades.Primary-1.700": {
"value": "{P.Color.Primary.1}",
"$extensions": {
"studio.tokens": { "modify": { "type": "darken", "value": "0.3", "space": "hsl" } }
}
}
Resolving the reference and dropping the modify collapses every brand ramp to
its own base colour — enabled, hover and active become identical across every
brand-coloured control. The rendered hexes never appear in tokens.json, so
grepping for them proves nothing: a modifier-derived value has no literal to find.
The parser therefore reproduces Token Studio's maths exactly, in two load-bearing
parts (packages/token-parser/src/modify.ts):
- Scale toward the endpoint —
l*(1-a)toward black,l+(1-l)*atoward white (a naïvel-ais off by up to 100/255 per channel). hsl()quantisation — the hue is rounded to whole degrees and the saturation to whole percent, exactly as Token Studio does. Skipping this still misses five of twelve channels and shifts the hue.
With both parts the output matches Figma's rendered pixels to the byte; golden
pairs pin it in modify.test.ts. The alpha modifier is the exception — it
leaves hue, saturation and lightness untouched, so it is not quantised.
An unsupported modifier is never allowed to pass silently: the parser emits an
unsupported-modifier diagnostic and the build fails rather than shipping an
unmodified base colour. That is how otherwise-invisible modifier bugs — a set of
alpha modifiers quietly losing their transparency — surface at build time
instead of in production.
Naming is the API
The Token Studio component namespace maps 1:1 to the component name:
Button (Figma) → buttonTokens (generated) → buttonTheme → --ph__button-*.
The CLI validates this mapping, so the convention is load-bearing and checked
rather than tribal knowledge — a divergent name is a build error, not a silent
mismatch.
Generated contracts
The parser does not stop at the global palette. It slices tokens.json by
component namespace and emits typed contracts under @v4/tokens/components/* —
today one directory per Token Studio namespace (core/, content/, video/,
football/, gamification/ — the Ghost export carries all five, though nothing
here reads the last two), each split by token type (color.ts,
borderRadius.ts, form.ts, …), rather than one file per component. Ghost 2.0 supports this directly: the colour sets
already specify a per-component surface (buttons, form fields, tabs, page
navigation), and the Token Studio component tokens carry state dimensions
(enabled / hover / focus / disabled / active).
Wiring a generated contract
The design intent is that a component whose own namespace exists in
tokens.json shrinks its .theme.ts to a single wiring statement. No component
here is wired that way yet — the generated namespaces reach components
through the global contract (g.components.*, below) — so the example is
illustrative:
import { registerComponentTheme, buildGlobalTheme } from "@v4/ghost-core/theme";
import type { MapTokensContract } from "@v4/ghost-core/theme";
import { buttonTokens } from "@v4/tokens/components/button"; // generated — do not edit
export const buttonTheme = registerComponentTheme(buildGlobalTheme("button", buttonTokens));
export type ButtonTokens = MapTokensContract<typeof buttonTheme.themeContract>;
buildGlobalTheme("button", buttonTokens)turns the token value tree into a theme, deriving one CSS variable from each kebab-cased contract path — sobuttonemits--ph__button-*and every path segment kebab-cases into the variable name.registerComponentTheme(...)records the theme in the registry the theme emitters walk. There is no hand-maintainedthemes.tsbarrel; the registry replaces it by construction.@v4/ghost-core/themeis the React-free subpath. Because it pulls in no React, the same helpers and contract types are importable from a.css.tsfile or a sibling component package.
Design becomes the source of truth
Because the contract is generated from the component's Figma nodes, design owns
the component's token surface. When a hover state is added to buttons in Figma,
the regenerated contract gains the path — and any component not consuming it
becomes a visible diff, not a silent gap. The generated file is never
hand-patched; the fix belongs in tokens.json or the parser.
Hand-authored contracts for code-only components
A component whose namespace does not exist in Token Studio yet authors its token object by hand against the global defaults contract, exactly as the generated shape would look:
import { registerComponentTheme, buildGlobalTheme, globalTheme } from "@v4/ghost-core/theme";
const g = globalTheme.themeContract;
export const cardTheme = registerComponentTheme(
buildGlobalTheme("card", {
padding: g.semantic.spacer["4"],
borderRadius: g.semantic.borderRadius.medium,
colourSets: {
base1: { enabled: { backgroundColor: g.colourSets.base1.background.main } },
// base2, colourSet1–6 as the design specifies
},
}),
);
The registration and the emitted --ph__card-* variables are identical whichever
way the values arrive, so migrating a hand-authored theme to the generated
contract when design catches up is swapping one import — the shape does not
change.
Binding through the global contract, never a raw import
Hand-authored themes reference the global contract paths
(g.semantic.spacer["4"], g.components.content.*, and for the basketball
library g.components.basketball.* and g.teams.teams.<club>.*, the extension
defined in packages/ghost-core/src/theme/basketball-surface.ts) — never
import { tokens } from "@v4/tokens" with raw tokens.components.*. A raw import
freezes the generated literal into the component's private contract at build
time, beyond the reach of ELB's override. Contract-path bindings resolve
through var(--ph__global-components-*), so ELB's overrides re-point them. The
only theming file that imports raw tokens is default.theme.ts, the single
ingestion point.
The result is that every value a component paints is a typed path with a generated CSS variable behind it — which is exactly what lets ELB re-skin the whole library with one token package. How those variables compose across override levels is covered in the component system.