Files
defiQUGandCursor 5c465d176e Institutional A-grade nav logo: SVG lockups, WCAG audit, and deploy hygiene.
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>
2026-06-15 19:06:00 -07:00

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)