@v4/tokens-euro-league
Euro League's own token layer: ELB's Token Studio export projected onto the Ghost contract, with competition themes and brand fonts.
Overview
@v4/tokens-euro-league is Euro League's token layer: ELB's Token Studio export, projected onto the Ghost 2.0 global contract in exactly the shape createTheme takes. It is what re-skins the inherited Ghost components as the Euro League site, and it is this project's own package: /sync-upstream never offers changes to it.
import { tokens } from "@v4/tokens-euro-league";
// tokens.global — level-2 overrides: every contract leaf ELB restates
// tokens.components — {} on purpose
components (override level 3) is empty, and that is not a gap. Level-3 overrides are keyed by component-theme name and shaped like each component's private token tree, which no Token Studio export can produce. Component themes bind g.components.* contract paths instead, so ELB's Component/* sets reach them through global. The override levels are explained on Override levels; how this package is built and wired is on Theme the site.
Where the tokens come from
Token Studio pushes tokens.json straight to this repo, as feat(tokens): … commits (the latest on 4 Nov 2026). The file is the package's source of truth.
- Never run
pnpm --filter @v4/tokens-euro-league sync-tokens. The script still exists, and it fetches thestadionHQ/elb-web-tokenscopy, which has not moved since 23 Sep 2026: running it would replace the current palette, the competition themes and every push since with that older file. - After every Token Studio push, run
pnpm --filter @v4/tokens-euro-league generateand commit the result. Nothing gates it: a push left ungenerated simply does not reach the site.
Some comments still describe the file as vendored from elb-web-tokens (apps/web/src/theme/tokens.css.ts, src/aliases.ts). It is not.
Scripts
| Script | What it does |
|---|---|
generate |
@v4/token-parser build -i tokens.json -o src/__generated__ -n euro-league, then scripts/generate-competition-themes.mjs, then Prettier over the output |
build |
generate, then tsc into dist/. The package's exports point at dist/, so consumers need a build, not just a generate |
check |
The parser's validation of tokens.json |
coverage |
How much of the Ghost contract the club changes, agrees with, or leaves at Ghost's defaults (missing); fails when a component theme reads a missing leaf. Reads dist/, so build first |
figma-colour-check |
Compares every emitted colour against what Figma paints on ELB – Web – Core's ColorSets frame (default --file HIjezWNeRQSBGkxx8VqmjA --node 23630:10386). Needs FIGMA_PAT; exits 1 on any difference |
test, lint, check-types |
Vitest (aliases, competition-themes, fonts), ESLint, tsc |
sync-tokens |
Do not run (above) |
The generated files — src/__generated__/tokens.ts, tokens.css, tokens.flat.json, components/* and competition-themes.ts — are never edited by hand. They are pre-adapter: their font weights are the export's standard numbers. Read a post-adapter value from tokens.global or resolutions, never from tokens.flat.json.
Exports
@v4/tokens-euro-league (src/index.ts → dist/index.js):
tokens—{ global, components: {} };globalis the adapted layer plusmotion(below).competitionThemes(EuroCup,NextGen) andCOMPETITION_THEME_NAMES; typesCompetitionTheme,CompetitionThemeName.resolutions— every Ghost contract leaf with how, or whether, Euro League resolved it; typesResolution,ResolutionKind,AdapterResult,AdapterOptions.euroLeagueSourceTokens— the raw generated tree.chromeDefaultWayAlias.- The font table and helpers:
EURO_LEAGUE_FONT_FACES,FONT_WEIGHT_RENUMBERING,TOKEN_STYLE_WEIGHTS,fontFaceDescriptors,fontFaceSrc; typesEuroLeagueFontFace,EuroLeagueFontStyle,FontFaceDescriptors. euroLeagueMotion.
@v4/tokens-euro-league/fonts (dist/fonts.js) — the font table and helpers alone, data only.
Runtime dependencies: @v4/ghost-core and @v4/tokens.
The adapter
The Euro League file was authored against Ghost 2.0, so it shares the contract's vocabulary. The adapter (src/adapter.ts, src/aliases.ts) is still load-bearing:
- It keeps contract paths only.
createGlobalThemethrows on a path the contract does not declare, and ELB's file is a superset (itsComponent/Gamificationset, for one). The paths it may fill are built bybuildGhostGlobalSurfacefrom@v4/ghost-core/global-surface— the same helper ghost-core's own default theme uses — so they are exactly what the global theme emits, including the chrome colour-way extension and the basketball extension (components.basketball.*,teams.teams.*), which the club'sComponent/BasketballandTeamssets now resolve onto directly. - Resolution kinds:
direct,alias,font,font-held,font-weight,missing. - The alias tables are empty, with one rule:
chromeDefaultWayAliasmaps Ghost's un-way'd chrome paths (navigation,moreMenu,hygieneLinks,myAccountDropDownTrigger,sSOLink) onto the export'sDefaultway. - The package calls it with
{ fonts: true, fontWeights: FONT_WEIGHT_RENUMBERING }(below).
The package README's adapter section still says the Component/Basketball and Teams sets are dropped, with an 8 October leaf count; src/index.ts is current.
Competition themes
tokens.json's $themes are EuroLeague (the base), EuroCup and NextGen. EuroCup and NextGen each add one brand set (Brand/EuroCup, Brand/NextGen), and all three enable the Competition set.
scripts/generate-competition-themes.mjscomposes each theme the way Token Studio does — a later set overrides an earlier one at the same path — resolves it with the same parser, and writes the leaves that differ from EuroLeague's tosrc/__generated__/competition-themes.ts. It fails rather than write a theme it cannot compose honestly.buildCompetitionThemes(src/competition-themes.ts) projects each through the SAME adapter and options and keeps only the delta againsttokens.global. EuroLeague is the club layer itself and has no entry.
Today the delta is colours and gradients only. Competition-specific typography was removed on 2 Nov 2026 (every competition sets in ELB's faces); the generated delta has no font leaves. The EuroCup and NextGen faces still ship in the font table. The README's "Typography is part of the delta" paragraph predates this.
In the app, apps/web/src/theme/competition-themes.css.ts emits each delta at :root.ph__competition-<slug> for every entry in COMPETITION_THEMES (apps/web/src/lib/competition-theme.ts), and the root layout puts the class on <html> from the page's competition section. A competition added in the CMS wears EuroLeague's layer until it is named there. Storybook has no competition-theme switch. How a page wears one, and how to add one, is on Competition themes; why they are built this way is on Competition sections.
Motion
src/motion.ts (euroLeagueMotion) is the ELB motion spec at level 2: only the leaves where the spec differs from Ghost's defaults — today duration.slow 400ms (Ghost 500ms). It is a hand-written interim until the spec's developer pack (motion-tokens.json) is vendored. It is merged into tokens.global.motion. The spec and its rules: Motion.
Fonts
EURO_LEAGUE_FONT_FACES (src/fonts.ts) is the one table of the brand faces: 13 cuts across 7 families.
| Family | Cuts (declared weight) |
|---|---|
| Bw Modelica | Light 400, Regular 500, Medium 600, Bold 700, Black 900 |
| Staff Wide | Black 900 |
| Bw Stretch | Black 800 |
| Hanson | Bold 700 |
| Maxim Sans | Regular 400, Bold 700 |
| Constructio Grotesk | Regular 400 |
| Okana | SemiBold 600, Black 900 |
The files are in apps/web/public/fonts/<family>/ and served at /fonts/…: OTFs, except Hanson, Constructio Grotesk and both Okana cuts, which are TTFs.
- Each face is declared at its file's own weight class, the number Figma Dev Mode prints. The export names weights by style and the parser turns them into standard numbers, so
FONT_WEIGHT_RENUMBERINGre-points the ramp: on Bw Modelica, Light 300 → 400 and Regular 400 → 500. - On Bw Modelica a literal
400ornormalis LIGHT. Anything that is not a ramp step must bind a ramp weight — which is whyapps/web/src/theme/tokens.css.tsbinds thebodyto Body-M's family and weight.src/fonts.test.tsreads each file's weight class and subfamily back, and scans the site's sources for a literal400,normal,bolderorlighter; itsturbo.jsoninputs include the font files, so a swapped file re-runs it. - Three consumers derive from the table, so none can drift: the app's
@font-facerules (apps/web/src/theme/fonts.css.ts), Storybook's (apps/storybook/.storybook/euro-league-theme.css.ts), and the adapter's renumbering. Thefontsflag and the files move together: a family with no@font-facebehind it renders as the browser default. - Licensing. The files are ELB's retail desktop fonts, served as supplied and deliberately not converted to WOFF2: the web-licence holder is still unrecorded (
D-22 · 2ainstadionHQ/euroleague-vault). Convert only once it is, in the same change.
club.config.ts's fonts names the heading and body faces. A comment in apps/web/src/theme/fonts.css.ts still calls Bw Stretch "deliberately absent"; it ships.
How the app loads it
The app imports tokens in its theme files under apps/web/src/theme/: tokens.css.ts declares the global layer once on :root and emits the ghost-core and ghost-content themes on every page (and binds the body face); tokens-home-hero.css.ts, tokens-video.css.ts and tokens-basketball.css.ts emit the other packages' themes on the routes that render them; competition-themes.css.ts emits the EuroCup and NextGen deltas; and fonts.css.ts writes one @font-face per table row. Which layout loads which part is on Theme emission and performance.
apps/web/next.config.ts sets PH_THEME_EMISSION=club, so these club parts are the single emitter of every theme. A route that renders a package must load its part, or that package's components declare no variables and render unstyled with nothing failing. club.config.ts's theme.tokens is {}: the tokens are wired here, not through the config.
How Storybook loads it
apps/storybook/.storybook/euro-league-theme.css.ts scopes the same tokens to ph__club-euro-league and declares the faces. The toolbar's clubTheme global defaults to Euro League; localStorage["ph-club-theme"] = "ghost" pins Ghost. The Playwright e2e pins it that way (storageState); the vitest story suite sets the same flag too late — its static import of the preview has already read it — so it renders the club theme.