#!/usr/bin/env node /** * Measures every text-on-surface pairing in both colour ramps and fails if one * drops under AA. * * node scripts/checkContrast.js * * Run it after changing anything in src/theme/palette.ts. The ratios written * in the comments there came from this script; if you change a value and do * not re-run it, those comments become fiction. * * It reads palette.ts by parsing it rather than importing it, so it needs no * TypeScript toolchain and can run in a git hook or in CI as plain node. * * Exit code 0 = every pairing passes. 1 = at least one failure, listed. */ const fs = require('fs'); const path = require('path'); const PALETTE_PATH = path.join(__dirname, '..', 'src', 'theme', 'palette.ts'); /* ── WCAG 2.1 relative luminance and contrast ratio ──────────────────────── */ const toRgb = (color) => { const rgba = color.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); if (rgba) return [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])]; let h = color.replace('#', '').trim(); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }; /** The alpha of an rgba() string, or 1 for an opaque colour. */ const alphaOf = (color) => { const m = color.match(/rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i); return m ? Number(m[1]) : 1; }; const channel = (c) => { const s = c / 255; return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; const luminance = (color) => { const [r, g, b] = toRgb(color); return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); }; /** * Composite a translucent colour over an opaque backdrop. * * Necessary because the on-brand tiers are translucent white, and a contrast * ratio is only meaningful between two opaque colours — the number that * matters is the one the composite actually paints. */ const composite = (color, backdrop) => { const a = alphaOf(color); if (a === 1) return color; const f = toRgb(color); const b = toRgb(backdrop); const mixed = f.map((c, i) => Math.round(c * a + b[i] * (1 - a))); return `#${mixed.map((c) => c.toString(16).padStart(2, '0')).join('')}`; }; const ratio = (fg, bg) => { const a = luminance(composite(fg, bg)); const b = luminance(bg); const [hi, lo] = a > b ? [a, b] : [b, a]; return (hi + 0.05) / (lo + 0.05); }; /* ── parse the two ramps out of palette.ts ───────────────────────────────── */ const parseRamp = (source, name) => { const start = source.indexOf(`export const ${name}: Palette = {`); if (start === -1) throw new Error(`Could not find the "${name}" ramp in palette.ts`); // Walk braces so a nested value cannot end the block early. let depth = 0; let end = -1; for (let i = source.indexOf('{', start); i < source.length; i += 1) { if (source[i] === '{') depth += 1; else if (source[i] === '}') { depth -= 1; if (depth === 0) { end = i; break; } } } const body = source.slice(start, end); const ramp = {}; // key: '#hex' | 'rgba(...)' for (const [, key, value] of body.matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) { ramp[key] = value; } // category: { blue: { tone: '..', surface: '..' }, ... } ramp.category = {}; for (const [, hue, tone, surface] of body.matchAll( /(\w+):\s*\{\s*tone:\s*'([^']+)',\s*surface:\s*'([^']+)'\s*\}/g, )) { ramp.category[hue] = { tone, surface }; } // key: ['a', 'b', 'c'] for (const [, key, list] of body.matchAll(/^\s*(\w+):\s*\[([^\]]+)\]/gm)) { ramp[key] = list.match(/'([^']+)'/g).map((s) => s.slice(1, -1)); } return ramp; }; /* ── the rules ───────────────────────────────────────────────────────────── */ /** * 4.6 rather than 4.5: a 0.1 margin over AA so rounding on a device cannot * drop a pairing under the bar it was signed off at. */ const AA = 4.6; /** Agent screens are used outdoors and hold to a higher bar. */ const AA_AGENT = 7; /** * A tint surface has to be this distinguishable from a plain card, or a banner * reads as untinted. * * 1.06 is the floor the approved light ramp actually achieves — its * `accentSurface` (#FEF7EA on white) sits at 1.066, the quietest tint in the * design. The bar is set to that rather than to the ramp's 1.123 average so it * asserts the real contract instead of failing the shipped design; it still * catches a genuinely invisible tint, which is what this check is for. */ const TINT_SEPARATION = 1.06; /** A border has to be at least this visible against the card it sits on. */ const BORDER_VISIBILITY = 1.25; /** Two adjacent surface fills need at least this much step between them. */ const SURFACE_STEP = 1.06; const TEXT_SURFACES = ['surface', 'background', 'surfaceMuted']; const TEXT_TONES = ['textPrimary', 'textSecondary', 'textMuted']; const SEMANTIC = ['success', 'warning', 'danger', 'info']; const ALL_TONES = ['brand', 'accent', ...SEMANTIC]; const checkRamp = (ramp, rampName) => { const results = []; const add = (ok, label, value, bar) => results.push({ ok, label, value, bar, ramp: rampName }); const at = (fg, bg, bar, label) => add(ratio(ramp[fg], ramp[bg]) >= bar, label, ratio(ramp[fg], ramp[bg]), bar); // Body text and every tone, on all three text-bearing surfaces. for (const tone of [...TEXT_TONES, ...ALL_TONES]) { for (const surface of TEXT_SURFACES) { at(tone, surface, AA, `${tone} on ${surface}`); } } // surfaceSunk is non-text, but these three are documented as legible on it. for (const tone of ['textPrimary', 'textSecondary', 'brand']) { at(tone, 'surfaceSunk', AA, `${tone} on surfaceSunk`); } // Each tone on its own tint surface — a banner shows both at once. for (const tone of ALL_TONES) { const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`; at(tone, surface, AA, `${tone} on ${surface}`); } // Body copy on every tint surface, for a banner's message text. for (const tone of ALL_TONES) { const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`; at('textPrimary', surface, AA, `textPrimary on ${surface}`); at('textSecondary', surface, AA, `textSecondary on ${surface}`); } // The fill rule: whatever `textOnFill` is for this ramp must work on every // saturated fill. This is the check that catches a white label in dark mode. // // `accentFill` is the documented exception and is checked separately below. // It is the vivid brass, and it takes ink in BOTH ramps — white on it is // 2.26:1. Including it here would demand that `textOnFill` work on it, which // is exactly the thing the palette says not to do. for (const tone of ['brand', ...SEMANTIC]) { at('textOnFill', tone, AA, `textOnFill on ${tone} fill`); } at('textOnAccentFill', 'accentFill', AA, 'textOnAccentFill on accentFill'); // Agent screens: the primary pairing has to clear the outdoor bar. at('textPrimary', 'surface', AA_AGENT, 'textPrimary on surface (agent bar)'); // A tint surface must be distinguishable from a plain card. for (const tone of ALL_TONES) { const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`; at(surface, 'surface', TINT_SEPARATION, `${surface} distinct from surface`); } // Borders have to be visible against the card they edge. for (const border of ['border', 'borderStrong', 'brandBorder', 'accentBorder', ...SEMANTIC.map((t) => `${t}Border`)]) { at(border, 'surface', BORDER_VISIBILITY, `${border} visible on surface`); } // Adjacent surface fills need a visible step, which is what carries depth // once the shadow stops doing anything (Android, and dark mode generally). at('background', 'surface', SURFACE_STEP, 'background / surface step'); at('surface', 'surfaceMuted', SURFACE_STEP, 'surface / surfaceMuted step'); at('surfaceMuted', 'surfaceSunk', SURFACE_STEP, 'surfaceMuted / surfaceSunk step'); // Categorical hues: the icon tone has to be legible on a plain card AND on // its own tint chip, and the chip has to be visible against the card. This // is what the old `${hue}15` alpha suffix could not deliver in dark mode. for (const [hue, pair] of Object.entries(ramp.category)) { const onCard = ratio(pair.tone, ramp.surface); add(onCard >= AA, `category.${hue} tone on surface`, onCard, AA); const onTint = ratio(pair.tone, pair.surface); add(onTint >= AA, `category.${hue} tone on its tint`, onTint, AA); const sep = ratio(pair.surface, ramp.surface); add(sep >= TINT_SEPARATION, `category.${hue} tint distinct from surface`, sep, TINT_SEPARATION); } // The on-brand tiers, against the LIGHTEST stop of the wash — the worst case. const wash = ramp.brandGradient[0]; for (const tier of ['onBrandPrimary', 'onBrandSecondary', 'onBrandMuted']) { const r = ratio(ramp[tier], wash); add(r >= AA, `${tier} on the brand wash`, r, AA); } // And solid white on each translucent fill — the tiers do not stack, so a // fill always carries the solid tone. for (const fill of ['onBrandSurface', 'onBrandSurfaceStrong']) { const composited = composite(ramp[fill], wash); const r = ratio(ramp.onBrandPrimary, composited); add(r >= AA, `onBrandPrimary on ${fill}`, r, AA); } return results; }; /* ── run ─────────────────────────────────────────────────────────────────── */ const source = fs.readFileSync(PALETTE_PATH, 'utf8'); const ramps = { light: parseRamp(source, 'light'), dark: parseRamp(source, 'dark') }; // Both ramps must declare the same keys — TypeScript enforces this at compile // time, but this script also runs standalone, and a missing key here would // otherwise show up as a confusing NaN. const lightKeys = Object.keys(ramps.light).sort(); const darkKeys = Object.keys(ramps.dark).sort(); const missing = [ ...lightKeys.filter((k) => !darkKeys.includes(k)).map((k) => `dark is missing "${k}"`), ...darkKeys.filter((k) => !lightKeys.includes(k)).map((k) => `light is missing "${k}"`), ]; const all = [...checkRamp(ramps.light, 'light'), ...checkRamp(ramps.dark, 'dark')]; const failures = all.filter((r) => !r.ok); for (const name of ['light', 'dark']) { const rows = all.filter((r) => r.ramp === name); const bad = rows.filter((r) => !r.ok); console.log(`\n${name.toUpperCase()} ${rows.length} pairings, ${bad.length} failing`); for (const row of bad) { console.log(` FAIL ${row.label.padEnd(44)} ${row.value.toFixed(2)} (needs ${row.bar})`); } } if (missing.length) { console.log('\nRAMP MISMATCH'); missing.forEach((m) => console.log(` ${m}`)); } if (failures.length || missing.length) { console.log(`\n${failures.length} contrast failure(s), ${missing.length} mismatch(es).`); console.log('Adjust the value in src/theme/palette.ts — do not delete the check.'); process.exit(1); } console.log(`\nAll ${all.length} pairings pass in both ramps.`);