Theme the site
How ELB's brand is put onto the site: the Euro League token package, its adapter and fonts, override levels and competition themes.
Overview
This guide is the practical companion to two concept pages you should read first: Theming & colour sets explains the colour-set model, and Override levels explains the five levels at which tokens compose. This page assumes both and shows how ELB's brand is put onto the Euro League site.
One package re-skins the site
The components came from the template in Ghost's design; the site wears ELB's because of one
tokens package, packages/tokens-euro-league (@v4/tokens-euro-league), which the app applies
from apps/web/src/theme/. Nothing forks a component or hand-picks CSS variables to do it:
// apps/web/src/theme/tokens.css.ts (abridged) — loaded on every page
import { OWN_THEMES as CORE_THEMES } from "@v4/ghost-core/init.css";
import { OWN_THEMES as CONTENT_THEMES } from "@v4/ghost-content/init.css";
import { createClubThemePart, globalTheme } from "@v4/ghost-core/theme";
import { tokens } from "@v4/tokens-euro-league";
// Euro League's global layer, declared ONCE on the root and inherited
globalTheme.createScoped(":root", tokens.global);
// the component themes of the packages every page renders, with Euro League's values
createClubThemePart(
{ global: tokens.global, components: tokens.components },
{ components: [...CORE_THEMES, ...CONTENT_THEMES] },
);
The global layer re-points the entire palette. Because the Ghost 2.0 Component/*
namespaces live in the global contract too, component-owned colours and radii re-skin from
the same object — every package (@v4/ghost-core, -content, -video, -basketball) is
re-themed at once. This is override level 2; the theme never touches component internals.
The app emits it in parts — the global layer once on :root, each package's
component themes with the routes that render them, and Euro League's layer as the one emitter
(PH_THEME_EMISSION=club). Why, and the rule that a route must load its package's part,
are on Theming & colour sets; the
files are listed under Wiring it into the app.
club.config.ts keeps theme: { tokens: {}, chrome: { colourWay: "default" } }: the
tokens are wired in the files above, not through the config, and chrome.colourWay picks
the header way the site runs on.
Colour sets are the theming axis
ELB's tokens re-skin the site by redefining the colour sets (and the palette that feeds
them), never with an onDark flag: a dark surface is simply a colour set. The model is on
Theming & colour sets.
The Euro League token package
packages/tokens-euro-league is this project's own (/sync-upstream never touches it). Its whole
job is to take ELB's Token Studio export and present it in the exact shape the theme helpers
expect:
export const tokens: Required<ThemeTokens> = {
global: { ...adapted.global, motion: euroLeagueMotion }, // level 2 — the re-skinned palette
components: {}, // level 3 — empty; ELB's data flows through `global`
};
global carries everything ELB changes; components is an escape-hatch (level 3)
that a Token Studio export cannot generate and is normally left empty. It also exports
competitionThemes — the EuroCup and NextGen layers drawn over the EuroLeague layer
(Competition themes). The rest of this guide
walks through:
- The token package — the package's layout, the generated/typed token shape, the adapter, and the fonts.
- Overriding tokens — global vs component vs per-instance overrides, colour-set overrides, and worked examples grounded in the override ladder.
- Competition themes — one export, three competitions.
The token package
packages/tokens-euro-league depends on @v4/tokens (the Ghost 2.0 defaults it projects onto)
and @v4/ghost-core (the global surface helper). Read its README.md and src/index.ts
alongside this page.
What the package contains
packages/tokens-euro-league/
├── tokens.json # the Token Studio export (stadionHQ/elb-web-tokens)
├── scripts/
│ ├── generate-competition-themes.mjs # the EuroCup / NextGen layers (see Competition themes)
│ ├── coverage.mjs # how much of the Ghost contract ELB re-skins
│ └── figma-colour-check.mjs
├── src/
│ ├── __generated__/ # output of @v4/token-parser — NEVER hand-edited
│ │ ├── tokens.ts # literal Euro League values (aliases already resolved)
│ │ ├── tokens.css, tokens.flat.json
│ │ ├── competition-themes.ts # the leaves each competition theme changes
│ │ └── components/ # the export's Component/* sets, per namespace
│ ├── adapter.ts # projects ELB's export onto the Ghost 2.0 contract
│ ├── aliases.ts # explicit, reviewable path renames
│ ├── flatten.ts
│ ├── fonts.ts # the self-hosted faces and the weight renumbering
│ ├── motion.ts # the motion tokens (the ELB motion spec)
│ ├── competition-themes.ts # projects the competition layers onto the contract
│ └── index.ts # exports `tokens`, `competitionThemes`, the font table
├── package.json
└── README.md
The generated, typed token shape
ELB's brand values are a Token Studio export (tokens.json) compiled by the same parser that
generates the Ghost defaults — no fork of the pipeline:
// packages/tokens-euro-league/package.json → "scripts" (abridged)
{
"generate": "node ./node_modules/@v4/token-parser/dist/cli.js build -i tokens.json -o src/__generated__ -n euro-league && node scripts/generate-competition-themes.mjs && prettier --write src/__generated__ …",
"build": "pnpm generate && rm -rf dist && tsc -p tsconfig.build.json",
"check": "… cli.js check -i tokens.json",
"coverage": "node scripts/coverage.mjs",
"sync-tokens": "gh api … repos/stadionHQ/elb-web-tokens/contents/token.json > tokens.json && pnpm generate",
}
Do not run sync-tokens. Token Studio pushes straight into the repo's
tokens.json (the EuroCup and NextGen themes arrived that way, 22–23 Oct 2026), and pulling
from elb-web-tokens reverts the palette. After each push, regenerate:
pnpm --filter @v4/tokens-euro-league generate
src/__generated__/tokens.ts holds literal values, not {references}: the parser
resolves every alias at build time, including Token Studio's modify extensions. The
generated diff is the review surface. Never hand-edit __generated__/.
The package's public export is tokens, typed as Required<ThemeTokens>:
// packages/tokens-euro-league/src/index.ts (abridged)
import { buildGhostGlobalSurface } from "@v4/ghost-core/global-surface";
import { tokens as ghostTokens } from "@v4/tokens";
const ghostGlobal = buildGhostGlobalSurface(ghostTokens);
const adapted = adaptClubTokens(ghostGlobal, { ...euroLeagueTokens }, adapterOptions);
export const tokens: Required<ThemeTokens> = {
global: { ...adapted.global, motion: euroLeagueMotion }, // level 2
components: {}, // level 3 — deliberately empty (see below)
};
buildGhostGlobalSurface is the SAME helper ghost-core's default.theme.ts uses, so the
set of paths the adapter can fill is exactly the set the global theme emits — including the
chrome colour-way extension and the basketball extension
(components.basketball.*, teams.teams.*). There is no hand-written copy of the surface
to keep in step.
Why there is an adapter
ELB's export is not guaranteed to match the Ghost 2.0 contract path-for-path, and two properties of the pipeline make a mismatch a hard failure, not a warning:
createGlobalThemevalidates the token object against the contract. A path the contract does not declare throws; a path ELB omits silently keeps the Ghost default.- Aliases are already flattened. Overriding
primitive.color.primary.1changes nothing downstream, because every dependent leaf was resolved to a literal at build time. The export must restate every leaf it wants changed.
So the package projects ELB's export onto the Ghost contract rather than handing it
over whole. adaptClubTokens() classifies each Ghost contract leaf as:
- direct — the path exists verbatim in both files.
- alias — an explicit rename declared in
src/aliases.ts(name equality is not semantic equality, so each rename is reviewed). - font — value-level substitution of the family roles.
- missing — ELB leaves it at the Ghost default.
pnpm --filter @v4/tokens-euro-league coverage reports how much of the Ghost contract ELB
actually re-skins and which paths stay at Ghost defaults; resolutions (exported)
records how each leaf resolved.
Fonts
The brand faces (Staff Wide, Bw Modelica) are self-hosted OTFs in apps/web/public/fonts.
EURO_LEAGUE_FONT_FACES (src/fonts.ts, exported at @v4/tokens-euro-league/fonts) is the
one table they are declared from — by the app (apps/web/src/theme/fonts.css.ts) and by
Storybook (apps/storybook/.storybook/euro-league-theme.css.ts). Each face is declared at its
FILE's own weight class, and the adapter's fontWeights: FONT_WEIGHT_RENUMBERING re-points
the ramp's weights to the same numbers, so a face and the steps that select it
cannot drift apart. A family name with no @font-face behind it renders as the browser
default; add a table row in the same change that adds a ramp step binding a new cut. The
files ship as supplied, not converted to WOFF2 — see the package README's licensing note.
The components slot is empty on purpose
Level-3 overrides (createTheme({ components })) are keyed by component-theme name and
shaped like each component's private token tree — a shape no Token Studio export can
generate. That is fine: component themes bind g.components.* contract paths, so ELB's
Component/* sets reach them through global (level 2). Leave
components: {} unless you are hand-authoring a level-3 escape hatch.
Wiring it into the app
The app applies the Euro League layer in parts, under apps/web/src/theme/
(Overview):
| File | Emits | Loaded by |
|---|---|---|
tokens.css.ts |
the global layer on :root; ghost-core + ghost-content themes |
the [locale] root layout (and global-error.tsx) — every page |
competition-themes.css.ts |
the competition layers on :root.<class> |
the [locale] root layout |
fonts.css.ts |
the @font-face rules |
the [locale] root layout (and global-error.tsx) |
tokens-home-hero.css.ts |
the basketball themes the home hero's widgets need | the [locale] root layout |
tokens-video.css.ts |
ghost-video themes | the video layout and the six data-area layouts |
tokens-basketball.css.ts |
ghost-basketball themes | the six data-area layouts, news/[...path] (the article header widgets), code-of-conduct |
Each part calls createClubThemePart({ global, components }, { components: OWN_THEMES })
with the OWN_THEMES list exported by that package's init.css, inside the same
vanilla-extract compile unit as the package's theme registrations. A new package needs a
new part and the layouts that import it, or its components render unstyled with every gate
green — check in the browser that every ph__*-theme class a page uses has declarations in
that page's stylesheets.
Because the global layer is declared once on :root and inherited, nothing re-declares it
per element: before changing either the root declaration or the parts, pixel-diff full
pages at two widths.
Overriding tokens
The override-levels page sets out the five levels in full. This page is the working summary for this site: which level to reach for, and worked examples. The highest level that sets a value wins.
| Level | What it is | Who sets it | Reach |
|---|---|---|---|
| 1 | Theme defaults | Generated from Ghost 2.0 tokens.json |
Whole library |
| 2 | createTheme({ global }) |
ELB's token package (@v4/tokens-euro-league) |
Whole library |
| 3 | createTheme({ components }) |
Per-component escape hatch | One component's contract |
| 4 | Raw CSS custom properties | Non-TS consumers | Any subtree |
| 5 | Per-instance tokens prop |
A single component instance | One instance |
Brand work on this site is almost always level 2. Levels 3–5 are escape hatches for cases the palette cannot express.
Global overrides (level 2) — the default
Everything ELB changes flows through global. Because the Component/* namespaces live
in the global contract, this one object re-skins palette and component-owned colours and
radii:
// the app's form: the global layer declared once on the root (apps/web/src/theme/tokens.css.ts)
globalTheme.createScoped(":root", tokens.global); // re-skins every package at once
You do not enumerate CSS variables — @v4/tokens-euro-league supplies ELB's whole re-skinned
palette (the tokens.global object it builds), and every component that binds a contract path
picks up the new value through var(--ph__global-*).
Colour-set overrides
Colour sets are part of the global palette, so ELB re-skins a set by supplying its leaves
in global. Redefining colourSets.base2 re-themes every component rendered under
colourSet="base2" across the whole app — you change the set once, not each component:
// conceptually, inside @v4/tokens-euro-league's global object
global: {
colourSets: {
base2: {
background: { main: "#0a0a0a" },
text: { main: "#ffffff", supporting: "#c8c8c8" },
// ... the rest of the set's leaves
},
// colourSet1–colourSet6 likewise
},
}
Remember the flattening rule from The token package: the export must restate every leaf it wants changed, because the generated contract holds literals, not references. Changing a primitive alone changes nothing downstream.
Component overrides (level 3) — escape hatch
When a value genuinely cannot be expressed through the palette, createTheme({ components })
overrides one component's own contract, keyed by its component-theme name:
createTheme({
components: { button: { borderRadius: "16px" } },
});
These cannot be generated from a Token Studio file (they are shaped like the component's
private token tree), so they are hand-authored and reviewed. Prefer level 2 — ELB's data
belongs in global. @v4/tokens-euro-league ships components: {}.
Raw CSS custom properties (level 4)
Every token is emitted as a --ph__{component}-* custom property, so a non-TypeScript
consumer (or a scoped app region) can set one from plain CSS:
.promo-region {
--ph__button-border-radius: 16px;
}
Per-instance overrides (level 5)
Every component takes tokens?: DeepPartial<{Component}Tokens>, applied once as inline
custom properties, to re-skin a single instance without touching the Euro League theme:
<Button
tokens={{
borderRadius: "16px",
colourSets: { base1: { enabled: { backgroundColor: "#custom" } } },
}}
/>
What level 5 cannot do: a prop is fixed at render, so it cannot follow a :hover or a
media query, and it reaches only its own component's contract — not a composed child's.
When a parent's CSS state must repaint a composed child (a card hover darkening the button
inside it), the house mechanism is a typed assignVars over the child's contract subtree in
a .css.ts — not the tokens prop. See the
composition patterns page.
Scoped themes (comparing two themes in one build)
The app emits the Euro League layer for the whole document. When you need more than one theme in one bundle — Storybook hosts the Ghost default and Euro League side by side, switched from its toolbar — emit that layer at a scoped selector instead:
// apps/storybook/.storybook/euro-league-theme.css.ts
import { clubThemeSelector, createScopedTheme } from "@v4/ghost-core/theme";
import { tokens } from "@v4/tokens-euro-league";
export const EURO_LEAGUE_THEME_CLASS = "ph__club-euro-league";
createScopedTheme(clubThemeSelector(EURO_LEAGUE_THEME_CLASS), {
global: tokens.global,
components: tokens.components,
});
clubThemeSelector matches the wrapper AND every component root that re-declares the global
theme class, so the scoped layer wins (0-2-0). The generated tokens object is identical
either way. The e2e suite pins the Ghost theme (ph-club-theme=ghost) so its assertions are
Ghost's values; the vitest story suite renders the Euro League theme, so a palette change can move
its contrast results.
The emission API
Helper (@v4/ghost-core/theme) |
Emits | Used by |
|---|---|---|
createTheme(tokens) |
every registered theme, globally | the default library (Storybook's Ghost theme) |
createClubThemePart(tokens, { global?, components }) |
the named component themes with ELB's values | the app's theme parts |
createScopedTheme(selector, tokens) |
the whole Euro League layer under a selector | Storybook's Euro League toggle |
createScopedOverrides(selector, global) |
only the global leaves given, under a selector | the competition themes |
globalTheme.createScoped(selector, global) |
the global layer under a selector | the app's :root layer |
Competition themes
The site runs three competitions — EuroLeague, EuroCup and NextGen — and each competition's pages wear its own brand. They are not three themes from scratch: EuroCup and NextGen are thin layers drawn OVER the EuroLeague layer, holding only the leaves that differ.
Where they come from
The Token Studio export carries three themes ($themes in
packages/tokens-euro-league/tokens.json). Each enables the same sets; EuroCup and
NextGen each enable one more — Brand/EuroCup, Brand/NextGen — which restates a
handful of Primitive leaves (brand colours, brand gradients, font families and
weights). In Token Studio a later set overrides an earlier one at the same path, so
every Semantic, Colour-Set and Component token that references a brand colour takes
the brand set's value in those themes.
pnpm --filter @v4/tokens-euro-league generate builds them after the base:
- The parser builds the BASE theme (EuroLeague) — the site's layer,
tokens. scripts/generate-competition-themes.mjscomposes each other theme the way Token Studio does, runs the SAME parser over it, and writes the leaves whose resolved value differs from the base's tosrc/__generated__/competition-themes.ts. It fails rather than writes when a theme cannot be composed honestly (a set the base enables that the theme does not, or a brand leaf no base set has).src/competition-themes.tsprojects those leaves onto the Ghost contract with the same adapter as the base layer, exported ascompetitionThemes(keyed by theme name,COMPETITION_THEME_NAMES).
A design change to a competition's brand is therefore a Token Studio push to
tokens.json followed by generate — never a hand edit, and never sync-tokens
(The token package).
How a page wears one
apps/web/src/theme/competition-themes.css.ts emits each layer with
createScopedOverrides at a class on the root element:
for (const [slug, name] of Object.entries(COMPETITION_THEMES)) {
createScopedOverrides(`:root.${competitionThemeClass(slug)}`, competitionThemes[name].global);
}
createScopedOverrides declares only the given leaves and inherits the rest, where
createScopedTheme would re-declare the whole global layer (~6,000 properties). At
:root.<class> (0-2-0) it sits over the base layer's :root declaration.
COMPETITION_THEMES (apps/web/src/lib/competition-theme.ts) maps a competition's
Contentful SLUG to its theme name — eurocup → EuroCup, nextgen → NextGen — and
competitionThemeClass(slug) gives the class (ph__competition-<slug>). The root
layout puts it on <html>:
- By address — a page in a competition's section (
/eurocup/…,/news/eurocup,/teams/eurocup,/video/eurocup) wears that competition's layer (sectionFromPath). - By the page — a page with one address for every competition (a team, player,
coach, game or article page) declares its own competition by rendering
<PageCompetition slug={…} />from@v4/content-app/page-competition. - Otherwise the base layer: EuroLeague is the base layer itself and has no entry, and a section with no theme (Corporate) wears it too.
A competition added in the CMS wears EuroLeague's layer until it is added to
COMPETITION_THEMES — there is no theme for it to wear until the export has one.
Adding one
- Design adds a
Brand/<Competition>set and a theme to the Token Studio file and pushes it. pnpm --filter @v4/tokens-euro-league generate; review the generated diff.- Add the slug → theme name to
COMPETITION_THEMES. - Check a page in the section and a team page set to that competition in the browser — the header in particular, which the root layout paints before the page streams in.