Color Pairings
The accessible foreground/background pairing system — which text token is WCAG-safe on which surface, for brand and service-tier colors, in both themes, enforced by a CI gate and portable to client themes.
A token name tells you a color's role, not whether it's legible on a given background. Picking a foreground and a background independently is how contrast regressions ship — and how they get patched at the consumer layer with hand-edited HEX instead of fixed at the root. This page is the contract that ends that: for every background family, the foreground that is WCAG-safe on it is fixed, documented, and gated.
The pairing set is data — tokens/contrast-pairings.json. One source of truth feeds three surfaces: the CI gate (scripts/validate-themes.js), the Contrast Compliance Storybook dashboard, and the matrix on this page. Add a pairing there; never hardcode a pair list in a component or a consumer.
The pairing question
What foreground is AA/AAA-safe on this background?
Answered per background family — three patterns cover the system:
1. Neutral surfaces → neutral text
On --page-*, --surface-*, and --background-* (the achromatic surfaces), pair the neutral text roles:
--text-primaryfor body and headings (clears AAA on every neutral surface, both themes).--text-secondaryfor supporting copy (AA).--text-mutedfor placeholder / disabled / decorative UI (AA-large, ≥3:1 — never for body).
2. Saturated / branded fills → the on-color neutral
On a solid colored or branded fill (--background-brand-primary, and any service base hue used as a button fill), the safe foreground is the brand-agnostic on-color neutral, not a tinted text token:
--text-on-color-dark(white) on dark/saturated fills.--text-on-color-light(black) on light fills.
These are mode-invariant by design and are what Button/TabBar on-color variants consume. See Token Anatomy → color modifiers for why on-color is a complete neutral role distinct from the -on-light/-on-dark context modifiers.
3. Service-tinted surfaces → context text on the pale step
Service lines (brand/marketing/information/product/back-office → yellow/green/blue/purple/orange) are governed by ADR-011: the no-suffix token is a mode-invariant base hue (a swatch, not a foreground); readable color comes from the -on-light/-on-dark context variants.
The locked rule for text-bearing service regions:
- Use the pale
-lightestsurface step (--surface-service-{line}-light) with--text-service-{line}-on-light. This clears AAA in light on all five lines. - The mid-tone base step (
--surface-service-{line}/--background-service-{line}) is decorative — gated at AA only, for large/near-black labels, not small body copy. - For a solid service button, prefer pattern 2 (the
on-colorneutral) and verify, rather than a mid-tone fill with tinted text.
The standard
AAA (7:1) for body/small text where the surface affords it; WCAG AA is the enforced floor.
| Text | Aim | Enforced floor |
|---|---|---|
| Primary body on neutral / pale-service surfaces | AAA (7:1) | AA (4.5:1) |
| Colored brand & service text, on-color labels | AA (4.5:1) | AA (4.5:1) |
| Large/heading text, muted/decorative UI | AA-large (3:1) | AA-large (3:1) |
The gate fails any pairing below its floor. An AAA-aim body pairing that clears AA but lands under 7:1 is reported as a non-blocking "below AAA aim" note — visible, not blocking. This matches WCAG's own large-text allowance and keeps the gate honest about what it enforces.
The matrix
Measured against the shipped Brik light + dark themes. Generated by node scripts/validate-themes.js --emit-matrix — regenerate when token values change; do not hand-edit.
| Pairing | Target | Light | Dark |
|---|---|---|---|
| Body text on page | AAA ≥7 | 17.22:1 AAA | 18.76:1 AAA |
| Body text on surface | AAA ≥7 | 17.22:1 AAA | 18.76:1 AAA |
| Body text on background | AAA ≥7 | 17.22:1 AAA | 18.76:1 AAA |
| Secondary text on page | AA ≥4.5 | 6.9:1 AA | 5.46:1 AA |
| Muted text on page | AA-large ≥3 | 3.84:1 AA-lg | 3.04:1 AA-lg |
| Brand text on page | AA ≥4.5 | 6.23:1 AA | 5.55:1 AA |
| On-color label on brand fill | AA ≥4.5 | 6.23:1 AA | 6.23:1 AA |
| Service brand — text on pale surface | AAA ≥7 | 8.4:1 AAA | 7.3:1 AAA |
| Service brand — text on mid-tone surface | AA ≥4.5 | 5.87:1 AA | 5.87:1 AA |
| Service marketing — text on pale surface | AAA ≥7 | 8.31:1 AAA | 7.69:1 AAA |
| Service marketing — text on mid-tone surface | AA ≥4.5 | 7.2:1 AAA | 7.2:1 AAA |
| Service information — text on pale surface | AAA ≥7 | 9.29:1 AAA | 6.89:1 AA |
| Service information — text on mid-tone surface | AA ≥4.5 | 4.59:1 AA | 4.59:1 AA |
| Service product — text on pale surface | AAA ≥7 | 13:1 AAA | 7.53:1 AAA |
| Service product — text on mid-tone surface | AA ≥4.5 | 4.85:1 AA | 4.85:1 AA |
| Service back-office — text on pale surface | AAA ≥7 | 13.87:1 AAA | 9.06:1 AAA |
| Service back-office — text on mid-tone surface | AA ≥4.5 | 4.79:1 AA | 4.79:1 AA |
Every recommended pairing clears WCAG AA in both themes. One pairing (information · pale · dark, 6.89:1) misses the AAA aim by a hair while comfortably exceeding AA.
The dark-mode service gap
Historically, ADR-011's dark-mode softening (context text shifts one tier lighter while service surfaces stay fixed-light) dropped service text below AA in dark mode. That active gap is largely closed for the recommended pairings — brik-bds#865 pinned the dark service context tokens to their -darkest primitives, and #827 darkened the light-mode primitives so even the mid-tone step clears AA.
What remains is the non-recommended anti-pattern: a white label on a mid-tone service base fill in dark mode (marketing/brand), which is sub-AA. The foundation steers away from it (use pattern 2 or 3). Service pairings in the dataset carry a darkException guard linked to #823: if a future token change pushes a dark service pairing below AA, the gate reports it as a tracked exception rather than hard-failing — the gap stays visible and owned, never silently passing.
Client themes inherit this for free
Pairing safety is relationship-based, not per-HEX. A client theme (theme-{client}.css, [data-audience]/[data-service] scope binding, registerClientTheme()) that fills its ramp per the documented step-roles — pale -lightest for text-bearing surfaces, -darkest for context text, on-color neutrals for solid fills — inherits AA-safety without anyone hand-tuning a hex to pass a checker.
Validation comes from the same source: the Contrast Compliance dashboard probes every registered theme — including client themes — against this exact pairing set via getComputedStyle. A new client ramp is audited against the full matrix the moment it's registered.
Enforcing it
The gate runs in the validate script (pre-commit), the release workflow (pre-publish), and pr-checklist.sh:
npm run contrast-gate # node scripts/validate-themes.js — exits 1 below the AA floor
node scripts/validate-themes.js --emit-matrix # regenerate the table aboveTo add or change a pairing, edit tokens/contrast-pairings.json and regenerate the matrix. The gate, the dashboard, and this page stay in lockstep because they read the same file.
Related
- Color — the canonical token registry these pairings draw from
- Token Anatomy — the four color-modifier constructs (
on-colorvs-on-light/-on-darkvs-inversevs tone) - Client Themes — scope binding + the theme registry the dashboard probes
- ADR-011 — the service-line value model the service pairings follow