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. Branded fills, on-color text
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
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
100surface 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 brand-color contract
Brik's brand color is Poppy (--color-poppy-500, #e35335) — a vibrant red that white text hits at 3.78:1: clears AA-large (3:1), not AA-normal (4.5:1). Rather than darken the brand to pass, BDS holds the vibrant color and gates it by role (ADR-015):
- Fills, headings, and accent/link/tab/breadcrumb affordances — gated AA-large (3:1). That is WCAG's bar for large/bold text and UI-component graphics, and the call every white-on-red CTA makes.
- Text on the brand fill (
--text-on-color-darkon--background-/--surface-brand-primary) — always white, normal weight, every size including body copy. 3.78:1 is owner-accepted debt (ADR-015 § Amendment 2026-09-12). Do not "fix" it by darkening the text to--text-primaryor bolding body copy to reach large-text — both are aesthetically wrong for the brand and are disallowed. Keep band copy short; long body copy belongs on a neutral surface. - Small body copy in the brand color — never
--text-brand-primary; use--text-primary. This is the OTHER case (Poppy-colored text on a neutral surface, not white-on-brand). Formerly advisory (the gate scores token pairings, not rendered font sizes), now CI-asserted bynpm run lint-brand-text, which flags--text-brand-primaryon--body-*-sized text (#1064). - Primary buttons at every size — no button label qualifies as WCAG large text (all ≤20px, semibold), so the vibrant fill is an owner-accepted AA-large / 1.4.11 risk, not a resolved pairing. They follow the on-color rule above: vibrant Poppy fill, white label, every size (ADR-015 § Amendment 2026-10-22). There is no size-gated fill and no darker button fill; every size resolves
--background-brand-primary.
--color-poppy-600 is not the button fill — by decision. It (#c94428) hits 4.84:1 on white and shipped with the 11-step scale in v0.152.0. ADR-016 planned to point button fills at it. The owner ruled instead that buttons keep the vibrant fill, so ADR-016 is superseded and #479 closed as not planned.
Don't reopen this with a darker fill, a size floor, or a bold label. Don't reach for 600 in component CSS either — Primitives never enter components.
Client brands are the mirror image, one contract. Brik chose vibrancy and relaxes to AA-large where the role allows. A client whose identity color can't clear AA on its own surfaces takes the opposite lever: tag a darker primitive brand-fill — the contrast-safe twin that fills --background-brand-primary / --text-brand-primary with an AA-passing value in-slot. Same rule ("brand text must be legible"), opposite mechanism (keep-the-hue-relax-the-tier vs. darken-the-slot).
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 | 12.63:1 AAA | 15.91:1 AAA |
| Secondary text on secondary surface | AA ≥4.5 | 11.29:1 AAA | 13.05:1 AAA |
| Muted text on page | AA-large ≥3 | 6.9:1 AA | 3.04:1 AA-lg |
| SegmentedControl inactive label on track | AA ≥4.5 | 15.39:1 AAA | 5.46:1 AA |
| Solid Tag / MultiSelect chip label on fill | AA ≥4.5 | 15.39:1 AAA | 5.46:1 AA |
| Secondary control label on secondary fill | AA-large ≥3 | 15.39:1 AAA | 3.43:1 AA-lg |
| SegmentedControl inactive hover label on tertiary fill | AA ≥4.5 | 13.05:1 AAA | 15.39:1 AAA |
| Disabled control label on disabled fill | AA-large ≥3 | 5.22:1 AA | 3.29:1 AA-lg |
| Neutral indicator label on subtle neutral fill | AA ≥4.5 | 8.15:1 AAA | 8.15:1 AAA |
| Solid neutral indicator label on fill | AA ≥4.5 | 12.08:1 AAA | 12.08:1 AAA |
| Info indicator label on subtle info fill | AA ≥4.5 | 4.51:1 AA | 4.51:1 AA |
| Solid info indicator label on fill | AA ≥4.5 | 6.1:1 AA | 6.1:1 AA |
| Inverse label on inverse fill | AA ≥4.5 | 17.22:1 AAA | 18.76:1 AAA |
| Faded fill-less control label | AA-large ≥3 | 3.32:1 AA-lg | 4.82:1 AA |
| Faded SegmentedControl segment label on track | AA-large ≥3 | 3.21:1 AA-lg | 2.66:1 ⚠ (#1741) |
| Brand text on page | AA-large ≥3 | 3.78:1 AA-lg | 5.55:1 AA |
| Rail TOC active label on surface | AA ≥4.5 | 6.23:1 AA | 5.55:1 AA |
| Rail TOC resting label on surface | AA ≥4.5 | 12.63:1 AAA | 15.91:1 AAA |
| On-color label on brand fill | AA-large ≥3 | 3.78:1 AA-lg | 3.78:1 AA-lg |
| On-color content block on brand band | AA-large ≥3 | 3.78:1 AA-lg | 3.78:1 AA-lg |
| On-color label on brand fill (hover) | AA ≥4.5 | 6.23:1 AA | 6.23:1 AA |
| On-color label on brand fill (pressed) | AA ≥4.5 | 10.24:1 AAA | 10.24:1 AAA |
| Secondary Button label on hover/press fill | AA ≥4.5 | 13.05:1 AAA | 11.29:1 AAA |
| Outline Button label on hover/press fill | AA-large ≥3 | 3.38:1 AA-lg | 4.56:1 AA |
| Service brand — text on pale surface | AAA ≥7 | 8.4:1 AAA | 7.87: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 | 6.06:1 AA | 6.06: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 |
| Service brand — inverse card text (light) | AA ≥4.5 | 8.59:1 AAA | — |
| Service brand — inverse card text (dark) | AA ≥4.5 | — | 7.3:1 AAA |
| Service marketing — inverse card text (light) | AA ≥4.5 | 8.48:1 AAA | — |
| Service marketing — inverse card text (dark) | AA ≥4.5 | — | 7.69:1 AAA |
| Service information — inverse card text (light) | AA ≥4.5 | 9.52:1 AAA | — |
| Service information — inverse card text (dark) | AA ≥4.5 | — | 9.29:1 AAA |
| Service product — inverse card text (light) | AA ≥4.5 | 14.69:1 AAA | — |
| Service product — inverse card text (dark) | AA ≥4.5 | — | 7.53:1 AAA |
| Service back-office — inverse card text (light) | AA ≥4.5 | 16.34:1 AAA | — |
| Service back-office — inverse card text (dark) | AA ≥4.5 | — | 9.06:1 AAA |
| Service brand — inverse card fill (light) | AA-large ≥3 | 8.59:1 AAA | — |
| Service brand — inverse card fill (dark) | AA-large ≥3 | — | 7.3:1 AAA |
| Service marketing — inverse card fill (light) | AA-large ≥3 | 8.48:1 AAA | — |
| Service marketing — inverse card fill (dark) | AA-large ≥3 | — | 7.69:1 AAA |
| Service information — inverse card fill (light) | AA-large ≥3 | 9.52:1 AAA | — |
| Service information — inverse card fill (dark) | AA-large ≥3 | — | 6.89:1 AA |
| Service product — inverse card fill (light) | AA-large ≥3 | 14.69:1 AAA | — |
| Service product — inverse card fill (dark) | AA-large ≥3 | — | 7.53:1 AAA |
| Service back-office — inverse card fill (light) | AA-large ≥3 | 16.34:1 AAA | — |
| Service back-office — inverse card fill (dark) | AA-large ≥3 | — | 9.06:1 AAA |
| Positive/success text on surface | AA ≥4.5 | 8.48:1 AAA | 7.31:1 AAA |
| Positive/success text on page | AA ≥4.5 | 8.48:1 AAA | 7.31:1 AAA |
| Negative/error text on surface | AA ≥4.5 | 4.56:1 AA | 4.61:1 AA |
| Negative/error text on page | AA ≥4.5 | 4.56:1 AA | 4.61:1 AA |
| Subtle negative Badge label on tint fill | AA ≥4.5 | 7.14:1 AAA | 7.14:1 AAA |
| Solid positive Badge label on fill | AA ≥4.5 | 6:1 AA | 7.31:1 AAA |
| Subtle positive Badge label on tint fill | AA ≥4.5 | 6.9:1 AA | 6.9:1 AA |
| Rating stars (outlined Testimonial/CardTestimonial) on surface | AA-large ≥3 | 4.36:1 AA-lg | 4.82:1 AA |
| Subtle warning Badge label on tint fill | AA ≥4.5 | 9.28:1 AAA | 9.28:1 AAA |
| Subtle brand Badge label on tint fill (light) | AA ≥4.5 | 5.46:1 AA | — |
| Subtle brand Badge label on tint fill (dark) | AA ≥4.5 | — | 4.56:1 AA |
| Solid error label on fill | AA ≥4.5 | 4.56:1 AA | 4.56:1 AA |
| Body text on the yellow accent tint | AAA ≥7 | 15.77:1 AAA | 19.22:1 AAA |
| Body text on the blue accent tint | AAA ≥7 | 12.47:1 AAA | 17.7:1 AAA |
| Body text on the green accent tint | AAA ≥7 | 16.25:1 AAA | 20.59:1 AAA |
| Body text on the purple accent tint | AAA ≥7 | 11.72:1 AAA | 14.29:1 AAA |
| Body text on the orange accent tint | AAA ≥7 | 11.95:1 AAA | 17.83:1 AAA |
| Label on the red accent fill | AA ≥4.5 | 4.56:1 AA | 5.55:1 AA |
| GHL form field border on form background | AA-large ≥3 | 3.84:1 AA-lg | 3.04:1 AA-lg |
| GHL form focus ring on form background | AA-large ≥3 | 3.78:1 AA-lg | 5.55:1 AA |
| GHL form checked-checkbox fill on form background | AA-large ≥3 | 3.78:1 AA-lg | 5.55:1 AA |
Every recommended pairing clears its WCAG target in both themes, with one tracked exception: the faded Chip secondary label measures 2.34:1 in dark mode, because dark --background-secondary is a mid-grey that gives its label only 3.43:1 even while enabled. That mid-grey is deliberate — the token carries two opposite foregrounds, and no grayscale step clears AA for both — so the fix is Chip adopting the disabled token swap rather than a token change (#1701). The vibrant brand-primary rows — brand text and the white on-color label on the brand fill — clear AA-large (3:1) per the brand-color contract above. One service pairing (information · pale · dark, 6.89:1) misses the AAA aim by a hair while comfortably exceeding AA.
The three Faded … rows are disabled states. A faded pairing is scored after compositing both its foreground and its fill over the page at --state-disabled-opacity, because opacity moves the label and the fill toward the same backdrop. Scoring the raw token pair instead reported a fade as safe when it was not — see Interaction States for which controls fade and which swap tokens.
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 primitive (stop 950), 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 100 for text-bearing surfaces, 950 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 on pre-push (validate:full → scripts/validate-all.js), in CI on every PR (tokens-gate.yml, as one step of the tokens-gate job), at release (pre-publish), and in pr-checklist.sh. tokens-gate is a required status check on main — a contrast regression cannot be merged, and no merge-wait discipline is needed (#2098):
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.
Decision records
The policies this page enforces are ratified in ADRs — all current, all cited inline above:
- ADR-011 — the service-line value model the service pairings follow
- ADR-015 — brand-primary holds vibrant Poppy, gated AA-large
- ADR-016 — small-primary-button contrast remedy (superseded 2026-10-22 by ADR-015)
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
brand-filltier — the client-theme mechanism for brands that darken in-slot