What you’ll learn
Your design system probably ships more than one mode — light and dark at least,
maybe a few brand themes on top. Each mode has its own colors, which means each
mode has its own contrast story to tell. A text color that reads beautifully on a
white canvas can quietly fail against a near-black one. This tutorial teaches
ds-bridge’s a11y command: a token-level WCAG contrast audit that checks every
foreground/background pairing in every mode and tells you, in plain numbers,
which ones fall short.
A word for designers and managers: you don’t need to open a contrast checker or do any math. The audit reads your token file — the same source of truth you already maintain — pairs the colors the way a screen actually combines them, and reports each result as a ratio against the threshold it needs to clear. Where a fix is possible by nudging lightness alone, it even suggests one.
- How ds-bridge discovers foreground/background pairings and reads your modes
- How to read a real audit at WCAG AA and the stricter AAA
- What a “suggestion: none” means, and why it’s a design decision — not a bug
- How exit codes let you gate a pull request on contrast
Setup
You’ll need the ds-bridge plugin and a design-token file with color tokens. New here? Getting started walks through install and the preferences dialog. This feature needs no Figma token — it reads your tokens and nothing else, so anyone on the team can run it.
If your tokens use Tokens Studio with a $themes array, ds-bridge audits one
mode per theme automatically. Any other shape — W3C, Style Dictionary, or a
theme-less file — is audited as a single mode called default.
# Audit a token file across all of its modes at WCAG AA.
ds-bridge a11y tokens.json
Steps
For these examples we’re auditing a small two-mode token file that ships with
ds-bridge — a light and a dark theme, each with a text scale and a canvas
surface. Your own file will look different, but the output reads the same way.
-
Run the audit and read the verdict. Point
a11yat your token file. Here is a real run at the default WCAG AA level:┌───────┬────────┬───────────────────────────────┬────────────────────────────────────────────────────────┐ │ mode │ status │ pair │ detail │ ├───────┼────────┼───────────────────────────────┼────────────────────────────────────────────────────────┤ │ dark │ fail │ text.muted / surface.canvas │ 2.35:1 (need 4.5:1) — #4b5563 on #111827 → try #778191 │ │ dark │ pass │ text.primary / surface.canvas │ 16.98:1 (need 4.5:1) — #f9fafb on #111827 │ │ light │ pass │ text.muted / surface.canvas │ 4.83:1 (need 4.5:1) — #6b7280 on #ffffff │ │ light │ pass │ text.primary / surface.canvas │ 17.74:1 (need 4.5:1) — #111827 on #ffffff │ └───────┴────────┴───────────────────────────────┴────────────────────────────────────────────────────────┘ 1 of 4 pair(s) fail AA contrast.One row per pairing per mode. The
darkmode’s muted text manages only a 2.35:1 ratio against the dark canvas — well under the 4.5:1 that AA requires for normal text. The same muted gray clears the bar comfortably inlight, which is exactly the kind of mode-specific trap this audit exists to catch. -
Understand how the pairings were found. ds-bridge doesn’t guess at random combinations — it pairs by role, read from each token’s path. A token is a foreground if its path contains
text,fg,foreground, or anon-*segment (likeon-primary); it’s a background if its path containsbg,background,surface, orfill. Every foreground is then checked against every background within the same mode. That’s whytext.mutedandtext.primaryare each tested againstsurface.canvas. Name your tokens with these conventional roles and the audit finds the right pairs for free. -
Tighten the bar to AAA. Pass
--level=AAAto hold your tokens to the stricter 7:1 standard. The same file tells a harder story:┌───────┬────────┬───────────────────────────────┬──────────────────────────────────────────────────────┐ │ mode │ status │ pair │ detail │ ├───────┼────────┼───────────────────────────────┼──────────────────────────────────────────────────────┤ │ dark │ fail │ text.muted / surface.canvas │ 2.35:1 (need 7:1) — #4b5563 on #111827 → try #99a4b4 │ │ dark │ pass │ text.primary / surface.canvas │ 16.98:1 (need 7:1) — #f9fafb on #111827 │ │ light │ fail │ text.muted / surface.canvas │ 4.83:1 (need 7:1) — #6b7280 on #ffffff → try #535967 │ │ light │ pass │ text.primary / surface.canvas │ 17.74:1 (need 7:1) — #111827 on #ffffff │ └───────┴────────┴───────────────────────────────┴──────────────────────────────────────────────────────┘ 2 of 4 pair(s) fail AAA contrast.The light muted text that passed AA now fails AAA — its 4.83:1 ratio is fine for the lower bar but short of 7:1. The ratios never change; the threshold they’re measured against does.
-
Focus on one mode. When you’re iterating on a single theme, pass
--modeswith a comma-separated list to audit just those. Auditing onlylightat AA:┌───────┬────────┬───────────────────────────────┬───────────────────────────────────────────┐ │ mode │ status │ pair │ detail │ ├───────┼────────┼───────────────────────────────┼───────────────────────────────────────────┤ │ light │ pass │ text.muted / surface.canvas │ 4.83:1 (need 4.5:1) — #6b7280 on #ffffff │ │ light │ pass │ text.primary / surface.canvas │ 17.74:1 (need 4.5:1) — #111827 on #ffffff │ └───────┴────────┴───────────────────────────────┴───────────────────────────────────────────┘ All 2 pair(s) meet AA contrast.The mode names come straight from your Tokens Studio
$themes. Ask for a mode that isn’t there and ds-bridge tells you which ones exist rather than failing silently:Unknown mode(s): mobile. Available modes: dark, light. -
Read the suggestion. Each failing row that can be fixed by lightness alone ends with
→ try #…. ds-bridge converts the foreground to OKLCH, then walks its lightness toward the background until the pair clears the required ratio — keeping the hue and chroma exactly as designed. So the dark muted gray becomes#778191to reach AA, or#99a4b4to reach the stricter AAA. These are suggestions, not edits: nothing on disk changes from runninga11y.
Result
You can now answer a question every multi-mode design system has to face: does our text stay readable in every theme we ship? A clean run prints “All N pair(s) meet AA contrast.”; otherwise you get a per-mode, severity-ordered list of exactly which pairings fall short, by how much, and — where possible — a hue-preserving color that would fix it.
Letting Claude Code summarize and (optionally) apply. Run
/ds-bridge:a11y-check and the slash command runs the same audit, then groups
the failures by mode and leads with the mode that has the most. If any failing
pair carries an adjusted suggestion, it uses an AskUserQuestion prompt to
offer your choices: apply the suggestions to the token source, show every finding
(not just failures), re-run at a different level, or stop and change nothing. It
never auto-applies — only the pairs you explicitly approve are ever edited, and
only to the values the audit computed.
When the suggestion is “none”. Sometimes a failing pair has no lightness-only
fix. The audit walks the foreground’s lightness from end to end and still can’t
reach the ratio, because the hue’s chroma caps how much contrast it can ever
achieve against that background. That’s reported as suggestion: none (the term
table shows → no lightness-only fix). It isn’t an error — it’s the tool telling
you this one needs a real design decision: pick a different background, desaturate
the hue, or choose another color. No automatic nudge can rescue it.
Gating CI. a11y follows the lint exit-code convention, so it slots straight
into a pipeline:
- 0 — every audited pair passes. The job goes green.
- 1 — at least one pair fails (or a color couldn’t be parsed). The job fails.
- 2 — an operational problem: a bad path, no token source, an unknown mode, or a bad flag. Fix the invocation, not the colors.
# Fail the build if any AA contrast pairing regresses.
ds-bridge a11y tokens.json --level AA
Your tokens are the source of truth for contrast just as they are for drift; if you haven’t yet seen ds-bridge treat them that way, Catching token drift is the natural companion to this page.
Troubleshooting
- “No semantic color pairings found”? ds-bridge found color tokens but none
whose paths read as foreground and background roles. Check your naming: a
pairing needs at least one
text/fg/on-*/foregroundtoken and at least onebg/background/surface/filltoken in the same mode. - Only one
defaultmode when you expected several? Per-mode auditing comes from a Tokens Studio$themesarray. A W3C, Style Dictionary, or theme-less file is audited as a singledefaultmode — that’s expected, not a failure. --modessays a mode is unknown? The list is exact and case-sensitive, and the error names every available mode. Copy a name from that list, or drop--modesto audit them all.- A row shows
unparseable? One of the two values isn’t a readable color — often a non-color value sitting on a color token. That’s a data problem, not a contrast failure, but it still trips exit code 1 so it can’t slip past CI.