Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-color-contrast.mjs
T

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);
}