The old pages-ui-tokens system generated themes at runtime from four numbers: base hue, accent hue, chroma, contrast. Call generateThemeCSS(config) in the browser, get 72 colour tokens. Simple. But it couldn’t express “CaseHub brand” vs “pages generic” — one ThemeConfig, two modes, no named themes. And every app ran the same generation code to produce identical output.

The replacement treats themes as programs, not configurations.

Pipelines, not parameters

A theme is an ordered list of transforms. Each transform is a pure function: TokenMap → TokenMap. The pipeline runner feeds an initial map (spacing, typography, motion, radius) through the transforms in sequence, and each transform sees the accumulated result of everything before it.

{
  "$name": "casehub-dark",
  "$extends": "default-dark",
  "pipeline": [
    { "transform": "oklch-scale", "params": { "hues": { "violet": 270, "green": 160, "magenta": 320 }}},
    { "transform": "lightness-shift", "params": { "offset": 10 }},
    { "transform": "chroma-curve", "params": { "curve": "gaussian", "neutral": 0.02 }},
    { "transform": "semantic-hues", "params": { "success": 175, "warning": 100 }},
    { "transform": "semantic-map" },
    { "transform": "gamut-clamp" }
  ]
}

casehub-dark extends default-dark. The $extends mechanism concatenates pipelines — the parent’s transforms run first, then the child’s append. The child adds brand hue scales (violet, green, magenta), shifts lightness brighter by 10 points, crushes neutral chroma to 2% of base (nearly achromatic), overrides success/warning hues for the CaseHub palette, maps everything to semantic role tokens, and clamps out-of-gamut values to sRGB.

This is append-only by design. A child cannot remove or replace a parent transform. If you need fundamentally different pipeline structure, don’t extend — write a new base preset.

Why this shape and not Style Dictionary’s

Style Dictionary is the dominant design token tool. It has a transform pipeline too — but the transforms are per-token (name transforms, value transforms, attribute transforms) and the pipeline’s job is platform adaptation: take a static token value and format it for CSS, iOS, Android.

Our transforms are full-map: TokenMap → TokenMap. Each transform sees the entire accumulated state. This matters because some operations require cross-token reasoning. contrast-check needs to see both a text token and its background token together to compute APCA contrast. semantic-map reads every primitive scale to generate role tokens that reference them. A per-token transform can’t do either of those.

The other difference is generative vs adaptive. Style Dictionary transforms static values into platform formats. Our oklch-scale transform doesn’t look up a colour value — it computes 12 perceptually uniform lightness steps from a hue angle, applying chroma reduction at extremes where the sRGB gamut narrows. The pipeline generates tokens algorithmically, then subsequent transforms shape the result.

OKLCH and the 12-step perceptual scale

OKLCH is a perceptually uniform colour space. Equal changes in the L coordinate produce equal perceived changes in lightness. This is why algorithmic colour generation works here — you can set 12 lightness targets and be confident that step 3 “feels” the same visual weight across all hues.

The chroma reduction at extremes is principled, not eyeballed:

Clamped lightness Chroma multiplier
> 90% or < 15% 0.3×
81–90% or 15–24% 0.6×
25–80% 1.0×

At extreme lightness values, the sRGB gamut boundary contracts — high chroma at near-white or near-black pushes values out of gamut. The multiplier prevents this. And because the multiplier keys off the lightness value after contrast adjustment (not the step index), the same step can produce different chroma depending on mode and contrast settings.

The gamut-clamp transform runs last as a safety net. It uses CSS Color Level 4’s binary-search chroma reduction: for each out-of-gamut value, reduce chroma toward zero while preserving lightness and hue. Same algorithm browsers use internally.

Three tiers of tokens

The real payoff of the pipeline is the three-tier token architecture:

Tier 1 — Primitives. Generated by oklch-scale. Named by hue: --pages-neutral-1 through --pages-neutral-12, --pages-accent-*, --pages-violet-*. These are the raw perceptual scales.

Tier 2 — Roles. Generated by semantic-map. --pages-surface-primary, --pages-text-muted, --pages-interactive, --pages-status-success. Each role token is a var() reference to a primitive: --pages-surface-primary: var(--pages-neutral-1).

Tier 3 — Component. App-level aliases. Outside the theme system’s scope.

The role tier is the abstraction that makes themes portable. Before this, components referenced neutral-3 directly. Switching themes meant auditing every component to check whether neutral-3 still works as a hover background in the new palette. Now components use surface-hover, and each theme maps that to whatever primitive step fits its aesthetic.

Build-time, not runtime

The pipeline runs at build time. pages-tokens build generates four CSS files and four DTCG JSON files — one per preset. The browser runtime is thin: applyTheme('casehub-dark') injects a pre-built CSS string and sets a class. No colour science, no pipeline logic, no token generation in the browser.

The old two-line consumer setup:

injectTheme(DEFAULT_THEME);
applyThemeMode(document.documentElement, 'dark');

Becomes one line:

applyTheme('casehub-dark');

The <pages-theme-picker> component handles switching — a dropdown for theme family (Default, CaseHub) and a toggle for light/dark mode. It reads from listThemes(), reflects with getTheme(), and applies via applyTheme(). Components that reference role tokens update instantly because CSS custom properties cascade.

The pipeline architecture means custom brand themes are a JSON file that extends a base preset. No fork, no code changes. The transform library is the foundation for a standalone designer tool — same pipeline, browser UI, real-time parameter adjustment.


<
Previous Post
The glue that was always missing
>
Blog Archive
Archive of all previous blog posts