Accessibility
Checked by the build, not by eye
Every colour token that is ever text or a control boundary declares what it must be legible against and at what ratio. The token build measures each pair and fails if one falls short. Text targets 4.5:1 and controls and focus rings 3:1 (WCAG AA), in both themes. Below: what the site shipped with, and what it measures now.
Before
Five pairs the old palette failed
| Pair | Ratio | Needed | Result |
|---|---|---|---|
| light accent text (#00aa2e) on the page | 2.84:1 | 4.5:1 | fail |
| white on the light accent button | 3.09:1 | 4.5:1 | fail |
| light pending (#ff8800) on the page | 2.20:1 | 4.5:1 | fail |
| dark field border (#4a4a4a) on a card | 1.96:1 | 3:1 | fail |
| light field border (#d0d0d0) on white | 1.54:1 | 3:1 | fail |
The fix was two new primitives, a darker green and a darker amber for the light theme, and a lighter grey for control borders in both. Dark text colours already passed. The dark theme's look did not change.
After
40 declared pairs, all passing
dark 24 pairs
| Token | Against | Colours | Ratio | Needed | Grade |
|---|---|---|---|---|---|
color.text.primary | surface.page | Aa #e0e0e0 / #0d0d0d | 14.72:1 | 7:1 text | AAA |
color.text.primary | surface.raised | Aa #e0e0e0 / #1a1a1a | 13.18:1 | 7:1 text | AAA |
color.text.secondary | surface.page | Aa #c0c0c0 / #0d0d0d | 10.68:1 | 4.5:1 text | AAA |
color.text.secondary | surface.raised | Aa #c0c0c0 / #1a1a1a | 9.57:1 | 4.5:1 text | AAA |
color.text.muted | surface.page | Aa #a0a0a0 / #0d0d0d | 7.43:1 | 4.5:1 text | AAA |
color.text.muted | surface.raised | Aa #a0a0a0 / #1a1a1a | 6.66:1 | 4.5:1 text | AA |
color.text.muted | surface.sunken | Aa #a0a0a0 / #252525 | 5.86:1 | 4.5:1 text | AA |
color.text.accent | surface.page | Aa #16ff9a / #0d0d0d | 14.63:1 | 4.5:1 text | AAA |
color.text.accent | surface.raised | Aa #16ff9a / #1a1a1a | 13.10:1 | 4.5:1 text | AAA |
color.text.link | surface.page | Aa #16ff9a / #0d0d0d | 14.63:1 | 4.5:1 text | AAA |
color.text.link | surface.raised | Aa #16ff9a / #1a1a1a | 13.10:1 | 4.5:1 text | AAA |
color.text.on-accent | accent.fill | Aa #000000 / #16ff9a | 15.80:1 | 4.5:1 text | AAA |
color.accent.edge | surface.page | Aa #16ff9a / #0d0d0d | 14.63:1 | 3:1 ui | AAA |
color.accent.edge | surface.raised | Aa #16ff9a / #1a1a1a | 13.10:1 | 3:1 ui | AAA |
color.border.control | surface.page | Aa #767676 / #0d0d0d | 4.28:1 | 3:1 non-text | AA large / non-text only |
color.border.control | surface.raised | Aa #767676 / #1a1a1a | 3.83:1 | 3:1 non-text | AA large / non-text only |
color.border.focus | surface.page | Aa #16ff9a / #0d0d0d | 14.63:1 | 3:1 non-text | AAA |
color.border.focus | surface.raised | Aa #16ff9a / #1a1a1a | 13.10:1 | 3:1 non-text | AAA |
color.status.win | surface.page | Aa #16ff9a / #0d0d0d | 14.63:1 | 4.5:1 text | AAA |
color.status.win | surface.raised | Aa #16ff9a / #1a1a1a | 13.10:1 | 4.5:1 text | AAA |
color.status.loss | surface.page | Aa #ff4444 / #0d0d0d | 5.70:1 | 4.5:1 text | AA |
color.status.loss | surface.raised | Aa #ff4444 / #1a1a1a | 5.11:1 | 4.5:1 text | AA |
color.status.pending | surface.page | Aa #ffa500 / #0d0d0d | 9.84:1 | 4.5:1 text | AAA |
color.status.pending | surface.raised | Aa #ffa500 / #1a1a1a | 8.81:1 | 4.5:1 text | AAA |
light 16 pairs
| Token | Against | Colours | Ratio | Needed | Grade |
|---|---|---|---|---|---|
color.text.primary | surface.page | Aa #1a1a1a / #f5f5f5 | 15.96:1 | 7:1 text | AAA |
color.text.primary | surface.raised | Aa #1a1a1a / #ffffff | 17.40:1 | 7:1 text | AAA |
color.text.secondary | surface.page | Aa #333333 / #f5f5f5 | 11.59:1 | 4.5:1 text | AAA |
color.text.secondary | surface.raised | Aa #333333 / #ffffff | 12.63:1 | 4.5:1 text | AAA |
color.text.muted | surface.page | Aa #666666 / #f5f5f5 | 5.27:1 | 4.5:1 text | AA |
color.text.muted | surface.raised | Aa #666666 / #ffffff | 5.74:1 | 4.5:1 text | AA |
color.text.muted | surface.sunken | Aa #666666 / #e8e8e8 | 4.69:1 | 4.5:1 text | AA |
color.text.on-accent | accent.fill | Aa #000000 / #16ff9a | 15.80:1 | 4.5:1 text | AAA |
color.accent.edge | surface.page | Aa #007a20 / #f5f5f5 | 5.06:1 | 3:1 ui | AA |
color.accent.edge | surface.raised | Aa #007a20 / #ffffff | 5.52:1 | 3:1 ui | AA |
color.border.control | surface.page | Aa #8a8a8a / #f5f5f5 | 3.17:1 | 3:1 non-text | AA large / non-text only |
color.border.control | surface.raised | Aa #8a8a8a / #ffffff | 3.45:1 | 3:1 non-text | AA large / non-text only |
color.status.loss | surface.page | Aa #cc0000 / #f5f5f5 | 5.40:1 | 4.5:1 text | AA |
color.status.loss | surface.raised | Aa #cc0000 / #ffffff | 5.89:1 | 4.5:1 text | AA |
color.status.pending | surface.page | Aa #a85a00 / #f5f5f5 | 4.67:1 | 4.5:1 text | AA |
color.status.pending | surface.raised | Aa #a85a00 / #ffffff | 5.09:1 | 4.5:1 text | AA |
Beyond colour
What the primitives guarantee
- Never colour alone. Outcomes are a glyph plus a word. Selection has a check mark. Edge signs are a glyph, a colour and hidden text. Errors are text.
- Keyboard. One focus ring for everything, visible on keyboard focus only. Segmented controls are radiogroups with arrow keys. Wide tables scroll inside a focusable region. Disabled buttons stay in the tab order and say why.
- Labels. A control cannot exist without a Field; clicking the label focuses it; hints and errors are read with it.
- Targets. Controls are 40px tall by default and never under 24px (WCAG 2.5.8).
- Text size. Anything typed into is 16px, so iOS does not zoom on focus.
- Motion. Durations are tokens, so
prefers-reduced-motioncollapses all of them in one rule; the same query removes the two movements the system has (press scale, tooltip rise) and keeps the fades. - Long strings. Addresses truncate in the middle, keep the full value for assistive tech, and break anywhere rather than overflow when shown whole.