Add transparent nav SVG assets with raster fallbacks, header contrast and focus fixes, automated a11y audit scripts, and evidence reports for institutional review. Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
6.8 KiB
JavaScript
217 lines
6.8 KiB
JavaScript
#!/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)
|