Skip to content

Contrast audit

Walks every rendered text node on a page, reads the real background from a canvas, and reports WCAG failures per theme. The full skill and its script are below, ready to copy.

  • Claude Code
  • Cursor
SKILL.md
---
name: contrast-audit
description: WCAG contrast sweep over rendered pages in headless Chromium, light and dark. Load when reviewing accessibility, changing colour tokens, or before a release. Not for APCA judgement or design advice on which colour to pick.
---

# Contrast audit

Text contrast is measured, not eyeballed. This skill runs the sweep, reads the result, and reports what fails and why. It does not restyle anything until the user asks.

## Run

1. Confirm the dev server is up. Ask for the base URL if it is not localhost:3000.
2. Build the URL list: the routes the user named, or every route in the sitemap when they said "all".
3. Run the script from the skill folder:

   ```bash
   BASE=http://localhost:3000 URLS=/,/pricing,/docs node scripts/sweep.mjs
   ```

   `THEMES=light` limits the scheme. `THEME_INIT` is a JS snippet run before load for sites that store the theme in localStorage, for example `THEME_INIT='localStorage.setItem("theme", THEME)'`.
4. Read `contrast-report.json`. Never summarise from the console lines alone; they are truncated.

## Judge

- Body text needs 4.5:1. Text at 24px, or 18.66px bold, needs 3:1. The script applies this; do not relax it.
- A ratio between 3:1 and 4.5:1 on 12px labels is a real failure. Small muted labels are the most common one.
- Repeated failures with the same class and colour are one finding, not twenty. Group by `cls` and `fg`/`bg`.
- Dark mode failures that do not appear in light mode point at a token that was not remapped. Say so.
- Fix contrast by moving lightness only. Do not touch chroma or hue when proposing a repair.

## Accepted exceptions

Skip, and say that you skipped, text that is:

- a colour sample or swatch label whose point is to show the colour
- a disabled control using the project's disabled token
- decorative and `aria-hidden`
- inside the project's own list of accepted exceptions, if one exists

Do not invent exceptions beyond these. A failure you are unsure about goes in the report as a failure with a note.

## Report

One markdown table, ranked by how many elements share the failure:

| Page | Theme | Element | Ratio | Needs | Fix |
| --- | --- | --- | --- | --- | --- |

"Fix" names the token to change and the lightness direction, not a hex. End with the count of findings, the count of accepted exceptions, and the command to re-run.

## Never

- Never report a pass for a page that returned an error; list it as unchecked.
- Never edit styles during the audit. The audit is the deliverable; the fix is a separate request.
- Never use a regex to parse colours. The script reads them back from a canvas so `oklch()`, `lab()` and `color-mix()` resolve correctly.
scripts/sweep.mjs
// Contrast sweep: every visible text node, effective background, WCAG 2 ratio.
// BASE=http://localhost:3000 URLS=/,/about THEMES=light,dark node scripts/sweep.mjs
import { chromium } from "playwright";
import fs from "node:fs";

const BASE = process.env.BASE ?? "http://localhost:3000";
const URLS = (process.env.URLS ?? "/").split(",");
const THEMES = (process.env.THEMES ?? "light,dark").split(",");
const THEME_INIT = process.env.THEME_INIT ?? "";

const browser = await chromium.launch();
const report = {};

for (const theme of THEMES) {
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: theme });
  if (THEME_INIT) await ctx.addInitScript(`const THEME = "${theme}"; ${THEME_INIT}`);
  const page = await ctx.newPage();

  for (const path of URLS) {
    try {
      await page.goto(BASE + path, { waitUntil: "load", timeout: 60000 });
      await page.waitForTimeout(600);
      report[`${theme}:${path}`] = await page.evaluate(() => {
        // Canvas readback resolves any CSS colour the browser can paint.
        const cv = document.createElement("canvas");
        cv.width = cv.height = 1;
        const cx = cv.getContext("2d", { willReadFrequently: true });
        const cache = new Map();
        const parse = (c) => {
          if (cache.has(c)) return cache.get(c);
          cx.clearRect(0, 0, 1, 1);
          cx.fillStyle = "#000";
          cx.fillStyle = c;
          cx.fillRect(0, 0, 1, 1);
          const d = cx.getImageData(0, 0, 1, 1).data;
          const a = d[3] / 255;
          const out = a === 0 ? [0, 0, 0, 0] : [d[0], d[1], d[2], a];
          cache.set(c, out);
          return out;
        };
        const lin = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
        const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
        const blend = (fg, bg) => [0, 1, 2].map((i) => Math.round(fg[i] * fg[3] + bg[i] * (1 - fg[3])));
        const isDark = document.documentElement.classList.contains("dark") ||
          matchMedia("(prefers-color-scheme: dark)").matches;

        // Walk up until an opaque background, compositing translucent layers on the way.
        const bgOf = (el) => {
          const stack = [];
          for (let e = el; e; e = e.parentElement) {
            const c = parse(getComputedStyle(e).backgroundColor);
            if (c[3] > 0) stack.push(c);
            if (c[3] >= 1) break;
          }
          if (!stack.length || stack[stack.length - 1][3] < 1) {
            const root = parse(getComputedStyle(document.body).backgroundColor);
            stack.push(root[3] >= 1 ? root : isDark ? [10, 10, 10, 1] : [255, 255, 255, 1]);
          }
          let acc = stack[stack.length - 1].slice(0, 3);
          for (let i = stack.length - 2; i >= 0; i--) acc = blend(stack[i], acc);
          return acc;
        };

        const SEL = "main p, main span, main label, main button, main a, main h1, main h2, main h3, main h4, main li, main td, main th, main code, main kbd, main dt, main dd, main input, main summary";
        const bad = [];
        for (const el of document.querySelectorAll(SEL)) {
          if (el.closest("[aria-hidden='true'], svg, canvas")) continue;
          const cs = getComputedStyle(el);
          if (cs.visibility === "hidden" || cs.display === "none" || parseFloat(cs.opacity) === 0) continue;
          const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join("").trim();
          if (text.length < 2) continue;
          const r = el.getBoundingClientRect();
          if (r.width === 0 || r.height === 0) continue;
          const fg0 = parse(cs.color);
          if (fg0[3] === 0) continue;
          const bg = bgOf(el);
          const fg = blend(fg0, bg);
          const L1 = lum(fg), L2 = lum(bg);
          const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
          const size = parseFloat(cs.fontSize);
          const bold = parseInt(cs.fontWeight) >= 700;
          const need = size >= 24 || (size >= 18.66 && bold) ? 3 : 4.5;
          if (ratio < need) {
            bad.push({
              text: text.slice(0, 40), ratio: +ratio.toFixed(2), needs: need, size: Math.round(size),
              fg: cs.color, bg: `rgb(${bg.join(",")})`, tag: el.tagName.toLowerCase(),
              cls: String(el.className || "").split(" ").slice(0, 4).join(" "),
            });
          }
        }
        return bad;
      });
    } catch (e) {
      report[`${theme}:${path}`] = [{ error: String(e).slice(0, 120) }];
    }
  }
  await ctx.close();
}
await browser.close();

fs.writeFileSync("contrast-report.json", JSON.stringify(report, null, 1));
for (const [key, rows] of Object.entries(report)) {
  if (!rows.length) continue;
  const head = rows.slice(0, 3).map((x) => x.error ? "ERROR " + x.error : `${x.ratio}:1 "${x.text}" [${x.tag} ${x.size}px]`);
  console.log(key, rows.length, "->", head.join(" | "));
}
console.log("report: contrast-report.json");

What it does

Opens each page in headless Chromium, collects every element with visible text, resolves the effective background by walking up the tree and compositing alpha layers, and computes the WCAG 2 contrast ratio against the size-aware threshold. It runs once per colour scheme and prints a short table of failures plus a JSON report.

It catches what a linter cannot: text on a translucent surface, a label that only fails in dark mode, an oklch() or lab() colour a regex parser misreads, a muted token used on a pastel band.

When to use it

  • Before a release, over every route
  • After a palette or token change
  • When a page "looks fine" and someone still cannot read it

What it will not do

It does not judge APCA, it does not see text inside <canvas> or images, and it does not know which low-contrast samples are intentional demos. Expect to mark a few findings as accepted, and to write those exceptions into the skill.

Install

Two files. Put them in a folder named contrast-audit where your agent reads skills from, ~/.claude/skills/ for yourself or .claude/skills/ for the repo, then npm i -D playwright and npx playwright install chromium once.

contrast-audit/
  SKILL.md
  scripts/sweep.mjs

Invoke with /contrast-audit and a URL list, or let the description trigger it on accessibility and colour work.