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.

2026-11-07

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:

  1. createGlobalTheme validates the token object against the contract. A path the contract does not declare throws; a path ELB omits silently keeps the Ghost default.
  2. Aliases are already flattened. Overriding primitive.color.primary.1 changes 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:

  1. The parser builds the BASE theme (EuroLeague) — the site's layer, tokens.
  2. scripts/generate-competition-themes.mjs composes 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 to src/__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).
  3. src/competition-themes.ts projects those leaves onto the Ghost contract with the same adapter as the base layer, exported as competitionThemes (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

  1. Design adds a Brand/<Competition> set and a theme to the Token Studio file and pushes it.
  2. pnpm --filter @v4/tokens-euro-league generate; review the generated diff.
  3. Add the slug → theme name to COMPETITION_THEMES.
  4. 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.