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.
  • Text on the brand fill (--text-on-color-dark on --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-primary or 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 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. 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.

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
Info indicator label on subtle info fillAA ≥4.54.51:1 AA4.51:1 AA
Solid info indicator label on fillAA ≥4.56.1:1 AA6.1:1 AA
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
Rail TOC active label on surfaceAA ≥4.56.23:1 AA5.55:1 AA
Rail TOC resting label on surfaceAA ≥4.512.63:1 AAA15.91:1 AAA
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.87: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.56.06:1 AA6.06: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.5—7.3:1 AAA
Service marketing — inverse card text (light)AA ≥4.58.48:1 AAA—
Service marketing — inverse card text (dark)AA ≥4.5—7.69:1 AAA
Service information — inverse card text (light)AA ≥4.59.52:1 AAA—
Service information — inverse card text (dark)AA ≥4.5—9.29:1 AAA
Service product — inverse card text (light)AA ≥4.514.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.516.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 ≥38.59:1 AAA—
Service brand — inverse card fill (dark)AA-large ≥3—7.3:1 AAA
Service marketing — inverse card fill (light)AA-large ≥38.48:1 AAA—
Service marketing — inverse card fill (dark)AA-large ≥3—7.69:1 AAA
Service information — inverse card fill (light)AA-large ≥39.52:1 AAA—
Service information — inverse card fill (dark)AA-large ≥3—6.89:1 AA
Service product — inverse card fill (light)AA-large ≥314.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 ≥316.34:1 AAA—
Service back-office — inverse card fill (dark)AA-large ≥3—9.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
Negative/error text on surfaceAA ≥4.54.56:1 AA4.61:1 AA
Negative/error text on pageAA ≥4.54.56:1 AA4.61:1 AA
Subtle negative Badge label on tint fillAA ≥4.57.14:1 AAA7.14:1 AAA
Solid positive Badge label on fillAA ≥4.56:1 AA7.31:1 AAA
Subtle positive Badge label on tint fillAA ≥4.56.9:1 AA6.9:1 AA
Rating stars (outlined Testimonial/CardTestimonial) on surfaceAA-large ≥34.36:1 AA-lg4.82:1 AA
Subtle warning Badge label on tint fillAA ≥4.59.28:1 AAA9.28:1 AAA
Subtle brand Badge label on tint fill (light)AA ≥4.55.46:1 AA—
Subtle brand Badge label on tint fill (dark)AA ≥4.5—4.56:1 AA
Solid error label on fillAA ≥4.54.56:1 AA4.56:1 AA
Body text on the yellow accent tintAAA ≥715.77:1 AAA19.22:1 AAA
Body text on the blue accent tintAAA ≥712.47:1 AAA17.7:1 AAA
Body text on the green accent tintAAA ≥716.25:1 AAA20.59:1 AAA
Body text on the purple accent tintAAA ≥711.72:1 AAA14.29:1 AAA
Body text on the orange accent tintAAA ≥711.95:1 AAA17.83:1 AAA
Label on the red accent fillAA ≥4.54.56:1 AA5.55:1 AA
GHL form field border on form backgroundAA-large ≥33.84:1 AA-lg3.04:1 AA-lg
GHL form focus ring on form backgroundAA-large ≥33.78:1 AA-lg5.55:1 AA
GHL form checked-checkbox fill on form backgroundAA-large ≥33.78:1 AA-lg5.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 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.

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)
  • 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
  • brand-fill tier — the client-theme mechanism for brands that darken in-slot

On this page

💬