import { readFileSync } from 'fs'; import { join } from 'path'; import { meetsWCAGStandard } from '../../src/lib/color-contrast'; /** * 对比度门禁(验收 A-11-1 重写)。 * * 旧实现是 7 组硬编码十六进制(全部 #FFFFFF 底),既不读令牌表也不含暗色配对 —— * 主题令牌改了它仍对陈旧色值报绿,因此全站 62 页的暗色违规能从这道门禁底下漏过。 * 现在:色值一律从 globals.css 的两个主题根解析,缺令牌即失败(不允许静默跳过)。 */ const THEME_BLOCKS = [ { theme: 'light', selector: ':root' }, { theme: 'dark', selector: "html[data-theme='dark']" }, ] as const; interface Pair { foreground: string; background: string; /** 前景 alpha(0-1),用于检验带 alpha 修饰类的正文(如 text-text-secondary 80%) */ alpha?: number; textSize?: 'normal' | 'large'; } /** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token。 */ /** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token,两主题各解一次。 */ const REQUIRED_GROUPS: Array<{ label: string; pairs: Pair[] }> = [ { label: '正文层级 × 页面底色', pairs: [ { foreground: '--color-text-primary', background: '--color-bg-primary' }, { foreground: '--color-text-primary', background: '--color-bg-secondary' }, { foreground: '--color-text-primary', background: '--color-bg-tertiary' }, { foreground: '--color-text-secondary', background: '--color-bg-primary' }, { foreground: '--color-text-secondary', background: '--color-bg-secondary' }, { foreground: '--color-text-tertiary', background: '--color-bg-primary' }, { foreground: '--color-text-muted', background: '--color-bg-primary' }, ], }, { label: '品牌红文字通道(text-brand-ink 及 hover 档)', pairs: [ { foreground: '--color-brand-ink', background: '--color-bg-primary' }, { foreground: '--color-brand-ink', background: '--color-bg-secondary' }, { foreground: '--color-brand-ink', background: '--color-brand-bg' }, { foreground: '--color-brand-ink-hover', background: '--color-bg-primary' }, { foreground: '--color-brand-ink-hover', background: '--color-brand-bg' }, // 红底按钮的真实组合是 bg-brand + text-white(button.tsx:14,text-sm 属 normal 字号); // --color-text-inverse 全仓 0 引用且暗色翻成墨色,不构成 UI 组合,故此处用字面量白。 { foreground: '#FFFFFF', background: '--color-brand' }, ], }, { label: 'alpha 修饰正文(带 /80 等修饰类的正文)', pairs: [{ foreground: '--color-text-secondary', background: '--color-bg-primary', alpha: 0.8 }], }, { // 验收 A-12 残留:装饰序号/占位字(aria-hidden + ≥24px)。axe 的 color-contrast 不认 aria-hidden // (实测 4.11.4 仍按可见文字判定),所以这类文字必须自己过 AA 大字 3:1,不能再靠低 alpha 淡化。 label: '大号装饰文本(text-text-hint)× 各级卡片底色', pairs: [ { foreground: '--color-text-hint', background: '--color-bg-primary', textSize: 'large' }, { foreground: '--color-text-hint', background: '--color-bg-secondary', textSize: 'large' }, { foreground: '--color-text-hint', background: '--color-bg-tertiary', textSize: 'large' }, { foreground: '--color-text-hint', background: '--color-bg-elevated', textSize: 'large' }, ], }, { label: '深色区块(不随主题翻转的独立 token)', pairs: [ { foreground: '--color-dark-text-primary', background: '--color-dark-bg' }, { foreground: '--color-dark-text-secondary', background: '--color-dark-bg' }, { foreground: '--color-dark-text-muted', background: '--color-dark-bg' }, { foreground: '--color-brand-section-text', background: '--color-brand-section' }, ], }, ]; function extractBlock(css: string, selector: string): string { const start = css.indexOf(`${selector} {`); if (start < 0) throw new Error(`globals.css 中找不到主题根:${selector}`); let depth = 0; let cursor = css.indexOf('{', start); const bodyStart = cursor + 1; while (cursor < css.length) { const ch = css[cursor]; if (ch === '{') depth += 1; else if (ch === '}') { depth -= 1; if (depth === 0) break; } cursor += 1; } return css.slice(bodyStart, cursor); } function parseTokens(block: string): Map { const tokens = new Map(); for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) { const name = match[1] as string; const value = (match[2] as string).trim(); tokens.set(name, tokens.get(name) ?? value); } return tokens; } interface Rgba { r: number; g: number; b: number; a: number; } function parseColor(raw: string, tokens: Map, depth = 0): Rgba { if (depth > 5) throw new Error(`var() 递归超过 5 层:${raw}`); const value = raw.startsWith('var(') ? resolveVar(raw, tokens) : raw; if (value !== raw && value.startsWith('var(')) return parseColor(value, tokens, depth + 1); const hex = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i)?.[1]; if (hex) { const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex; return { r: parseInt(full.slice(0, 2), 16), g: parseInt(full.slice(2, 4), 16), b: parseInt(full.slice(4, 6), 16), a: 1, }; } const rgb = value.match(/^rgba?\(([^)]+)\)$/); if (rgb) { const parts = (rgb[1] as string).split(/[\s,]+/).map((p) => Number.parseFloat(p)); const [r, g, b, a] = [parts[0], parts[1], parts[2], parts[3]]; if ([r, g, b].some((n) => n === undefined || Number.isNaN(n))) throw new Error(`无法解析颜色:${value}`); return { r: r ?? 0, g: g ?? 0, b: b ?? 0, a: a === undefined || Number.isNaN(a) ? 1 : a }; } throw new Error(`无法解析颜色:${raw}`); } function resolveVar(raw: string, tokens: Map): string { const inner = raw.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/); if (!inner) return raw; const name = inner[1] as string; const fallback = inner[2]; const value = tokens.get(name) ?? fallback; if (value === undefined) throw new Error(`令牌未定义:${name}`); return value.trim(); } function composite(fg: Rgba, bg: Rgba): Rgba { const a = fg.a; return { r: Math.round(fg.r * a + bg.r * (1 - a)), g: Math.round(fg.g * a + bg.g * (1 - a)), b: Math.round(fg.b * a + bg.b * (1 - a)), a: 1, }; } function toHex({ r, g, b }: Rgba): string { const hex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); return `#${hex(r)}${hex(g)}${hex(b)}`.toUpperCase(); } function resolve(tokenName: string, tokens: Map): string { if (tokenName.startsWith('#')) return tokenName; const raw = tokens.get(tokenName); if (raw === undefined) throw new Error(`主题令牌缺失:${tokenName}`); return raw; } function check() { const cssPath = join(process.cwd(), 'src', 'app', 'globals.css'); const css = readFileSync(cssPath, 'utf8'); let passes = 0; const failures: string[] = []; const missing: string[] = []; for (const { theme, selector } of THEME_BLOCKS) { const own = parseTokens(extractBlock(css, selector)); // 按 CSS 层叠语义取色:暗色块只声明需要翻转的令牌,其余沿用 :root。 // 不做层叠就会把「刻意不翻」的底色通道误报成令牌缺失(DESIGN.md 双通道红规则)。 const tokens = theme === 'dark' ? new Map([...parseTokens(extractBlock(css, ':root')), ...own]) : own; console.log(`\n🎨 ${theme.toUpperCase()} 主题(${selector},${tokens.size} 个令牌)`); for (const group of REQUIRED_GROUPS) { console.log(` ${group.label}`); for (const pair of group.pairs) { let label = `${pair.foreground} on ${pair.background}${pair.alpha !== undefined ? ` @${pair.alpha * 100}%` : ''}`; try { const fgToken = parseColor(resolve(pair.foreground, tokens), tokens); const bgToken = parseColor(resolve(pair.background, tokens), tokens); const fg = pair.alpha === undefined ? fgToken : { ...fgToken, a: pair.alpha }; const composited = composite(fg, bgToken); const bgHex = toHex(bgToken); const fgHex = toHex(composited); const result = meetsWCAGStandard(fgHex, bgHex, 'AA', pair.textSize ?? 'normal'); const ratio = result.ratio.toFixed(2); if (result.passes) { passes += 1; console.log(` ✅ ${label} — ${ratio}:1`); } else { failures.push(`${theme} · ${label} = ${ratio}:1(需 ${result.requiredRatio}:1;${fgHex} on ${bgHex})`); console.log(` ❌ ${label} — ${ratio}:1(需 ${result.requiredRatio}:1)`); } } catch (error) { missing.push(`${theme} · ${label} — ${(error as Error).message}`); console.log(` ❌ ${label} — ${(error as Error).message}`); } } } } console.log(`\n📊 合计 ${passes + failures.length + missing.length} 组:✅ ${passes} / ❌ ${failures.length} 不达标 / ⚠️ ${missing.length} 令牌缺失`); if (failures.length > 0) { console.log('\n不达标:'); failures.forEach((f) => console.log(` · ${f}`)); } if (missing.length > 0) { console.log('\n令牌缺失(门禁视为失败,避免对着陈旧色值报绿):'); missing.forEach((m) => console.log(` · ${m}`)); } if (failures.length > 0 || missing.length > 0) { console.log('\n⚠️ 存在不满足 WCAG 2.1 AA 的令牌配对'); process.exit(1); } console.log('\n✅ 全部令牌配对满足 WCAG 2.1 AA'); process.exit(0); } check();