Token packages

Regenerate Euro League's tokens after a Token Studio push, the Ghost defaults beneath them, and the icon set.

2026-11-07

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. Its generate script runs the parser over the vendored Ghost 2.0 tokens.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 — its sync-icons script reads ELB's Figma files (Icons).
  • packages/token-parser — the engine. A self-contained CLI (v4-tokens) that takes a tokens.json and emits typed TypeScript plus matching CSS from the same resolved graph, so the two can never disagree. Its own package build is tsc; the pipeline runs the built dist/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 in packages/token-parser/src changes nothing until you pnpm --filter @v4/token-parser build; regenerating then needs a build of the token package (not just generate) 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-tokens on @v4/tokens-euro-league. The package still has the script, which fetches token.json from stadionHQ/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-tokens to extract a component's Token Studio bindings from its Figma file (ELB's, for this site) into the component's tokens/ directory, and /sync-figma-styles to 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 typed as const defaults and the Tokens type. 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 Studio components.<figmaFile>.<group> namespace — 13 modules across content, core, football, gamification and video from the current Ghost export, plus an index.ts per namespace — importable as @v4/tokens/components/core/form and 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-hidden by 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-tokens to 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.