Keep every mode WCAG-compliant with a11y-check

Audit your color tokens for WCAG contrast across light, dark, and every other mode — read which pairings fail, see lightness-only fixes, and gate CI on the result.

What you’ll learn: a11y-check

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.

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.

  1. Run the audit and read the verdict. Point a11y at 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 dark mode’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 in light, which is exactly the kind of mode-specific trap this audit exists to catch.

  2. 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 an on-* segment (like on-primary); it’s a background if its path contains bg, background, surface, or fill. Every foreground is then checked against every background within the same mode. That’s why text.muted and text.primary are each tested against surface.canvas. Name your tokens with these conventional roles and the audit finds the right pairs for free.

  3. Tighten the bar to AAA. Pass --level=AAA to 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.

  4. Focus on one mode. When you’re iterating on a single theme, pass --modes with a comma-separated list to audit just those. Auditing only light at 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.
    
  5. 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 #778191 to reach AA, or #99a4b4 to reach the stricter AAA. These are suggestions, not edits: nothing on disk changes from running a11y.

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:

# 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