#!/usr/bin/env node /** * Blocking guard for finding UI-09 (measured 2026-09-26): 0pt.app carried 82 * `color-contrast` violations at axe-core 4.10.2 impact=serious — the estate's * single largest a11y defect. Every one of them came from the same shape: a * Tailwind alpha modifier on a muted text colour. * * Measured WCAG 2.2 contrast against the two theme backgrounds in * styles/globals.css (--background: oklch(1 0 0) light / oklch(0.145 0 0) dark, * --muted-foreground: oklch(0.556 0 0) / oklch(0.708 0 0)): * * text-muted-foreground 4.73:1 light 7.63:1 dark PASS (AA needs 4.5) * text-muted-foreground/70 2.71:1 4.20:1 FAIL * text-muted-foreground/60 2.29:1 3.37:1 FAIL * text-muted-foreground/50 1.96:1 2.67:1 FAIL * text-muted-foreground/40 1.69:1 2.11:1 FAIL * text-muted-foreground/10 1.13:1 1.13:1 FAIL * * The unmodified token passes in both themes, so the fix is simply never to * fade it. Purely decorative text is exempt — but it has to SAY so: the element * must carry `aria-hidden`, which is also what makes axe skip it. The single * remaining case (app/bestall/page.tsx, the oversized "01" masthead numeral) is * `aria-hidden` + `select-none` + `pointer-events-none` and is allowed by that * rule. * * This is a source-level guard, not a browser run. The full axe-core job that * UI-09 also asks for needs a build + serve in CI and is tracked separately; * until it exists this stops the regression at the shape that caused it. */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; const ROOT = new URL('../..', import.meta.url).pathname.replace(/\/$/, ''); const SKIP = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '.turbo']); const EXT = /\.(tsx|ts|jsx|js|css)$/; /** Alpha-modified colour utilities on TEXT. Background/border alphas are fine. */ const PATTERN = /\btext-(muted-foreground|foreground|primary|secondary|accent-foreground|card-foreground|popover-foreground)\/\d+/g; function walk(dir, out = []) { for (const name of readdirSync(dir)) { if (SKIP.has(name)) continue; const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, out); else if (EXT.test(name)) out.push(p); } return out; } const violations = []; for (const file of walk(ROOT)) { const src = readFileSync(file, 'utf8'); const lines = src.split('\n'); lines.forEach((line, i) => { for (const m of line.matchAll(PATTERN)) { // Decorative text is exempt when the element declares itself hidden to AT. // The attribute may sit on an earlier line of the same JSX element. const window = lines.slice(Math.max(0, i - 6), i + 1).join('\n'); if (/\baria-hidden\b/.test(window)) continue; violations.push({ file: relative(ROOT, file), line: i + 1, token: m[0], text: line.trim() }); } }); } if (violations.length) { console.error(`\n✗ ${violations.length} alpha-faded text colour(s) found — WCAG 2.2 AA 1.4.3 (finding UI-09).\n`); for (const v of violations) { console.error(` ${v.file}:${v.line} ${v.token}`); console.error(` ${v.text.slice(0, 160)}`); } console.error('\n Use the unmodified token (text-muted-foreground = 4.73:1 light / 7.63:1 dark).'); console.error(' If the text really is decoration, put aria-hidden on the element.\n'); process.exit(1); } console.log('✓ no alpha-faded text colours — WCAG 2.2 AA 1.4.3 contrast shape is clean (UI-09)');