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. 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 100 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 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.
  • Small body copynever --text-brand-primary; use --text-primary. Formerly advisory (the gate scores token pairings, not rendered font sizes), now CI-asserted by npm run lint-brand-text, which flags --text-brand-primary on --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. ADR-016 ratified Option C (mint an AA-passing near-Poppy step) and explicitly declined a size floor — there is no size-gated fill in Button today; every size resolves --background-brand-primary.

The AA-passing near-Poppy step now exists — and is not wired up yet. --color-poppy-600 (#c94428) hits 4.84:1 on white: it clears AA-normal while staying visibly Poppy, which is exactly what ADR-015 and ADR-016 recorded as unavailable. It shipped with the 11-step scale in v0.152.0, unblocking ADR-016's Option C.

No Semantic token points at 600 today, so nothing has changed at the component layer. Repointing the on-color fills is #479. Don't reach for 600 directly in component CSS — Primitives never enter components; wait for the Semantic role.

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.

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.512.63:1 AAA15.91:1 AAA
Secondary text on secondary surfaceAA ≥4.511.29:1 AAA13.05:1 AAA
Muted text on pageAA-large ≥36.9:1 AA3.04:1 AA-lg
SegmentedControl inactive label on trackAA ≥4.515.39:1 AAA5.46:1 AA
Solid Tag / MultiSelect chip label on fillAA ≥4.515.39:1 AAA5.46:1 AA
Secondary control label on secondary fillAA-large ≥315.39:1 AAA3.43:1 AA-lg
SegmentedControl inactive hover label on tertiary fillAA ≥4.513.05:1 AAA15.39:1 AAA
Disabled control label on disabled fillAA-large ≥35.22:1 AA3.29:1 AA-lg
Neutral indicator label on subtle neutral fillAA ≥4.58.15:1 AAA8.15:1 AAA
Solid neutral indicator label on fillAA ≥4.512.08:1 AAA12.08:1 AAA
Inverse label on inverse fillAA ≥4.517.22:1 AAA18.76:1 AAA
Faded fill-less control labelAA-large ≥33.32:1 AA-lg4.82:1 AA
Faded SegmentedControl segment label on trackAA-large ≥33.21:1 AA-lg2.66:1 ⚠ (#1741)
Brand text on pageAA-large ≥33.78:1 AA-lg5.55:1 AA
On-color label on brand fillAA-large ≥33.78:1 AA-lg3.78:1 AA-lg
On-color content block on brand bandAA-large ≥33.78:1 AA-lg3.78:1 AA-lg
On-color label on brand fill (hover)AA ≥4.56.23:1 AA6.23:1 AA
On-color label on brand fill (pressed)AA ≥4.510.24:1 AAA10.24:1 AAA
Secondary Button label on hover/press fillAA ≥4.513.05:1 AAA11.29:1 AAA
Outline Button label on hover/press fillAA-large ≥33.38:1 AA-lg4.56: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
Service brand — inverse card text (light)AA ≥4.58.59:1 AAA
Service brand — inverse card text (dark)AA ≥4.57.3:1 AAA
Service marketing — inverse card text (light)AA ≥4.58.48:1 AAA
Service marketing — inverse card text (dark)AA ≥4.57.69:1 AAA
Service information — inverse card text (light)AA ≥4.59.52:1 AAA
Service information — inverse card text (dark)AA ≥4.56.89:1 AA
Service product — inverse card text (light)AA ≥4.514.69:1 AAA
Service product — inverse card text (dark)AA ≥4.57.53:1 AAA
Service back-office — inverse card text (light)AA ≥4.516.34:1 AAA
Service back-office — inverse card text (dark)AA ≥4.59.06:1 AAA
Service brand — inverse card fill (light)AA-large ≥38.59:1 AAA
Service brand — inverse card fill (dark)AA-large ≥37.3:1 AAA
Service marketing — inverse card fill (light)AA-large ≥38.48:1 AAA
Service marketing — inverse card fill (dark)AA-large ≥37.69:1 AAA
Service information — inverse card fill (light)AA-large ≥39.52:1 AAA
Service information — inverse card fill (dark)AA-large ≥36.89:1 AA
Service product — inverse card fill (light)AA-large ≥314.69:1 AAA
Service product — inverse card fill (dark)AA-large ≥37.53:1 AAA
Service back-office — inverse card fill (light)AA-large ≥316.34:1 AAA
Service back-office — inverse card fill (dark)AA-large ≥39.06:1 AAA
Positive/success text on surfaceAA ≥4.58.48:1 AAA7.31:1 AAA
Positive/success text on pageAA ≥4.58.48:1 AAA7.31:1 AAA
Solid positive Badge label on fillAA ≥4.56:1 AA7.31:1 AAA
Solid error Badge label on fillAA ≥4.54.95:1 AA6.03: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 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
  • ADR-015 — brand-primary holds vibrant Poppy, gated AA-large
  • ADR-016 — small-primary-button contrast remedy (staged)
  • brand-fill tier — the client-theme mechanism for brands that darken in-slot

On this page

💬