Token packages
Regenerate Euro League's tokens after a Token Studio push, the Ghost defaults beneath them, and the icon set.
The pipeline
Every design token on the Euro League site is a typed TypeScript path, produced by an authored Token Studio export feeding a chain of generated outputs:
tokens.json ──▶ @v4/token-parser ──▶ @v4/tokens-euro-league ──▶ apps/web/src/theme ──▶ --ph__* CSS vars
(ELB's Token (the CLI) (generated + adapted) (the theme parts) (emitted at build)
Studio export)
ELB's tokens do not stand alone: they override the Ghost defaults in @v4/tokens, which every
component theme binds through the global contract. How the chain works, and why those generated
defaults are the contract — a Euro League token that lags a structure change fails to compile
at the exact token path — is explained on The token pipeline. This
guide is the running of it: which package to regenerate, with which command, and the traps.
The three packages
packages/tokens-euro-league(@v4/tokens-euro-league) — this project's token layer, and the one you will regenerate. It is built from ELB's Token Studio export (which Token Studio pushes straight into this repo), projected onto the Ghost contract by an adapter, and emitted by the app's theme files — see Euro League's overrides.packages/tokens(@v4/tokens) — the generated Ghost defaults that the Euro League layer overrides, plus the 129 generated icons. Itsgeneratescript runs the parser over the vendored Ghost 2.0tokens.json. The Ghost defaults are template-owned: they change here through/sync-upstream, not by hand (Take template updates). The icon set is this project's — itssync-iconsscript reads ELB's Figma files (Icons).packages/token-parser— the engine. A self-contained CLI (v4-tokens) that takes atokens.jsonand emits typed TypeScript plus matching CSS from the same resolved graph, so the two can never disagree. Its own package build istsc; the pipeline runs the builtdist/cli.js.
The v4-tokens commands
The CLI surface (run via the package scripts, or directly through
node ./node_modules/@v4/token-parser/dist/cli.js …):
| Command | Purpose |
|---|---|
build |
Parse, resolve and emit tokens.ts / tokens.css (and per-namespace contract modules) into an out dir. |
check |
Parse and validate only; non-zero exit on errors — this is the CI drift gate. |
icons |
Discover icon component masters 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 raw build invocation looks like this — it is what @v4/tokens-euro-league's generate script
wraps:
v4-tokens build \
-i packages/tokens-euro-league/tokens.json \
-o packages/tokens-euro-league/src/__generated__ \
-n euro-league
The generate script follows that with node scripts/generate-competition-themes.mjs (the
EuroCup and NextGen competition themes) and a prettier pass.
Generated output is committed
@v4/tokens and @v4/tokens-euro-league run the CLI in their build script (turbo-cached on
tokens.json and the CLI version), and the generated output is committed, so a token change is
reviewed as its generated diff
(why).
Operational trap. The pipeline runs the parser's built
dist/. A fix inpackages/token-parser/srcchanges nothing until youpnpm --filter @v4/token-parser build; regenerating then needs a build of the token package (not justgenerate) before Storybook sees new values. A "the fix didn't work" reading is usually one of those stale layers.
Token names use British spelling ("colour set") and every emitted variable is prefixed ph
(--ph__badge-*). The next pages cover running the pipeline: regenerating tokens, regenerating
the icons, and how Euro League's layer is shaped and wired. For the parser itself, see the
@v4/token-parser CLI reference.
Generating tokens
This page is the operational how-to for regenerating the token layer: Euro League's own tokens after each Token Studio push, and — for reference — the Ghost defaults underneath them, which files are generated (and therefore never hand-edited), and how to validate them.
After a Token Studio push
Token Studio pushes ELB's tokens.json straight into packages/tokens-euro-league (the EuroCup and
NextGen themes arrived that way). After each push, only regenerate:
pnpm --filter @v4/tokens-euro-league generate # parser + competition themes + prettier
pnpm --filter @v4/tokens-euro-league check # validate only
pnpm --filter @v4/tokens-euro-league build # so Storybook and the app see it
Review the generated diff under src/__generated__/ and commit it with the tokens.json change.
Never run
sync-tokenson@v4/tokens-euro-league. The package still has the script, which fetchestoken.jsonfromstadionHQ/elb-web-tokens— and that copy is behind this repo's, so running it reverts the palette.
The Ghost defaults (@v4/tokens)
The rest of this page is the Ghost layer that Euro League overrides. It is template-owned: changes
to it reach this repo through /sync-upstream, so pnpm --filter @v4/tokens sync-tokens is not
part of day-to-day work here. You need it when you are reading a generated contract, debugging a
value that Euro League does not override, or checking a template update's token diff.
The @v4/tokens scripts
packages/tokens exposes these scripts — run them with pnpm --filter @v4/tokens <script>:
| Script | What it runs |
|---|---|
sync-tokens |
Pull the latest tokens.json from the design repo, then generate. |
generate |
v4-tokens build over tokens.json → src/__generated__, then prettier the output. |
build |
generate, then tsc compile to dist. |
check |
v4-tokens check — validate tokens.json only; non-zero exit on errors (the CI gate). |
sync-icons |
Regenerate the icon set (see Icons). |
drift |
Design-drift report — Figma latest version vs the version stamped in each component's figma.json. |
Refresh the export and regenerate
sync-tokens fetches the current tokens.json from the Ghost tokens repo
(stadionHQ/ghost-web-tokens-v2) via the GitHub API and then regenerates:
pnpm --filter @v4/tokens sync-tokens
Under the hood that is:
gh api -H 'Accept: application/vnd.github.raw' \
repos/stadionHQ/ghost-web-tokens-v2/contents/tokens.json > tokens.json \
&& pnpm generate
So it requires an authenticated gh CLI. If you already have the tokens.json
you want in place (e.g. you are testing a local edit), skip the fetch and just
regenerate:
pnpm --filter @v4/tokens generate
generate runs the parser and prettifies the result:
node ./node_modules/@v4/token-parser/dist/cli.js build \
-i tokens.json -o src/__generated__ -n ghost \
&& prettier --write src/__generated__ …
⚡ Do it with Claude — run
/extract-figma-tokensto extract a component's Token Studio bindings from its Figma file (ELB's, for this site) into the component'stokens/directory, and/sync-figma-stylesto cross-reference a component's theme wiring against its Figma design and refresh the generated contract. This guide covers the manual CLI path those skills wrap.
What gets generated
A build/generate writes into packages/tokens/src/__generated__ (@v4/tokens-euro-league's
src/__generated__/ has the same shape, plus competition-themes.ts):
tokens.ts— the typedas constdefaults and theTokenstype. This is the token source of truth. Proven against the real export, this is a ~6,700-line typed file (3,160 tokens: 591 primitive / 1,063 semantic / 1,507 colour-set).tokens.css— the parallel--ph-*CSS custom properties, from the same resolved graph, for escape hatches and non-TS consumers (~4,900 lines).components/<figmaFile>/<group>.ts— one typed contract module for every Token Studiocomponents.<figmaFile>.<group>namespace — 13 modules acrosscontent,core,football,gamificationandvideofrom the current Ghost export, plus anindex.tsper namespace — importable as@v4/tokens/components/core/formand the like. Component themes bind these through the global contract (g.components.*), never by importing them raw.tokens.flat.json— a sidecar used to print a contract diff (+added / −removed / ~changed) against the previous build on every run.
These files are generated — never hand-edit them. If a value is wrong, the
fix belongs in tokens.json or the parser, not in the emitted file. CI enforces
this two ways: it regenerates and git diff --exit-codes the committed output,
and v4-tokens drift reports design-side staleness.
Validate without emitting
The check command parses and validates only, exiting non-zero on any error —
this is the gate that keeps a malformed or drifted token file out of the build:
pnpm --filter @v4/tokens check
# → node …/cli.js check -i tokens.json
It catches genuine data bugs at build time rather than in production. On first
contact with the real export it caught a link-visited vs Link-Visited
collision after normalisation and failed the build as designed.
The modifier maths (why grepping for a hex proves nothing)
Ghost — and ELB's export after it — authors 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" } }
}
}
The rendered hexes never appear in tokens.json, so before concluding a value is
absent from an export, look for {reference} +
$extensions["studio.tokens"].modify — grepping the rendered colour proves
nothing either way. The parser reproduces Token Studio's maths exactly (scale
toward the endpoint plus hsl() quantisation), and refuses to pass an
unsupported modifier silently: it emits an unsupported-modifier diagnostic and
fails the build rather than shipping an unmodified base colour. See
The parser for the full derivation.
Regeneration checklist
After changing a tokens.json (or the parser) — shown for @v4/tokens; substitute
@v4/tokens-euro-league for Euro League's layer (and never its sync-tokens):
# 1. if the parser changed, rebuild it first — the pipeline runs the built dist
pnpm --filter @v4/token-parser build
# 2. regenerate the defaults
pnpm --filter @v4/tokens generate # or sync-tokens to fetch first
# 3. validate
pnpm --filter @v4/tokens check
# 4. build so downstream (Storybook, web) sees new values
pnpm --filter @v4/tokens build
# 5. review the committed generated diff, then commit
Icons
The icon set is generated the same way tokens are — from Figma, by the
v4-tokens CLI — and lives inside @v4/tokens beside the Ghost defaults. Unlike those defaults, the set is this
project's: sync-icons reads ELB's Figma files as well as Ghost's. There are currently 129 generated icons, exported from
@v4/tokens/icons.
Regenerating the icons
packages/tokens wraps the CLI's icons command as sync-icons:
pnpm --filter @v4/tokens sync-icons
It runs the CLI six times, because the icons this site uses come from three Figma files:
| Run | File | What it takes |
|---|---|---|
| 1 | Ghost – Core 2.0 (67J5FrDZn5HzYzf5hJgoNA) |
every icon master |
| 2 | ELB – Web – Core (HIjezWNeRQSBGkxx8VqmjA) |
every icon master |
| 3 | ELB – Web – Core | six social-media "Plain" glyphs by --nodes (Facebook, YouTube, X, Instagram, TikTok, WhatsApp) |
| 4 | ELB – Web – Content (9iz5GeR9syO68cmPr1ppE1) |
Icon-Core/FEature and Icon-Social Media/Share-Plain by --nodes |
| 5 | Ghost – Core 2.0 | Icon-Core/Chevron-Down and Chevron-Up by --nodes |
| 6 | ELB – Web – Content | the competition marks Icon-Competition/EuroLeague, EuroCup, NextGen by --nodes |
All six write into packages/tokens/src/icons/. The exact node ids live in the
sync-icons script in packages/tokens/package.json.
It needs FIGMA_PAT in the repo's root .env (a personal access token with
file-read scope). The full CLI signature:
v4-tokens icons -k <file-key> -o <dir> [--page "- Icons"] [--filter <substr>] \
[--nodes "<Figma name>@<node-id>[,…]"]
--nodes names the components explicitly instead of discovering them on a
page. Discovery skips every variant child, which is right for icon masters and
wrong for the glyphs the ELB files only use as instances or variants — the
footer's social glyphs and the competition marks — so those are listed by name
and node id. The Figma name becomes the export name: Icon-Competition/EuroLeague
is IconCompetitionEuroleague, Icon-Social Media/X-Plain is
IconSocialMediaXPlain.
How it works, and what it emits
icons discovers icon component masters via Figma's published-components
endpoint (variant children are filtered out), exports each as SVG, and emits a
React icon component per icon (a kebab-case file such as
icon-chevron-right.tsx). Each
generated component:
- renders with
fill="currentColor"so it inherits text colour, - is
aria-hiddenby default, - takes a typed
IconProps,
and the run rewrites an alphabetical barrel so every icon is exported from
one place. The output directory is a flat set of .tsx components plus the
index.ts barrel.
Consuming an icon
Import from the @v4/tokens/icons subpath (declared in the package exports):
import { IconChevronRight, IconSearch } from "@v4/tokens/icons";
export function Example() {
return (
<button aria-label="Search">
<IconSearch />
</button>
);
}
Because icons render with currentColor, they take their colour from the
surrounding text context — a themed control's icon re-colours through the same
--ph__* variables as its text, with no per-icon wiring.
Slot demo content must use real generated icons. When a component's story or a Figma slot needs an icon, import the real one from
@v4/tokens/icons— never hand-roll an inline SVG demo shape, and never generate a one-off icon for demo content. This is a standing Storybook rule.
Directional icons and RTL
Icons do not mirror automatically under dir="rtl" — logical properties
mirror layout, not artwork, so an arrow drawn pointing right keeps pointing right
on an RTL page. Where a glyph genuinely means inline-start/inline-end (a
"next" chevron, a "back" arrow), opt it in at the call site with the house
directionalIcon mechanism rather than a blanket flip:
import { directionalIcon } from "@v4/ghost-core";
<span className={directionalIcon} data-directional="true">
{direction === "next" ? <IconChevronRight /> : <IconChevronLeft />}
</span>;
It flips only inline-axis navigation glyphs and reads the element's own resolved
directionality (:dir(rtl)), so a dir="ltr" island inside an RTL page is left
alone. Block-axis and semantic glyphs (up/down chevrons, play triangles, search
glasses, logos) must never be flipped. The full rule is in the vanilla-extract
conventions.
Keeping icons current
Like tokens, icons are generated and committed, so the workflow is: run
sync-icons, review the generated diff, commit. Design-side staleness across the
component library is reported by the drift command:
pnpm --filter @v4/tokens drift
Euro League's overrides
The whole component library wears ELB's brand because of one token package:
packages/tokens-euro-league (@v4/tokens-euro-league). It is built by the same pipeline as
@v4/tokens, but from ELB's Token Studio export, and it overrides the Ghost defaults rather than
replacing them. Its README.md is the detailed reference for everything on this page. The package
is this project's own — /sync-upstream never touches it.
⚡ Do it with Claude — run
/extract-figma-tokensto extract a component's Token Studio bindings from ELB's Figma. This page covers the package shape and how the app wires it.
The package shape
| Path / script | What it is |
|---|---|
tokens.json |
ELB's Token Studio export. Token Studio pushes it straight into this repo. |
src/__generated__/ |
Parser output. Never edit by hand. |
src/adapter.ts, src/aliases.ts |
The adapter: projects ELB's tokens onto the Ghost contract. |
src/competition-themes.ts |
The EuroCup and NextGen layers, as deltas over the EuroLeague layer. |
src/fonts.ts |
The brand faces and their weight renumbering (also exported as @v4/tokens-euro-league/fonts). |
src/motion.ts |
The motion values from the ELB motion spec (Token Studio has no motion to adapt). |
generate |
v4-tokens build -i tokens.json -o src/__generated__ -n euro-league, then scripts/generate-competition-themes.mjs. |
build |
generate, then tsc to dist. |
check |
v4-tokens check -i tokens.json — validate only. |
coverage |
How much of the Ghost contract ELB re-skins, and what it leaves at Ghost defaults. |
test |
The package's vitest suite. |
figma-colour-check |
scripts/figma-colour-check.mjs. |
sync-tokens |
Do not run. It fetches the stale stadionHQ/elb-web-tokens copy and reverts the palette. |
After each Token Studio push, run pnpm --filter @v4/tokens-euro-league generate
(and build so Storybook and the app see it).
The package's src/index.ts exports the Euro League layer in the shape the theme
helpers take:
export const tokens: Required<ThemeTokens> = {
global: { ...adapted.global, motion: euroLeagueMotion }, // level 2
components: {}, // level 3 — empty by design, see below
};
The adapter guarantees every emitted path exists in the Ghost contract, so an ELB token that references a renamed or misspelled path fails structurally rather than painting the wrong thing silently. The Euro League file was authored against Ghost 2.0, so its alias table is empty; the README explains why the adapter is still load-bearing.
Wiring it into the app
The app does not make one createTheme() call: the Euro League theme is emitted in
parts from apps/web/src/theme/, with Euro League's layer as the one emitter
(PH_THEME_EMISSION=club in apps/web/next.config.ts). A route that renders a
package must load that package's part, or its components render unstyled with
nothing failing. The files, and what loads each, are under
Wiring it into the app in the Theme the site
guide; the measurements behind the split are in
Front-end performance.
apps/web/club.config.ts does not carry the tokens: its
theme: { tokens: {}, chrome: { colourWay: "default" } } notes that they are
wired in src/theme/tokens.css.ts, and chrome.colourWay picks the header's
way.
Competition themes
generate also writes the EuroCup and NextGen layers — the leaves where each
competition's Token Studio theme differs from EuroLeague's — to
src/__generated__/competition-themes.ts, exported as competitionThemes. How
they are composed and how a page wears one is in
Competition themes.
The override levels
ELB's data flows through level 2 (the package's global), which
re-skins the colour sets and global.components.* in one supply. Level 3
(components) stays empty on purpose, and a component theme never imports raw
tokens — both explained in
The components slot is empty on purpose
and on Override levels.
Modifier extensions in ELB's export
ELB's export uses the same modify extensions Ghost does (tints, shades, and
alpha, for example on visited-link colours). The parser refuses an unsupported
modifier rather than shipping the unmodified base, so a check that fails on an
unsupported modifier has found a real data issue, not a tooling one.
Where to go next
For how the site is themed — the theme parts, the competition themes and the brand faces — see Theme the site. For the pipeline internals, see Token pipeline.