190 lines
6.8 KiB
JavaScript
190 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* WCAG-2.2-AA Color-Contrast verifier.
|
|
*
|
|
* Reads the canonical brand-color palette from `packages/brand/src/index.ts`
|
|
* (`defaultBrandTokens.mail.colors`), computes the WCAG-2.1 relative-luminance
|
|
* contrast ratio for typical foreground/background pairs, and reports
|
|
* pass/fail against the AA thresholds:
|
|
*
|
|
* - Normal text ≥ 4.5:1
|
|
* - Large text ≥ 3.0:1 (>=18pt, or >=14pt bold)
|
|
* - UI/graphics ≥ 3.0:1
|
|
*
|
|
* Exits non-zero if any required pair fails AA, so the script can be wired
|
|
* into CI (.github/workflows/a11y.yml + .gitea/workflows/a11y.yml).
|
|
*
|
|
* The script has no third-party dependencies — the WCAG contrast formula is
|
|
* <20 lines of math and tightly testable.
|
|
*
|
|
* Spec: docs/improvement-plan/10-compliance-polish/wcag-internal.md
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
|
|
const __filename = url.fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
|
|
const AA_NORMAL = 4.5;
|
|
const AA_LARGE = 3.0;
|
|
|
|
/** Parse a 3/6-digit hex colour (`#rgb` or `#rrggbb`) into `{r,g,b}` (0-255). */
|
|
export function parseHex(hex) {
|
|
const h = hex.trim().replace(/^#/, "");
|
|
let r;
|
|
let g;
|
|
let b;
|
|
if (h.length === 3) {
|
|
r = parseInt(h[0] + h[0], 16);
|
|
g = parseInt(h[1] + h[1], 16);
|
|
b = parseInt(h[2] + h[2], 16);
|
|
} else if (h.length === 6) {
|
|
r = parseInt(h.slice(0, 2), 16);
|
|
g = parseInt(h.slice(2, 4), 16);
|
|
b = parseInt(h.slice(4, 6), 16);
|
|
} else {
|
|
throw new Error(`Invalid hex colour: "${hex}"`);
|
|
}
|
|
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
|
throw new Error(`Invalid hex colour: "${hex}"`);
|
|
}
|
|
return { r, g, b };
|
|
}
|
|
|
|
/** WCAG-2.1 relative luminance for an sRGB colour. */
|
|
export function relativeLuminance({ r, g, b }) {
|
|
const channel = (c) => {
|
|
const v = c / 255;
|
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
};
|
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
}
|
|
|
|
/** WCAG-2.1 contrast ratio between two hex colours. Result is in [1, 21]. */
|
|
export function contrastRatio(hexA, hexB) {
|
|
const la = relativeLuminance(parseHex(hexA));
|
|
const lb = relativeLuminance(parseHex(hexB));
|
|
const [lighter, darker] = la > lb ? [la, lb] : [lb, la];
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
/**
|
|
* Cheap regex-based parser for `defaultBrandTokens.mail.colors` literal in
|
|
* packages/brand/src/index.ts. Avoids dragging tsx/import-helpers into a
|
|
* preflight script that has to run before pnpm install in some CI lanes.
|
|
*/
|
|
function loadBrandColors() {
|
|
const brandPath = path.join(REPO_ROOT, "packages/brand/src/index.ts");
|
|
const source = fs.readFileSync(brandPath, "utf8");
|
|
const match = source.match(/mail:\s*\{[^}]*colors:\s*\{([^}]+)\}/m);
|
|
if (!match) {
|
|
throw new Error("Could not locate `defaultBrandTokens.mail.colors` literal in packages/brand/src/index.ts");
|
|
}
|
|
const block = match[1];
|
|
const colors = {};
|
|
for (const line of block.split("\n")) {
|
|
const m = line.match(/^\s*(\w+):\s*"(#[0-9a-fA-F]{3,6})"/);
|
|
if (m) {
|
|
colors[m[1]] = m[2];
|
|
}
|
|
}
|
|
return colors;
|
|
}
|
|
|
|
/**
|
|
* Pairs to verify. `level` controls the threshold:
|
|
* - "AA-normal" (4.5:1) for body text
|
|
* - "AA-large" (3.0:1) for headings (≥18pt or ≥14pt bold) and UI chrome
|
|
*
|
|
* The set is intentionally small — these are the colour combinations that
|
|
* appear in transactional mail templates and the marketing surface. Per-route
|
|
* audits cover the larger app surface via axe-core in CI.
|
|
*/
|
|
function buildPairs(colors) {
|
|
// `informationalOnly` pairs are reported but do not fail the audit. Use for
|
|
// colours that don't convey state-information per WCAG 1.4.11 (e.g. a
|
|
// purely decorative divider). Document the rationale next to each entry.
|
|
return [
|
|
{ name: "Body text on background", fg: colors.text, bg: colors.background, level: "AA-normal" },
|
|
{ name: "Body text on surface", fg: colors.text, bg: colors.surface, level: "AA-normal" },
|
|
{ name: "Muted text on background", fg: colors.mutedText, bg: colors.background, level: "AA-normal" },
|
|
{ name: "Muted text on surface", fg: colors.mutedText, bg: colors.surface, level: "AA-normal" },
|
|
{ name: "Primary button label", fg: colors.primaryText, bg: colors.primary, level: "AA-normal" },
|
|
{ name: "Link on background", fg: colors.link, bg: colors.background, level: "AA-normal" },
|
|
{ name: "Link on surface", fg: colors.link, bg: colors.surface, level: "AA-normal" },
|
|
{
|
|
name: "Border on background (decorative)",
|
|
fg: colors.border,
|
|
bg: colors.background,
|
|
level: "AA-large",
|
|
// WCAG 1.4.11 non-text contrast only applies to UI components that
|
|
// convey state. Mail-template borders are purely decorative dividers
|
|
// around the call-to-action card; they do not affect comprehension.
|
|
informationalOnly: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
function thresholdFor(level) {
|
|
return level === "AA-large" ? AA_LARGE : AA_NORMAL;
|
|
}
|
|
|
|
function pad(s, n) {
|
|
return String(s).padEnd(n, " ");
|
|
}
|
|
|
|
export function runColorContrastAudit() {
|
|
const colors = loadBrandColors();
|
|
const pairs = buildPairs(colors);
|
|
|
|
const rows = pairs.map((pair) => {
|
|
const ratio = contrastRatio(pair.fg, pair.bg);
|
|
const threshold = thresholdFor(pair.level);
|
|
return {
|
|
...pair,
|
|
ratio,
|
|
threshold,
|
|
pass: ratio >= threshold,
|
|
};
|
|
});
|
|
|
|
// Informational-only rows are reported but do not gate CI (they fail-but-
|
|
// don't-fail — see WCAG 1.4.11 carve-out documented in buildPairs).
|
|
const failed = rows.filter((r) => !r.pass && !r.informationalOnly);
|
|
|
|
console.log("WCAG-2.2-AA Color-Contrast Audit");
|
|
console.log("--------------------------------");
|
|
console.log(
|
|
`${pad("Pair", 32)}${pad("Foreground", 10)}${pad("Background", 12)}${pad("Ratio", 8)}${pad("Min", 6)}${pad("Level", 12)}Result`,
|
|
);
|
|
for (const row of rows) {
|
|
let verdict;
|
|
if (row.pass) verdict = "PASS";
|
|
else if (row.informationalOnly) verdict = "INFO";
|
|
else verdict = "FAIL";
|
|
console.log(
|
|
`${pad(row.name, 36)}${pad(row.fg, 10)}${pad(row.bg, 12)}${pad(row.ratio.toFixed(2), 8)}${pad(
|
|
row.threshold.toFixed(1),
|
|
6,
|
|
)}${pad(row.level, 12)}${verdict}`,
|
|
);
|
|
}
|
|
console.log();
|
|
if (failed.length === 0) {
|
|
console.log(`✓ All ${rows.length} brand-colour pairs meet WCAG-2.2-AA.`);
|
|
return { ok: true, rows, failed: [] };
|
|
}
|
|
console.error(`✗ ${failed.length} of ${rows.length} pair(s) fail WCAG-2.2-AA:`);
|
|
for (const f of failed) {
|
|
console.error(` - "${f.name}": ratio ${f.ratio.toFixed(2)} < ${f.threshold} (${f.fg} on ${f.bg})`);
|
|
}
|
|
return { ok: false, rows, failed };
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1].endsWith("verify-color-contrast.mjs")) {
|
|
const result = runColorContrastAudit();
|
|
process.exit(result.ok ? 0 : 1);
|
|
}
|