Theming and colour sets
The colour-set theming axis, the competition themes, and how the Euro League theme is emitted in parts.
Colour sets
The components follow the Ghost 2.0 colour model. A global Core Palette (Primary / Secondary /
Neutral ramps with generated Tints and Shades, System colours in light and dark, and
Gradients) feeds ten colour sets — the canonical ramp COLOUR_SETS in
packages/ghost-core/src/types.ts. A colour set is a rich, self-contained surface
definition — typography, links, backgrounds, borders, fills, buttons, form fields, tabs
and page navigation all specified together.
| Code key | Figma name | Purpose |
|---|---|---|
default |
ColourWay=Default |
The component's standard (light) way |
inverse |
ColourWay=Inverse |
The component's dark way |
base1 |
Colour Set=Base 1 |
Default light surface |
base2 |
Colour Set=Base 2 |
Alternate / inverted surface |
colourSet1–colourSet6 |
Colour Set=CS 1–CS 6 |
Editorial / brand section themes |
Colour sets, not onDark
There is no onDark boolean prop. The colour set is the first-class theming axis: a themeable component takes colourSet?: ColourSet
(defaulting to that component's own default — often default, sometimes base1) and maps it
to a class. Dark surfaces are simply colour sets — inverse, base2 or a dark brand set —
not a boolean flag.
// A themeable component takes the set, never an onDark flag
<Badge colourSet="inverse" {...props} />
<PromoBlockSimple colourSet="colourSet3" {...props} />
Most components support only a SUBSET of the ten — many just default / inverse, which
is the ELB ColourWay axis — and the polarity of a way is per component, not global.
Always read the component's own supported subset (its .types.ts narrows ColourSet with
Extract<…>) rather than assuming it takes all ten.
Unknown sets degrade, never break
vanilla-extract recipes silently drop an unknown variant value, which would render a
component unstyled. Any component supporting only part of the ramp resolves an
unsupported value back to its default set before styling. Storybook's colour-set toolbar
forces arbitrary sets onto every story, so this path is exercised constantly — and an
AllColourSets story per themeable component keeps every supported surface visible in the
sidebar.
Competition themes: a second, page-level axis
Colour sets theme a SURFACE. The site also themes a whole PAGE by competition. EuroLeague
is the base layer itself; on the EuroCup and NextGen section pages (/eurocup/…,
/news/eurocup, /teams/eurocup, /video/eurocup and so on) the root layout adds
ph__competition-<slug> to <html> and that competition's Token Studio theme is declared
over the base layer on :root.ph__competition-<slug> — only the leaves that differ,
typography included. A team's, player's, game's or article's own page has one address for
every competition and wears the base layer unless it declares its competition. Which slug
maps to which theme is COMPETITION_THEMES in apps/web/src/lib/competition-theme.ts; a
competition added in the CMS wears EuroLeague's until it is named there. A component needs
no prop for any of this — it reads the same contract variables, which the root class
re-points.
Euro League's theme
Because every token is an overridable contract, ELB's brand re-skins the entire component
library through one tokens package, @v4/tokens-euro-league. The app applies it in
parts, so each route loads only the component themes it renders.
The Euro League tokens package
packages/tokens-euro-league is this project's own. Its tokens.json is ELB's Token Studio
export, projected onto the Ghost contract by an adapter (src/aliases.ts, src/adapter.ts); the brand faces are self-hosted OTFs in
apps/web/public/fonts, declared in src/theme/fonts.css.ts. Values are generated by
@v4/token-parser (pnpm --filter @v4/tokens-euro-league generate) — including brand Tints
and Shades derived by reference-plus-modifier rather than literal hex — and never
hand-tuned.
Token Studio now pushes tokens.json straight to this repo. Do not run sync-tokens:
it fetches the older stadionHQ/elb-web-tokens copy and would revert the palette and the
competition themes. Run generate after every push. The same generate writes the EuroCup
and NextGen competition themes (src/__generated__/competition-themes.ts).
The theme is emitted in parts
// apps/web/src/theme/tokens.css.ts — loaded by the [locale] layout, so 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";
globalTheme.createScoped(":root", tokens.global);
createClubThemePart(
{ global: tokens.global, components: tokens.components },
{ components: [...CORE_THEMES, ...CONTENT_THEMES] },
);
Part (apps/web/src/theme/) |
Emits | Loaded by |
|---|---|---|
tokens.css.ts |
the global layer on :root; ghost-core + ghost-content |
the [locale] layout (every page) |
tokens-home-hero.css.ts |
the home hero widgets' basketball themes | the [locale] layout |
tokens-video.css.ts |
ghost-video | the /video layout and the data-area layouts |
tokens-basketball.css.ts |
ghost-basketball | the data-area layouts (games, teams, players, …) |
competition-themes.css.ts |
EuroCup / NextGen over the base layer, on :root.<class> |
the root layout, per section page |
Three rules follow:
- The global layer is declared once, on
:root, and inherited —globalTheme.createScoped(":root", …), not at the theme class every component root carries. Declared per element it re-declared ~7,600 properties on hundreds of elements. Competition layers sit on:root.<class>, and the page measure's gutter is declared on:roottoo. - A route that renders a package must load that package's part. Without it the
components declare no
--ph__*variables and render unstyled — with nothing failing. Check in the browser that everyph__*-themeclass a page uses has declarations in that page's stylesheets. components(level 3) is empty by design: ELB's data flows throughglobal, which includes the Token StudioComponent/*namespaces, so component-owned colours and radii re-skin there too.
Emission is owner-keyed, and in the app Euro League's layer is the one emitter
The theme registry is a module singleton shared across vanilla-extract compile units, so
each package's init.css.ts names the themes it owns (OWN_THEMES) and calls
createPackageTheme(OWN_THEMES); @v4/ghost-core alone owns the global layer. Ownership
is stated at the emission site rather than inferred from evaluation order, and three guards
hold it: an unregistered name throws at CSS-compile time, check-dist /
check-theme-owners.mjs fail the build when the list and the theme modules disagree, and
apps/e2e/tests/shared/theme-emission.e2e.ts asserts a component from every package computes
real values with each layer emitted exactly once.
In the app, apps/web/next.config.ts sets PH_THEME_EMISSION=club: createPackageTheme
then only registers (still validating its list) and the theme parts above are the single
emitter. Storybook and the e2e never set it — there each package emits its own themes. The
component stylesheets are precompiled in each package's build, so a stylesheet or
.theme.ts must never reach a package barrel. The full ruling is in
.claude/rules/frontend/theme-variant-patterns.md.
Storybook and the test harnesses
Storybook applies the Euro League theme by default (the Euro League layer scoped to the
story canvas, apps/storybook/.storybook/euro-league-theme.css.ts, with a toolbar to switch).
The Playwright e2e pins Ghost: its storageState sets localStorage["ph-club-theme"] = "ghost" before the page loads, so it asserts the Ghost 2.0 contract. The vitest story suite sets the same flag in .storybook/vitest.setup.ts but still renders the Euro League theme — the preview reads the flag when it loads, before the setup runs (measured 29 Sep 2026: a story resolving g.primitive.color.primary["1"] rendered ELB's #FA5500, not Ghost's #1976DC). So an ELB palette change moves story-suite colour and contrast results, and the e2e's do not.
Bind the contract, never raw tokens
A component theme binds global-contract paths (g.components.core.*,
g.components.content.*, g.components.basketball.*), never raw tokens.* imports, so
@v4/tokens-euro-league's global tree re-points them. The only theming file that imports raw tokens is
packages/ghost-core/src/theme/default.theme.ts.