Files
SnapOtter/tests/unit/palette-contrast.test.ts
T
SnapOtterandGitHub 6339370093 fix(a11y): focus indicators meet the 3:1 non-text contrast bar (#574)
Fixes #568. Adds a real --color-ring token (ink orange #A85518 light / #F0A766 dark) and sweeps all 57 focus-indicator occurrences onto it: soft opacity rings blended to 1.2-1.7:1, border-only indicators sat at 2.6-3.0:1, and four focus:ring-ring sites referenced a token that never existed. Sponsor button keeps pink via pink-700; range sliders and the file list gain their missing keyboard indicators; landing skip link and form borders hardened; the palette contrast guard pins the ring at 3:1 in both themes.
2026-07-19 16:23:15 +08:00

163 lines
5.4 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Guards the Otter Orange palette against WCAG AA drift (issue #557).
* Parses the design tokens straight out of the two globals.css files and
* recomputes contrast with the same math axe-core uses, so any token edit
* that breaks AA fails unit tests on the PR, not a nightly axe run.
*/
const WEB_CSS = fs.readFileSync(
path.resolve(__dirname, "../../apps/web/src/styles/globals.css"),
"utf-8",
);
const LANDING_CSS = fs.readFileSync(
path.resolve(__dirname, "../../apps/landing/src/styles/globals.css"),
"utf-8",
);
function tokensIn(block: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of block.matchAll(/--color-([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})/g)) {
out[m[1]] = m[2].toUpperCase();
}
return out;
}
function extractBlock(css: string, opener: RegExp): string {
const start = css.search(opener);
if (start === -1) return "";
const open = css.indexOf("{", start);
let depth = 0;
for (let i = open; i < css.length; i++) {
if (css[i] === "{") depth++;
if (css[i] === "}") {
depth--;
if (depth === 0) return css.slice(open + 1, i);
}
}
return "";
}
const webLight = tokensIn(extractBlock(WEB_CSS, /@theme/));
const webDark = { ...webLight, ...tokensIn(extractBlock(WEB_CSS, /\.dark\s*\{/)) };
const landing = tokensIn(LANDING_CSS);
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
const f = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function ratio(fg: string, bg: string): number {
const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
return (hi + 0.05) / (lo + 0.05);
}
function expectPair(tokens: Record<string, string>, fg: string, bg: string, min = 4.5) {
expect(tokens[fg], `token --color-${fg} must be defined`).toBeDefined();
expect(tokens[bg], `token --color-${bg} must be defined`).toBeDefined();
const r = ratio(tokens[fg], tokens[bg]);
expect(
r,
`--color-${fg} (${tokens[fg]}) on --color-${bg} (${tokens[bg]}) is ${r.toFixed(2)}:1, needs >= ${min}:1`,
).toBeGreaterThanOrEqual(min);
}
describe("web light theme", () => {
it("defines the label tokens the app uses", () => {
for (const t of [
"primary-foreground",
"destructive-foreground",
"success-foreground",
"accent-foreground",
]) {
expect(
webLight[t],
`--color-${t} must be defined (undefined utilities render no CSS)`,
).toBeDefined();
}
});
it("labels pass AA on their fills", () => {
expectPair(webLight, "primary-foreground", "primary");
expectPair(webLight, "primary-foreground", "primary-light");
expectPair(webLight, "destructive-foreground", "destructive");
expectPair(webLight, "success-foreground", "success");
expectPair(webLight, "accent-foreground", "accent");
});
it("ink text passes AA on every light surface", () => {
for (const bg of ["background", "card", "primary-subtle", "muted"]) {
expectPair(webLight, "primary-ink", bg);
}
expectPair(webLight, "primary-ink-strong", "background");
for (const bg of ["background", "card"]) {
expectPair(webLight, "destructive-ink", bg);
expectPair(webLight, "success-ink", bg);
}
});
it("neutral text passes AA", () => {
expectPair(webLight, "foreground", "background");
expectPair(webLight, "muted-foreground", "background");
expectPair(webLight, "muted-foreground", "card");
expectPair(webLight, "muted-foreground", "muted");
expectPair(webLight, "sidebar-foreground", "sidebar");
});
});
describe("web dark theme", () => {
it("labels still pass on fills (theme-static)", () => {
expectPair(webDark, "primary-foreground", "primary");
expectPair(webDark, "destructive-foreground", "destructive");
expectPair(webDark, "success-foreground", "success");
});
it("ink text passes AA on dark surfaces", () => {
for (const bg of ["background", "card"]) {
expectPair(webDark, "primary-ink", bg);
expectPair(webDark, "destructive-ink", bg);
expectPair(webDark, "success-ink", bg);
expectPair(webDark, "muted-foreground", bg);
}
expectPair(webDark, "foreground", "background");
});
});
describe("focus ring (issue #568)", () => {
it("ring meets 3:1 non-text contrast (WCAG 1.4.11) on light surfaces", () => {
for (const bg of ["background", "card", "muted", "primary-subtle", "sidebar"]) {
expectPair(webLight, "ring", bg, 3);
}
});
it("ring meets 3:1 non-text contrast on dark surfaces", () => {
for (const bg of ["background", "card", "muted", "sidebar"]) {
expectPair(webDark, "ring", bg, 3);
}
});
});
describe("landing", () => {
it("ink text passes AA on light surfaces", () => {
for (const bg of ["background", "surface", "background-alt", "primary-subtle"]) {
expectPair(landing, "primary-ink", bg);
}
expectPair(landing, "primary-ink-strong", "background");
});
it("CTA label passes on both gradient ends", () => {
expectPair(landing, "foreground", "primary");
expectPair(landing, "foreground", "primary-light");
});
it("semantic and muted text pass", () => {
expectPair(landing, "success", "background");
expectPair(landing, "muted", "background");
expectPair(landing, "dark-fg", "dark-bg");
});
});