Color
Scout Blue over a cool slate ramp, with neutral grey for text. Every value below is measured against its intended ground, and the measurement is part of the token's definition — not a check someone did once.
Layer 1 — primitives
Declared outside @theme, so they generate no utilities. No component may reference these names. Ratios are on #ffffff.
theme.css and tokens.css both annotate #21b2fc as 2.11:1. Measured, it is 2.38:1 — the checker below agrees. The decision is unaffected (it fails 3:1 either way, and stays decorative-only), but the comment should be corrected at source so nobody re-derives a rule from a wrong number.
Layer 2 — semantic
The only names you write. Named by role, not by shade — which is why surface and surface-raised are two tokens even though they are identical in light mode.
surfacesurface-mutedsurface-raisedsurface-sunkensurface-inversetext-primaryHeadlinestext-secondaryBody copytext-mutedCaptionsborderborder-strongborder-focusAction vs commitment
The single most-misapplied rule in the system. --color-primary is not the primary button.
--color-primary- Links and text buttons
- The focus ring
- Category icons
- Select item indicators
--color-ctaButton variant="primary"- The selected Rating chip
- Step markers
- Nothing else
This was briefly reversed on the evidence of a screenshot showing a blue primary button, then reverted the same day. If the shipping product still renders a blue primary button, that is a product divergence from the system, not a system update.
Status — fill and text are different colours
The three brand status hexes are fill colours; all three fail AA as text. Each status therefore ships a pair. -fill may never carry text; the base token may never flood a surface.
Rating scale
Five tiers plus two off-scale states. These are data colours: they belong to bar fills, tier badges and the aggregate figure. They never touch the stars.
theme.css states the rating hues are "already AA-safe as text on white". Measured, three of the five are not: very-good (3.30), good (3.45) and fair (4.04) clear the 3:1 graphics threshold but miss the 4.5:1 text threshold. This is not currently a live defect, because tintValue is off by default and the ScoreBar figure stays ink. It becomes one the moment anybody turns tintValue on. Either restrict it to excellent/poor, or darken three values.
Foreground on a subtle fill
Every -subtle token is a 10% tint of its own hue over the surface. Putting that same hue back on top at full strength is the most common contrast failure in this system: the tint has already spent part of the lightness delta the pair depends on.
The rule: a subtle fill takes ink, not its own hue. --color-text-primary on chips and badges, --color-text-secondary on banner body text. Where the tinted chip must stay blue, --color-primary-hover is the darker blue that survives the tint.
The hue itself is not the problem — --color-primary on plain white passes. It is the tint underneath that breaks it. The hue also stays correct on icons and borders inside a subtle fill, which are non-text and answer to 3:1, not 4.5:1.
Status hues need a value per theme
The subtle-fill rule above is about the ground. This one is about the hue itself: no single status colour clears 4.5:1 against both #ffffff and #121212. A status token that is declared once, on :root, is therefore broken in one of the two themes — and it will be the theme nobody measured.
| Token | Light value | on #ffffff | Dark value | on #121212 |
|---|---|---|---|---|
--color-destructive | red-700 #b91c1c | 6.47:1 | red-500 #ef4444 | 4.98:1 |
--color-warning | amber-700 #b45309 | 5.02:1 | amber-500 #f59e0b | 8.72:1 |
--color-success | emerald-700 #047857 | 5.48:1 | emerald-500 #10b981 | 7.39:1 |
Swapped, each fails: #b91c1c measures 2.90:1 on the dark ground, and #ef4444 measures 3.76:1 on white. The pair is not a preference — it is the only way one name can be legible in both themes.
The rule: every status token that carries text or a border gets a .dark value. The dark value is the -500 step, which the ramp already holds as the -fill. What does not change is -fill and -subtle: a fill is non-text at 3:1, and a 10–14% oklab tint of the same hue resolves against whatever surface is under it.
One consequence worth naming: in dark mode --color-destructive and --color-destructive-fill resolve to the same hex. That is correct, not a redundancy — the two names still mean different things, and only the light theme needs them to differ.
The exception: a solid button
Flipping the hue lighter fixes text and breaks any surface that carries white on it. White on #ef4444 is 3.76:1. So a solid destructive button uses --color-destructive-solid, which is red-700 in both themes and holds white at 6.47:1 either way — the one status token with no .dark value, deliberately.
The distinction is which side of the pair is fixed. Destructive text sits on the theme surface, so the hue must move when the surface moves. A destructive button brings its own surface, so nothing needs to move.
Contrast checker
Measures the live computed value of any two semantic tokens, including color-mix() results and the dark-mode re-points. Toggle the theme in the header and the numbers move.
Do & don't
All 412 debriefs sent.
Tone paints the 3px rule. Body copy stays in the reading colour.
All 412 debriefs sent.
A -fill flooding a surface, carrying white text at 2.57:1.
theme.css.