Token pipeline

How Token Studio tokens become typed contracts and --ph__ CSS variables, through the parser, the generated defaults and ELB's layer.

2026-11-07

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 parses tokens.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 buildGlobalTheme and registerComponentTheme, 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):

  1. Scale toward the endpoint — l*(1-a) toward black, l+(1-l)*a toward white (a naïve l-a is off by up to 100/255 per channel).
  2. 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 — so button emits --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-maintained themes.ts barrel; the registry replaces it by construction.
  • @v4/ghost-core/theme is the React-free subpath. Because it pulls in no React, the same helpers and contract types are importable from a .css.ts file 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.