import fs from 'node:fs' import path from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' const require = createRequire(import.meta.url) const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(scriptDir, '..') const appCssPath = path.join(rootDir, 'src/index.css') const AA_NORMAL_TEXT = 4.5 function read(filePath) { return fs.readFileSync(filePath, 'utf8') } function extractBlock(css, selector) { const selectorIndex = css.indexOf(selector) if (selectorIndex === -1) { throw new Error(`Missing CSS selector: ${selector}`) } const start = css.indexOf('{', selectorIndex) let depth = 0 for (let index = start; index < css.length; index += 1) { if (css[index] === '{') depth += 1 if (css[index] === '}') depth -= 1 if (depth === 0) return css.slice(start + 1, index) } throw new Error(`Unclosed CSS block: ${selector}`) } function extractVars(cssBlock) { return Object.fromEntries( [...cssBlock.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6}|#[0-9a-f]{3}|var\(--[\w-]+\))/gi)] .map(([, name, value]) => [name, value.toLowerCase()]) ) } const palette = { 'sky-300': '#7dd3fc', 'sky-400': '#38bdf8', 'sky-500': '#0ea5e9', 'sky-600': '#0284c7', 'sky-700': '#0369a1', 'sky-800': '#075985', 'green-300': '#86efac', 'green-400': '#4ade80', 'green-700': '#15803d', 'green-800': '#166534', 'amber-300': '#fcd34d', 'amber-400': '#fbbf24', 'amber-500': '#f59e0b', 'amber-800': '#92400e', 'red-300': '#fca5a5', 'red-400': '#f87171', 'red-600': '#dc2626', 'red-700': '#b91c1c', 'purple-400': '#c084fc', 'purple-700': '#7e22ce', 'orange-400': '#fb923c', 'orange-500': '#f97316', 'orange-800': '#9a3412', 'slate-400': '#94a3b8', 'slate-500': '#64748b', 'slate-600': '#475569', 'slate-700': '#334155', 'gray-200': '#e5e7eb', 'gray-700': '#374151', 'white': '#ffffff', 'black': '#000000', } function hexToRgb(hex) { const normalized = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex return [0, 2, 4].map(offset => parseInt(normalized.slice(1 + offset, 3 + offset), 16) / 255) } function luminance(hex) { return hexToRgb(hex) .map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4) .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0) } function contrastRatio(foreground, background) { const [lighter, darker] = [luminance(foreground), luminance(background)] .sort((left, right) => right - left) return (lighter + 0.05) / (darker + 0.05) } function resolveColor(value, themeVars) { if (value.startsWith('var(')) { const varName = value.match(/--([\w-]+)/)?.[1] if (!varName || !themeVars[varName]) { throw new Error(`Cannot resolve CSS variable: ${value}`) } return resolveColor(themeVars[varName], themeVars) } return value.toLowerCase() } const appCss = read(appCssPath) const darkVars = extractVars(extractBlock(appCss, ':root')) const lightVars = { ...darkVars, ...extractVars(extractBlock(appCss, '[data-theme="light"]')), } const themes = { dark: darkVars, light: lightVars, } const checks = [] function addCheck(label, themeName, foreground, background) { const themeVars = themes[themeName] const fg = resolveColor(foreground, themeVars) const bg = resolveColor(background, themeVars) const ratio = contrastRatio(fg, bg) checks.push({ label, themeName, foreground: fg, background: bg, ratio }) } for (const [themeName, themeVars] of Object.entries(themes)) { const surfaceBackgrounds = ['bg', 'bg-card', 'bg-panel', 'sidebar-bg'] const textTokens = [ 'text', 'text-muted', 'success-text', 'danger-text', 'warning-text', 'info-text', 'marked-text', ] for (const background of surfaceBackgrounds) { for (const foreground of textTokens) { addCheck(`${foreground} on ${background}`, themeName, themeVars[foreground], themeVars[background]) } } addCheck('header text', themeName, themeVars['header-text'], themeVars['header-bg']) addCheck('header muted text', themeName, themeVars['header-muted'], themeVars['header-bg']) addCheck('header accent text', themeName, themeVars['header-accent'], themeVars['header-bg']) addCheck('header danger text', themeName, themeName === 'light' ? themeVars['danger-text'] : palette['red-400'], themeVars['header-bg']) } const darkSurfaceClasses = { 'text-sky-300': palette['sky-300'], 'text-sky-400': palette['sky-400'], 'text-sky-500': palette['sky-500'], 'text-green-300': palette['green-300'], 'text-green-400': palette['green-400'], 'text-amber-300': palette['amber-300'], 'text-amber-400': palette['amber-400'], 'text-red-300': palette['red-300'], 'text-red-400': palette['red-400'], 'text-purple-400': palette['purple-400'], 'text-orange-400': palette['orange-400'], 'text-orange-500 compatibility': palette['orange-400'], 'text-slate-400': palette['slate-400'], 'text-slate-500/600/700 compatibility': darkVars['text-muted'], 'text-*-400 opacity compatibility': darkVars['text-muted'], } const lightSurfaceClasses = { 'text-sky-300/400/500 compatibility': palette['sky-700'], 'text-green-300/400 compatibility': palette['green-800'], 'text-amber-300/400 compatibility': palette['amber-800'], 'text-red-300/400 compatibility': palette['red-700'], 'text-purple-400 compatibility': palette['purple-700'], 'text-orange-400/500 compatibility': palette['orange-800'], 'text-slate-* compatibility': lightVars['text-muted'], } for (const [className, foreground] of Object.entries(darkSurfaceClasses)) { for (const background of ['bg', 'bg-card', 'bg-panel']) { addCheck(`${className} on ${background}`, 'dark', foreground, darkVars[background]) } } for (const [className, foreground] of Object.entries(lightSurfaceClasses)) { for (const background of ['bg', 'bg-card', 'bg-panel']) { addCheck(`${className} on ${background}`, 'light', foreground, lightVars[background]) } } const componentChecks = [ ['btn-primary', 'white', 'sky-700'], ['btn-primary hover', 'white', 'sky-800'], ['bg-sky-600 utility override', 'white', 'sky-700'], ['hover:bg-sky-600 utility override', 'white', 'sky-800'], ['btn-danger', 'white', 'red-600'], ['btn-danger hover', 'white', 'red-700'], ['btn-success', 'white', 'green-700'], ['btn-success hover', 'white', 'green-800'], ['bg-amber-500 utility override', 'white', 'amber-800'], ['q-dot not visited', 'gray-700', 'gray-200'], ['q-dot not answered', 'white', 'red-700'], ['q-dot answered', 'white', 'green-700'], ['q-dot marked', 'white', 'purple-700'], ['calculator operator', 'white', '#1e4080'], ['calculator equals', 'white', 'green-700'], ['calculator clear', 'white', 'red-600'], ['calculator backspace', 'white', 'amber-800'], ] for (const [label, foregroundKey, backgroundKey] of componentChecks) { const foreground = palette[foregroundKey] ?? foregroundKey const background = palette[backgroundKey] ?? backgroundKey addCheck(label, 'dark', foreground, background) addCheck(label, 'light', foreground, background) } const failures = checks .filter(check => check.ratio < AA_NORMAL_TEXT) .sort((left, right) => left.ratio - right.ratio) if (failures.length > 0) { console.error(`WCAG AA contrast audit failed: ${failures.length} pair(s) below ${AA_NORMAL_TEXT}:1`) for (const failure of failures) { console.error( `- [${failure.themeName}] ${failure.label}: ${failure.ratio.toFixed(2)}:1 ` + `(${failure.foreground} on ${failure.background})` ) } process.exit(1) } const lowest = [...checks] .sort((left, right) => left.ratio - right.ratio) .slice(0, 8) console.log(`WCAG AA contrast audit passed: ${checks.length} text pairs are >= ${AA_NORMAL_TEXT}:1`) console.log(`Palette source: hardcoded`) console.log('Lowest passing pairs:') for (const check of lowest) { console.log(`- [${check.themeName}] ${check.label}: ${check.ratio.toFixed(2)}:1`) }