Tokens
Semantic tokens, and why there are no raw colours in a component.
There are two layers. Primitives are the raw ramps — blue/600,
gray/200 — and no component ever references one. Tokens are semantic:
bg/raised, text/muted, border/hairline. Every token has a light value and
a dark value, which is what lets a component stay theme-agnostic.
Surfaces
Four surfaces, and the distance between them is what makes a card read as a card.
| Token | Class | Use |
|---|---|---|
bg/canvas | bg-canvas | The page, when cards need an outline to separate them |
bg/subtle | bg-subtle | The page, when cards should separate by tone instead |
bg/raised | bg-raised | Cards, menus, anything lifted off the page |
bg/sunken | bg-sunken | Recesses — segmented tracks, progress rails |
bg/sunken exists because a segmented control on a bg/subtle page was
invisible: its track and the page were the same colour. A recess needs to be
darker than every surface it can sit on, not merely different from white.
In dark mode that inverts. You cannot cut a hole below near-black, so
bg/sunken is lighter than bg/canvas there. A recess is a change in
luminance, not a direction.
Tints
A tint is a surface that carries hue — never a block of colour. bg/brand-tint
is what a recommendation card sits on; bg/brand is what a button is made of.
They are not interchangeable, and the difference is weight.
| Token | Light | Dark |
|---|---|---|
bg/brand-tint · bg/info-tint | indigo/50 | indigo/975 |
bg/error-tint | red/50 | red/975 |
bg/success-tint | green/50 | green/975 |
bg/warning-tint | amber/50 | amber/975 |
The dark values alias a dedicated 975 step rather than 900. This is the
system's most expensive past mistake: the dark tints originally aliased the
saturated 700/900 primitives, so every "Recommended action" block and every
selected table row rendered as a solid indigo slab with near-illegible text on
it. A tint has to sit within about 1.2–1.4:1 of the canvas. indigo/900
(#2f3891) reads 3.4:1 — that is a fill.
red/950 is reserved for bg/diff-removed; do not repurpose it.
The colour contract
The rules above are enforced, not merely documented. npm test runs
tests/tokens.test.mjs
against design/tokens.json in both modes, and CI runs it before the build.
| Rule | Threshold |
|---|---|
| Body and secondary text on any surface | ≥ 4.5:1 |
| Semantic ink on its own tint, and on neutral surfaces | ≥ 4.5:1 |
text/on-brand on every filled control, including hover and pressed | ≥ 4.5:1 |
| Placeholder text | ≥ 3:1 |
| Focus ring against every surface | ≥ 3:1 |
A border that reports state (border/active) | ≥ 3:1 |
A resting border (border/default, border/subtle) | ≥ 1.15:1 |
| A tint against the canvas | ≤ 2:1 |
| Dark elevation ladder — canvas → subtle → raised → selected | strictly increasing |
| Adjacent layers that stack | never equal |
Writing these down found seven real defects that had shipped: text/error and
text/warning both missed AA on their own tints in light mode, dark-mode
bg/brand-hover put white text at 3.9:1, and dark text/placeholder sat at
2.86:1 on a raised card. Every one had been reviewed by eye and passed.
The last two rules are not accessibility rules — they are the two bugs a human
reads straight past. An active tab whose pill and track were both bg/raised
had 1.00:1 against what it sat on and was simply not there.
Ink
| Token | Class |
|---|---|
text/primary | text-fg |
text/secondary | text-fg-muted |
text/placeholder | text-fg-placeholder |
text/brand | text-fg-brand |
text/success · text/error | text-fg-success · text-fg-error |
Type
Type is a set of utilities rather than a set of props, so a heading is
t-heading and not <Text size="xl" weight="600">.
What colour is allowed to mean
Green and red mean gain and loss. Brand blue means you can act on this.
Identity hues — the tag/* family — separate one category from another and
carry no judgement.
This is why a falling number is sometimes green: a sales cycle shortening from
21 days to 19 is a down arrow with positive sentiment. Direction and
sentiment are separate props on <Delta> precisely because they disagree often
enough to matter.