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 <[email protected]>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
#!/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)
|
||||
@@ -40,6 +40,16 @@ convert "$horizontal" -strip "$OUT/logo-horizontal.png"
|
||||
convert "$horizontal" -quality 88 -define webp:method=6 "$OUT/logo-horizontal.webp"
|
||||
convert "$horizontal" -resize 2048x -strip "$OUT/logo-horizontal-2x.png"
|
||||
|
||||
echo "Exporting nav-optimized transparent lockups..."
|
||||
convert "$horizontal" -alpha set \
|
||||
-fuzz 18% -transparent "#1a3c34" \
|
||||
-fuzz 12% -transparent "#05382f" \
|
||||
-fuzz 8% -transparent "#0f2922" \
|
||||
PNG32:"$OUT/logo-horizontal-nav.png"
|
||||
convert "$OUT/logo-horizontal-nav.png" -quality 88 -define webp:method=6 "$OUT/logo-horizontal-nav.webp"
|
||||
|
||||
convert "$symbol" -trim +repage -background none -resize 512x512\> PNG32:"$OUT/logo-symbol-nav.png"
|
||||
|
||||
echo "Exporting square PNG/WebP..."
|
||||
convert "$square" -strip "$OUT/logo-square.png"
|
||||
convert "$square" -quality 88 -define webp:method=6 "$OUT/logo-square.webp"
|
||||
@@ -75,9 +85,10 @@ echo "Building brand kit ZIP..."
|
||||
cd "$OUT"
|
||||
rm -f MIM4U-Brand-Kit.zip
|
||||
zip -q MIM4U-Brand-Kit.zip \
|
||||
logo-symbol.svg \
|
||||
logo-symbol.svg logo-symbol-nav.svg \
|
||||
logo-symbol.png logo-symbol-64.png logo-symbol-128.png logo-symbol-256.png logo-symbol-512.png logo-symbol-1024.png \
|
||||
logo-horizontal.png logo-horizontal.webp logo-horizontal-2x.png \
|
||||
logo-horizontal-nav.svg logo-horizontal-nav.png logo-horizontal-nav.webp logo-symbol-nav.png \
|
||||
logo-square.png logo-square.webp \
|
||||
favicon.ico favicon-16.png favicon-32.png favicon-180.png favicon-192.png favicon-512.png \
|
||||
og-image.png \
|
||||
|
||||
Reference in New Issue
Block a user