Theming and colour sets

The colour-set theming axis, the competition themes, and how the Euro League theme is emitted in parts.

2026-11-07

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 :root too.
  • 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 every ph__*-theme class a page uses has declarations in that page's stylesheets.
  • components (level 3) is empty by design: ELB's data flows through global, which includes the Token Studio Component/* 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.