Colour, computed

Design Tokens Case Study

Client
Broadcast graphics software company
Role
Design Systems Engineer
Year
2026

A palette built on numbers that ignore perception will lie to you — and a design system is a machine for trusting numbers.

Overview

The product is a dark-first control-room application — the software that runs playout channels, graphics engines and on-air state. Its design system had 106 colour tokens and no logic connecting them.

v2 replaces that with nine anchor colours, eleven generated scales, 48 semantic tokens, and a contrast gate that runs on every build. A light theme ships alongside the dark one for the first time.

Role

I owned the colour rebuild end to end: the audit, the generation model, the semantic vocabulary, the CI contract and the migration path.

Most of the work is not colour selection. It is deciding what a token is allowed to know, and then making the correct choice the cheapest one for a developer to make.

The warm-up I open every colour class with

Which bar is brighter? Everyone says yellow — and everyone is right. But HSL, the model most design tools still hand you, assigns both bars the same lightness: 50%. Measured luminance puts them almost 13× apart.

That gap is the whole case study in one figure. If the numbers a system reasons about don't match what an eye meters, every downstream guarantee is decoration.

Warm-up — which bar is brighter?
hsl(240 100% 50%)
hsl(60 100% 50%)
what your eye meters
what your eye meters

HSL says 50% = 50%. Your eye says 12.9× apart. OKLab agrees with your eye: L 0.45 against 0.97.

Pick a space that keeps its lightness promise

Same trick, whole spectrum. Both maps plot every hue left to right against “lightness” bottom to top — in colour, then metered to grayscale.

HSL fills a perfect rectangle, but meter it and the surface buckles: yellow and cyan crest bright, blue and red sink dark, at the same L. OKLCH is honest twice over — its metered field is a clean top-to-bottom ramp, and it refuses to draw what sRGB can't show. The blank arches are real gamut limits. Every palette decision in this project lives on the right-hand map.

HSL — hue →, “lightness” ↑
…metered: the surface buckles
OKLCH — hue →, lightness ↑ · blank = outside sRGB
…metered: height finally means brightness

Rendered live from the raw math, not illustrated. Equal L in OKLCH is equal perceived lightness in every hue.

The palette nobody could explain

The system I inherited had 106 colour tokens and no rule connecting them. Colours had been added one wish at a time. Exactly one hue had a muted companion scale — blue, and nobody remembered why. The primary colour existed only as a button token, so using it anywhere else meant writing button-background on things that were not buttons.

Developers, quite reasonably, gave up: production code was full of color: gray and hand-typed hexes.

The source of truth was Figma — except Figma can't express half of what a real token system needs (composite values, translucent washes, derived colours), so the actual truth was whatever the code did. The export plugin had been quietly abandoned. A source of truth nobody consults isn't a source of anything.

And the audit receipts were worse than the vibes. Five shipped component recipes failed WCAG AA outright — not edge cases, but the primary button, the danger button and the border of every form control.

Shipped recipes failing WCAG AA at audit
RecipeMeasuredRequiredWhat it was
Primary fill · #4666fd3.6 : 14.5 : 1White label on the old action blue
Danger fill · #e53d2e3.45 : 14.5 : 1Hover state dropped it to 2.99 : 1
Input border · white @ 24%2.18 : 13 : 1The boundary of every form control

“Just invert the scale”

Then the product needed a light theme, and the first instinct was the obvious one: flip the ramp. Here is that instinct, applied literally.

The grays land somewhere plausible — which is why everyone tries this — but the physics comes out backwards. In dark, elevation means getting lighter, so the mirrored theme makes cards darker than the page: everything reads sunken instead of raised, where a designed light theme goes to white and picks up a shadow. And anchored colours slide to the wrong end of their own ramp — the primary button drifts from Blue.700 to Blue.300 and its white label collapses to 1.9 : 1.

v1 · dark (the real thing)
Render queue
4 clips waiting
Render
same colors, scale flipped
Render queue
4 clips waiting
Render
v2 · light (designed)
Render queue
4 clips waiting
Render

Same colours, opposite physics. The middle panel is the mirrored theme; the right one is designed.

Inverting a scale gives you an inverted theme, not a light theme.

The failure isn't in the colours — it's in what the tokens know. gray-700 can't survive a theme flip because it doesn't say what it's for. That request is what finally justified rebuilding the system from the semantics down.

Nine colours are chosen. The rest are consequences.

Instead of hand-picking eleven scales × thirteen steps, the new system pins nine anchor colours — the ones we already trusted: the brand orange (pinned exactly, it never drifts), the action blue and danger red (darkened until white text passes on them), the status hues, the categorical seeds.

Every other colour is generated in OKLCH: lightness from a fixed ladder shared by every scale, chroma from a Gaussian that peaks mid-scale, hue drifting linearly so yellow warms toward amber as it darkens. Out-of-gamut results give up chroma, never lightness.

The system doesn't decide where a colour sits — the colour's own lightness does. Yellow measures L 0.90, so it pins at step 150. Blue measures 0.52, so it pins at 700. All nine anchors sit on the ladder rung nearest their measured lightness.

The nine anchors, with the ladder step each one landed on. The system doesn't decide where a colour sits — the colour's own lightness does.

hover a step…

Five of the eleven generated scales — 151 core tokens in total. Median drift from the previous hand-picked values: ΔE 0.9. The generator reproduced what taste had already found, then kept going where taste ran out. ⚓ marks the anchor, pinned byte-exact.

Values are negotiable. Contrast isn't.

  • 189 sanctioned pairings verified per build, both themes
  • 4.5 / 3 minimum ratio — text / non-text marks (WCAG AA)
  • 3 light-theme tokens moved a step darker because the gate said so
  • 1 documented exception (yellow), banded — not blind

Before any new colour existed, I set the rule that would govern all of them. The designers' first worry about a rebuilt palette was the honest one: “how do we make sure all the new combinations keep contrast?”

My answer was to make contrast the system's contract — not a checklist at the end, but the thing every other decision has to satisfy. Every sanctioned foreground × background pairing is recomputed and asserted on every single build. If a colour change breaks a pairing, CI fails and the build does not ship.

Two consequences I care about. First, drift is policy, not accident — hex values are allowed to change when the gate demands it, because the contract is the ratios, not the numbers. Second, exceptions are engineered: yellow physically cannot reach 3:1 on light surfaces, so its one exemption is a band, and below 1.5:1 is still a build failure. An exception with a floor is a rule; an exception without one is a hole.

Plot everything. Kill the nonsense. Keep the proof.

So how do nine anchors and a contract become a shipped palette? Not in one pass. The first anchor candidates were simply the colours we already had; the generator proposed everything else; and this grid told us what actually held — every foreground the system offers against every background, computed with the same math the CI gate runs.

Plenty passes. Plenty fails. And a lot of it is semantic nonsense that happens to pass — status text sitting on a status fill, muted text on a selection wash. Contrast math can't see nonsense. That part needs a designer, walking it cell by cell, tweaking anchors and banning combinations until every visible cell clears the bar.

Round 0Plot every combination

Everything against everything. Plenty passes; plenty fails; and a lot of it is semantic nonsense that happens to pass — status text sitting on a status fill, muted text on a selection wash. Contrast math can't see nonsense. That part needs a designer.

Rounds 1–nDecisions become ∅
Status fills take inverse text only — one row survives for statuses; every other text style on them is banned, not just discouraged.
Primary and danger fills take emphasis text only. If white doesn’t pass on the fill, the fill changes — that’s what darkened both anchors.
The emphasis surface is mode-agnostic: near-black in both themes, always white text. Some colors shouldn’t flip.
No borders on action or status fills. Muted text is banned from washes and insets it can’t survive.
ShippedEvery visible cell ≥ AA, enforced per build

Every remaining cell clears its bar, in both themes, forever — because the gate re-derives this exact grid on every build. (Grid shown for the dark theme, first 14 foregrounds; the full gate also covers the light theme and eight categorical rows — 189 live pairings.)

Four rules the grid produced

Status fills

Status fills take inverse text only. One row survives for statuses; every other text style on them is banned, not merely discouraged.

Action fills

Primary and danger fills take emphasis text only. If white doesn't pass on the fill, the fill changes — that is what darkened both anchors.

Some colours shouldn't flip

The emphasis surface is mode-agnostic: near-black in both themes, always white text.

Borders and washes

No borders on action or status fills. Muted text is banned from the washes and insets it cannot survive.

Tokens that mean something

The generated scales are raw material; nothing in the product touches them directly. Components speak a smaller language — background, foreground, border — where every name answers a question a component actually asks. And because the vocabulary is small enough to hold in your head, I can show all of it. This isn't a sample; it's the entire colour system.

gray-700 tells you nothing. surface/raised tells you what it's for, and each theme supplies its own physics. The light theme itself ships as a sparse overlay: only the names where light physics genuinely differs get re-pointed.

tokendarklight

The complete v2 colour vocabulary — 50 semantic tokens, both themes resolved from the same names. Rows marked = are deliberately mode-agnostic.

The complete v2 vocabulary — 48 semantic tokens, both themes resolved from the same names. Rows marked = are deliberately mode-agnostic.

The org this ships into is developer-heavy and designer-light. Semantic tokens are how design survives that ratio — the system makes the correct choice the easy choice, because there won't always be a designer in the review.

Half the palette is one ladder of twelve opacities

Look back at that table: text/*, border/*, state/*, the inset, the backdrop — most of the vocabulary isn't colours at all. It's white or black at a step of a single opacity ladder: twelve named steps, all multiples of 8. The polarity flips with the theme; the numbers never do.

Why opacity instead of picked grays? Because opacity composes. A gray chosen for one surface is subtly wrong on every other surface; a wash is right on all of them. And the ladder names jobs, not amounts — Pressed and Tint both sit at 16%, Outline and Backdrop at 48%. Same quantity, different meaning: either can move later without a breaking change, because nothing ever referenced the number.

valuestepon darkon light

v1 — 12 tokens for 4 buttons

every state hand-picked, drift guaranteed

v2 — 4 tokens + shared washes

hover / pressed computed · try them, watch the math
hover a button…

Twelve steps, all multiples of 8, named for what they do rather than what they measure.

State isn't a colour — it's arithmetic. Hover is the 8% wash over whatever you're on, pressed is 16%. Four button colours used to need twelve hand-picked hexes; now they need four.

The wash idea has a bonus the gate loves: state contrast becomes monotonic by construction. The primary button's label runs 4.9 : 1 at rest → 5.5 on hover → 6.2 pressed. Interaction makes text more readable, never less.

Every old name still works. Every old colour is gone.

A palette replacement that breaks the downstream apps doesn't ship, no matter how pretty the ramps are. So the tokens became a versioned npm package apps can pin, and v2 keeps every v1 name alive as a deprecated alias pointing at its new value.

Consumers upgrade and their UI changes colour without breaking — then they migrate names on their own schedule, before v3 removes the aliases.

The migration, in three versions
VersionWhat it isWhat it means for consumers
v1106 hand-picked tokens, Figma as the nominal source of truthDark theme only. Five AA failures in shipped components. Component-named tokens — button-background as the only primary.
v248 semantic tokens, generated core, code as source of truth134 old token paths live on as aliases: same names, new colours, zero breaking changes. 35 component families migrated. The contrast gate runs in CI. Figma becomes an export target, not an origin.
v3Aliases removedThe deprecation window closes after every consumer has had a full major version to move.
  • 106 → 48 colour tokens, after deleting the component layer
  • 134 v1 paths preserved as deprecated aliases
  • 35 component families migrated to semantic bindings
  • 0 breaking changes for consumers at v2.0

Why it matters

Brand colours are designed for billboards. Product colours have to survive a disabled button, on a hover state, in dark mode. The interesting work is making one palette do both.

Every company I've worked at has had two design languages: the brand on the website, and whatever framework default the product fell back to when the brand colours turned out to be an accessibility minefield.

This rebuild is my answer to that split — take the colours a brand actually loves, anchor them, and let a perceptual colour space and a CI contract carry them everywhere the brand team never had to think about.

All values, ratios and counts on this page are real; the company is anonymised. Palette generation: OKLCH — fixed lightness ladders, Gaussian chroma, linear hue ramps. Verification: WCAG 2.1 AA per build, plus a colour-vision-deficiency check (OKLab ΔE under protan and deutan simulation).