Brik Design System
Foundation

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-primary for body and headings (clears AAA on every neutral surface, both themes).
  • --text-secondary for supporting copy (AA).
  • --text-muted for 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 -lightest surface 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-color neutral) 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.

TextAimEnforced floor
Primary body on neutral / pale-service surfacesAAA (7:1)AA (4.5:1)
Colored brand & service text, on-color labelsAA (4.5:1)AA (4.5:1)
Large/heading text, muted/decorative UIAA-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.

PairingTargetLightDark
Body text on pageAAA ≥717.22:1 AAA18.76:1 AAA
Body text on surfaceAAA ≥717.22:1 AAA18.76:1 AAA
Body text on backgroundAAA ≥717.22:1 AAA18.76:1 AAA
Secondary text on pageAA ≥4.56.9:1 AA5.46:1 AA
Muted text on pageAA-large ≥33.84:1 AA-lg3.04:1 AA-lg
Brand text on pageAA ≥4.56.23:1 AA5.55:1 AA
On-color label on brand fillAA ≥4.56.23:1 AA6.23:1 AA
Service brand — text on pale surfaceAAA ≥78.4:1 AAA7.3:1 AAA
Service brand — text on mid-tone surfaceAA ≥4.55.87:1 AA5.87:1 AA
Service marketing — text on pale surfaceAAA ≥78.31:1 AAA7.69:1 AAA
Service marketing — text on mid-tone surfaceAA ≥4.57.2:1 AAA7.2:1 AAA
Service information — text on pale surfaceAAA ≥79.29:1 AAA6.89:1 AA
Service information — text on mid-tone surfaceAA ≥4.54.59:1 AA4.59:1 AA
Service product — text on pale surfaceAAA ≥713:1 AAA7.53:1 AAA
Service product — text on mid-tone surfaceAA ≥4.54.85:1 AA4.85:1 AA
Service back-office — text on pale surfaceAAA ≥713.87:1 AAA9.06:1 AAA
Service back-office — text on mid-tone surfaceAA ≥4.54.79:1 AA4.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 above

To 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.

  • Color — the canonical token registry these pairings draw from
  • Token Anatomy — the four color-modifier constructs (on-color vs -on-light/-on-dark vs -inverse vs tone)
  • Client Themes — scope binding + the theme registry the dashboard probes
  • ADR-011 — the service-line value model the service pairings follow

On this page