# chart-color-audit Accessibility audit for chart color palettes. Answers one question with math: **can everyone still tell your data series apart?** Plenty of tools check text contrast. This one checks the thing dashboards actually break: whether your palette survives colorblindness. It simulates deuteranopia, protanopia, and tritanopia with the published [Machado, Oliveira & Fernandes 2009](https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html) matrices, measures pairwise separation in OKLab, checks [WCAG 2.2](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) contrast against your background, and fails your build when a change quietly breaks any of it. ## Four ways in. Pick yours. | you are | you want | go to | |---|---|---| | a designer with a palette in hand | a verdict right now, nothing installed | [1 · Try a palette](#1--try-a-palette-nothing-to-install) | | a team with tokens in a repo | the build to fail when a color change breaks accessibility | [2 · Gate your CI](#2--gate-your-ci) | | an engineer building tooling | the audit as a typed function | [3 · Call it from code](#3--call-it-from-code) | | working with Claude or another agent | the audit as an MCP tool | [4 · Wire it into an agent](#4--wire-it-into-an-agent) | ## 1 · Try a palette (nothing to install) ```bash npx chart-color-audit --colors "#4E79A7,#F28E2B,#E15759" --bg "#fff" ``` ``` chart-color-audit · 3 colors on #ffffff · mode: perceptual vision min ΔE closest pair reading · normal 14.6 2 ↔ 3 clearly distinct · deutan 11.9 2 ↔ 3 clearly distinct · protan 11.9 1 ↔ 3 clearly distinct · tritan 10.3 2 ↔ 3 clearly distinct · achromatopsia 5.2 1 ↔ 3 distinguishable contrast vs background: worst 2.42:1 (slot 2, needs ≥ 3:1) ✗ slot 2 #f28e2b — 2.42:1 FAIL — 1 finding: · contrast: slot 2 (#f28e2b) is 2.42:1 vs. background (needs ≥ 3:1, WCAG 2.2 SC 1.4.11). ``` Those three colors separate cleanly under every colorblind simulation. The orange still fails: 2.42:1 against white, below the 3:1 floor for chart marks. Pale bars on white is one of the oldest dashboard sins, and this is it, caught in numbers. **Exit codes: `0` pass, `1` fail, `2` bad input.** That is the whole CI contract. ## Reading the output ΔE is perceptual color distance (OKLab × 100). About 2 is the edge of what a human eye can tell apart. | reading | min ΔE | meaning | |---|---|---| | clearly distinct | ≥ 10 | survives a projector and a hallway glance | | distinguishable | ≥ 2 | a careful reader separates the series | | patterns carry identity | < 2 | color alone is not enough; dash/shape must carry it | | COLLISION | ≈ 0–1 | two series are the same color for these viewers | `closest pair` names the two slots (1-based) that came nearest. Fix those two, re-run, repeat. ## CLI ```bash npx chart-color-audit --colors --bg [options] npx chart-color-audit [--config chartaudit.config.json] npx chart-color-audit mcp ``` | flag | what it does | |---|---| | `--colors ` | comma-separated palette, any CSS color syntax: hex, `hsl()`, `rgb()`, `oklch()`, named, raw triples (`"222 47% 6%"`) | | `--bg ` | background the marks render on | | `--mode ` | `perceptual` (default) · `redundant-encodings` · `strict` (see Floors) | | `--config ` | config file, default `./chartaudit.config.json` | | `--json` | full result as JSON: `verdict`, `failures[]`, `perVision[]`, `contrast[]`, `semantic[]`, `text[]` | | `--version`, `--help` | the usual | ## 2 · Gate your CI Two files, no dependencies added. **Step 1.** Put `chartaudit.config.json` in your repo root, pointed at the tokens you already have: ```jsonc { // Where your tokens live: a .css file (custom properties) or a // W3C design-tokens .json file. Optional; you can also write // literal colors directly in the fields below. "tokens": "src/index.css", // CSS selector whose block holds the variables. Optional, default ":root". "selector": ":root", // REQUIRED. Your data-series colors, in slot order. // Each entry is a literal ("#4E79A7"), a CSS variable name from the // tokens file ("--chart-cat-1"), or a dotted path into design-tokens // JSON ("chart.categorical.1"). "categorical": ["--chart-cat-1", "--chart-cat-2", "--chart-cat-3"], // REQUIRED. The background your marks render on. "background": "--chart-bg", // Optional. Status colors, checked at 3:1 vs the background AND for // collisions against the palette: the classic silent failure where a // "positive" green KPI reads as data series 3 for deutan viewers. // Verdict semantics: "low-contrast" and "collision" FAIL the audit; // "cvd-risk" (collides with a slot only under CVD simulation) is // REPORTED but does not fail — semantic roles usually carry redundant // cues (position, icon, label). Treat cvd-risk as a review prompt. "semantic": { "positive": "--chart-positive", "muted": "--chart-muted" }, // Optional. Tokens rendered as UI text (status labels, captions, table // numbers). Text needs 4.5:1 (SC 1.4.3), not the 3:1 mark floor. // Reusing mark tokens as text is the most common failure I have shipped: // one measured sweep found 111 instances of it in a single app. "text": { "positive": "--chart-positive-text" }, // Optional. Floors preset, and per-floor overrides on top of it. "mode": "perceptual", "floors": { "minContrast": 3, "minTextContrast": 4.5 } } ``` **Step 2.** Add the workflow: ```yaml # .github/workflows/chart-colors.yml on: [push, pull_request] jobs: chart-colors: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npx chart-color-audit ``` Done. A token edit that breaks a floor now exits 1 with named findings, in the PR, before anyone screenshots anything. Prefer a pinned dependency? `npm i -D chart-color-audit` and call it from a package script. Same behavior. ## The famous-palette test ```bash npx chart-color-audit --colors "#4E79A7,#F28E2B,#E15759,#76B7B2,#59A14F,#EDC948,#B07AA1,#FF9DA7,#9C755F,#BAB0AC" --bg "#fff" ``` That is Tableau 10, one of the most-shipped chart palettes on earth, on a white dashboard: ``` ✗ deutan 0.7 3 ↔ 5 COLLISION ✗ protan 0.9 4 ↔ 10 COLLISION ✗ achromatopsia 1.6 2 ↔ 4 COLLISION ... FAIL — 8 findings ``` Its red and green sit ΔE 0.7 apart under deuteranopia simulation. Roughly 1 in 12 men has red-green color vision deficiency; for most of them, series 3 and series 5 on that chart are the same color. Nothing in code review catches this. Math does. ## 3 · Call it from code ```bash npm i chart-color-audit ``` ```ts import { audit } from "chart-color-audit"; const result = audit({ colors: ["#4E79A7", "#F28E2B", "#E15759"], background: "#ffffff", // optional: semanticRoles, textRoles, mode, floors }); result.verdict; // "pass" | "fail" result.failures; // human-readable reasons, empty on pass result.perVision; // per simulation: minDeltaE, closestPair, band ``` Fully typed. Two data dependencies ([culori](https://culorijs.org/) for color math, the MCP SDK for the server). No DOM, no network. ## 4 · Wire it into an agent (MCP) Let an agent audit colors mid-conversation: ```bash claude mcp add chart-color-audit -- npx chart-color-audit mcp ``` Two tools: `audit_palette` (paste colors) and `audit_tokens` (point at a config). Ask "is this palette colorblind-safe on white?" and the answer comes back with measurements instead of vibes. ## Floors | mode | fails when | for | |---|---|---| | `perceptual` (default) | any pair lands below ΔE 2, the edge of human perception, under any simulation; or contrast < 3:1 | palettes where color alone carries identity | | `redundant-encodings` | outright collision only (ΔE < 1 normal / < 0.1 CVD) | design systems pairing every color with a dash/decal/shape channel | | `strict` | below ΔE 10 normal / ΔE 4 CVD | control rooms, projectors, hallway glances | Text tokens are checked at 4.5:1 in every mode. Changing default floors is a **major version**, always: a CI gate that tightens defaults in a patch release breaks builds and trust. ## Honest scope Tokens in, findings out. This audits declared palette colors. It does not render charts, screenshot pixels, or crawl pages. Contrast checkers for text exist in plenty; what this adds is the part I could not find anywhere else: CVD-simulated *series separation*, as a build gate and an MCP tool. ## Where this came from The engine was extracted from a [chart color system](https://socraticstatic.github.io/Visualizations/) whose own history proves the point: an "accuracy fix" commit once replaced the correct deuteranopia matrix with confident, plausible, fabricated values, and review passed it. Only a pinned regression test would have caught it, so this package pins the matrices against the published paper in its test suite, permanently. **The full story: [docs/POSTMORTEM.md](docs/POSTMORTEM.md)**: nine weeks of a wrong matrix, the AI-co-authored commit that forged it, and why the math now checks the math. ## Consulting I audit dashboards and design systems for exactly these failures. 30 years of UX practice, and the math above to prove findings instead of arguing them. **micah@conscious-shell.com** MIT © [Micah Boswell](https://conscious-shell.com)