#!/usr/bin/env node /** * Header chrome WCAG 2.2 contrast audit + optional Lighthouse accessibility run. * Usage: node scripts/a11y/audit-header-wcag.mjs [--lighthouse] [--url=https://mim4u.org] */ import { spawnSync } from 'node:child_process' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) const repoRoot = join(__dirname, '../..') const outDir = join(repoRoot, 'reports/status') const args = process.argv.slice(2) const runLighthouse = args.includes('--lighthouse') const urlArg = args.find((a) => a.startsWith('--url=')) const url = urlArg ? urlArg.split('=')[1] : 'https://mim4u.org' function relLuminance(hex) { const h = hex.replace('#', '') const channels = [h.slice(0, 2), h.slice(2, 4), h.slice(4, 6)].map((pair) => { const c = parseInt(pair, 16) / 255 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }) return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] } function contrastRatio(fg, bg) { const l1 = relLuminance(fg) const l2 = relLuminance(bg) const lighter = Math.max(l1, l2) const darker = Math.min(l1, l2) return (lighter + 0.05) / (darker + 0.05) } function gradePair(ratio, { largeText = false, uiComponent = false, decorative = false } = {}) { if (decorative) return { pass: true, level: 'exempt', note: 'Decorative; accessible name on parent link' } const aa = largeText || uiComponent ? 3 : 4.5 const aaa = largeText || uiComponent ? 4.5 : 7 if (ratio >= aaa) return { pass: true, level: 'AAA' } if (ratio >= aa) return { pass: true, level: 'AA' } return { pass: false, level: 'fail', required: aa } } const tokens = { headerBgLight: '#fffefb', headerBgDark: '#000000', navLinkLight: '#52525b', navLinkDark: '#d4d4d8', navLinkHoverLight: '#1a3c34', navLinkHoverDark: '#d4af37', logoGold: '#c5a059', focusGreenDeep: '#0f2922', focusPrimary500: '#2d6b5c', eyebrowLight: '#7d6232', eyebrowDark: '#d4af37', eyebrowBgLight: '#fafafa', eyebrowBgDark: '#111827', donateBtnText: '#ffffff', donateBtnGreen: '#1a3c34', } const headerPairs = [ { id: 'nav-link-light', fg: tokens.navLinkLight, bg: tokens.headerBgLight, context: 'Desktop nav links (light)' }, { id: 'nav-link-dark', fg: tokens.navLinkDark, bg: tokens.headerBgDark, context: 'Desktop nav links (dark)' }, { id: 'nav-link-hover-light', fg: tokens.navLinkHoverLight, bg: tokens.headerBgLight, context: 'Nav hover (light)' }, { id: 'nav-link-hover-dark', fg: tokens.navLinkHoverDark, bg: tokens.headerBgDark, context: 'Nav hover (dark)' }, { id: 'logo-gold-light', fg: tokens.logoGold, bg: tokens.headerBgLight, context: 'Nav logo artwork (light)', decorative: true, }, { id: 'logo-gold-dark', fg: tokens.logoGold, bg: tokens.headerBgDark, context: 'Nav logo artwork (dark)', decorative: true, }, { id: 'brand-link-focus-light', fg: tokens.focusGreenDeep, bg: tokens.headerBgLight, context: 'Home logo focus ring (light)', uiComponent: true, }, { id: 'brand-link-focus-dark', fg: tokens.eyebrowDark, bg: tokens.headerBgDark, context: 'Home logo focus ring (dark)', uiComponent: true, }, { id: 'nav-focus-ring-light', fg: tokens.focusPrimary500, bg: tokens.headerBgLight, context: 'Nav control focus ring (light)', uiComponent: true, }, { id: 'donate-btn-text', fg: tokens.donateBtnText, bg: tokens.donateBtnGreen, context: 'Header Donate CTA text', largeText: true, }, { id: 'section-eyebrow-light', fg: tokens.eyebrowLight, bg: tokens.eyebrowBgLight, context: 'Section eyebrow near hero (light)', }, { id: 'section-eyebrow-dark', fg: tokens.eyebrowDark, bg: tokens.eyebrowBgDark, context: 'Section eyebrow (dark)', }, ] const contrastResults = headerPairs.map((pair) => { const ratio = contrastRatio(pair.fg, pair.bg) const grade = gradePair(ratio, pair) return { ...pair, ratio: Number(ratio.toFixed(2)), ...grade } }) const contrastFailures = contrastResults.filter((r) => !r.pass) let lighthouse = null if (runLighthouse) { const lhOut = join(outDir, 'lighthouse-a11y-mim4u-latest.json') mkdirSync(outDir, { recursive: true }) const chromePath = process.env.CHROME_PATH || '/usr/bin/google-chrome' const result = spawnSync( 'npx', [ '--yes', 'lighthouse', url, '--only-categories=accessibility', '--output=json', `--output-path=${lhOut}`, '--chrome-flags=--headless --no-sandbox', ], { cwd: repoRoot, env: { ...process.env, CHROME_PATH: chromePath }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], } ) if (result.status === 0) { const raw = JSON.parse(readFileSync(lhOut, 'utf8')) const failing = raw.categories.accessibility.auditRefs .filter((ref) => { const audit = raw.audits[ref.id] return audit && audit.score !== null && audit.score < 1 }) .map((ref) => ({ id: ref.id, title: raw.audits[ref.id].title, score: raw.audits[ref.id].score, })) lighthouse = { score: Math.round(raw.categories.accessibility.score * 100), failingAudits: failing, reportPath: lhOut, } } else { lighthouse = { error: (result.stderr || result.stdout || 'Lighthouse failed').trim().slice(0, 500), } } } const report = { schemaVersion: '1.0.0', assessmentDate: new Date().toISOString().slice(0, 10), subject: 'mim4u.org header chrome WCAG 2.2 contrast audit', standard: 'WCAG 2.2 Level AA (contrast + focus appearance)', url, summary: { headerContrastPairs: contrastResults.length, failures: contrastFailures.length, pass: contrastFailures.length === 0, lighthouseScore: lighthouse?.score ?? null, }, contrastResults, lighthouse, remediations: [ 'Nav logo marked decorative; home link provides accessible name', 'Brand link focus ring uses --mim-brand-green-deep (3:1+ UI contrast)', 'Section eyebrow uses --mim-eyebrow-text (#7d6232) on light backgrounds', 'Footer Instagram link visible text matches accessible name (no aria-label mismatch)', ], } mkdirSync(outDir, { recursive: true }) const outPath = join(outDir, 'mim4u-header-wcag-audit-latest.json') writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`) console.log(`WCAG header audit: ${contrastFailures.length} contrast failure(s)`) contrastFailures.forEach((f) => { console.log(` FAIL ${f.id}: ${f.ratio}:1 (${f.context})`) }) if (lighthouse?.score != null) { console.log(`Lighthouse accessibility: ${lighthouse.score}/100`) lighthouse.failingAudits?.forEach((a) => console.log(` LH FAIL ${a.id}: ${a.title}`)) } console.log(`Report: ${outPath}`) process.exit(contrastFailures.length === 0 ? 0 : 1)