/*** @ui-arch 对比度审计:把「文字色 × 底色」的语义配对算成 WCAG 对比度,做成可回归的门禁 * * 为什么需要它:`audit:visual` 只保证「没有硬编码字面量」,令牌用对了但令牌本身可能对比度不够—— * 改一个 --ua-color-text-muted 就能让全站辅助文字跌到 4.4,人眼在亮底上未必察觉,暗色下更看不出来。 * 这里只做四步:解析令牌 → 求值(含 color-mix 混色)→ 按固定配对表算比值 → 与阈值比对。 * 配对表写死在代码里(而不是外置配置):它是「设计契约」,加一条配对等于承认一种新的文字/底色组合, * 必须在评审里看见;阈值分三档(正文 7 / 常规文字 4.5 / 禁用态 2.6)对应 WCAG AA/AAA 与豁免口径, * 另有一档 3 给非文本元素(焦点描边):WCAG 1.4.11 对「关键图形与界面部件」只要求 3:1。 */ import fs from 'node:fs'; import path from 'node:path'; /*** 作用:令牌样式表相对仓库根的路径(唯一来源,勿在别处再写一份) ***/ const TOKENS_REL = 'packages/design/tokens/tokens.css'; /*** 作用:文字色 × 底色配对表:label 人读、fg/bg 是令牌名、min 是 WCAG 阈值 ***/ export const PAIRS = [ { label: '正文 / 页面底', fg: 'text', bg: 'bg', min: 7 }, { label: '正文 / 表面', fg: 'text', bg: 'surface', min: 7 }, { label: '正文 / 二阶表面', fg: 'text', bg: 'surface-2', min: 7 }, { label: '次级文字 / 页面底', fg: 'text-secondary', bg: 'bg', min: 4.5 }, { label: '次级文字 / 表面', fg: 'text-secondary', bg: 'surface', min: 4.5 }, { label: '辅助文字 / 页面底', fg: 'text-muted', bg: 'bg', min: 4.5 }, { label: '辅助文字 / 表面', fg: 'text-muted', bg: 'surface', min: 4.5 }, { label: '辅助文字 / 凹陷底', fg: 'text-muted', bg: 'surface-inset', min: 4.5 }, { label: '强调色 / 页面底', fg: 'accent', bg: 'bg', min: 4.5 }, { label: '主按钮文字 / 强调底', fg: 'accent-contrast', bg: 'accent', min: 4.5 }, { label: '正文 / 强调淡底', fg: 'text', bg: 'tint-accent', min: 4.5 }, { label: '强调文字 / 强调淡底', fg: 'accent', bg: 'tint-accent', min: 4.5 }, { label: '危险色 / 页面底', fg: 'danger', bg: 'bg', min: 4.5 }, { label: '成功色 / 页面底', fg: 'success', bg: 'bg', min: 4.5 }, { label: '警告色 / 页面底', fg: 'warning', bg: 'bg', min: 4.5 }, { label: '三级文字(禁用)/ 页面底', fg: 'text-faint', bg: 'bg', min: 2.6 }, // 非文本对比(WCAG 1.4.11):焦点描边是键盘用户唯一的「我在哪」线索,阈值 3:1 而不是文字档 { label: '焦点描边 / 页面底', fg: 'accent', bg: 'bg', min: 3 }, { label: '焦点描边 / 表面', fg: 'accent', bg: 'surface', min: 3 }, ]; /*** 作用:读文件并归一换行,保证解析稳定 ***/ const readText = (file) => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); /*** 作用:截取某个 CSS 选择器块的块内文本(大括号配平,嵌套的 @media 也能正确闭合) ***/ function blockOf(css, selector) { const start = css.indexOf(selector); if (start < 0) return ''; const open = css.indexOf('{', start); if (open < 0) return ''; let depth = 0; for (let i = open; i < css.length; i++) { if (css[i] === '{') depth += 1; else if (css[i] === '}') { depth -= 1; if (depth === 0) return css.slice(open + 1, i); } } return ''; } /*** 作用:把块内 `--name: value;` 声明抽成表(同名后者覆盖前者,天然实现暗色覆盖) ***/ function declsOf(block) { const map = new Map(); for (const line of block.split('\n')) { const m = /^\s*(--[\w-]+)\s*:\s*([^;]+);/.exec(line); if (m) map.set(m[1], m[2].trim()); } return map; } /*** 作用:解析单个颜色字面量(hex / rgb / rgba / transparent),解析不了返回 null ***/ function parseColor(text) { const v = text.trim().toLowerCase(); if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(v); if (hex) { const h = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1]; return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }; } const fn = /^rgba?\(([^)]+)\)$/.exec(v); if (!fn) return null; const parts = fn[1].split(/[,/]/).map((s) => s.trim()).filter(Boolean); if (parts.length < 3) return null; const ch = (s) => (s.endsWith('%') ? (parseFloat(s) * 255) / 100 : parseFloat(s)); return { r: ch(parts[0]), g: ch(parts[1]), b: ch(parts[2]), a: parts[3] === undefined ? 1 : parseFloat(parts[3]) }; } /*** 作用:求表达式的值:递归替换 var(--x),支持 alpha 变量与 color-mix 单层混色 * 只覆盖令牌文件里真实用到的语法(in srgb + 两个颜色 + 一个百分比),不做通用 CSS 求值器—— * 遇到认不出的表达式直接抛错,宁可红也不静默算错对比度。 ***/ function resolve(expr, vars, depth = 0) { if (depth > 12) throw new Error(`令牌引用层级过深:${expr}`); const text = expr.trim(); const color = parseColor(text); if (color) return color; const mix = /^color-mix\(\s*in\s+srgb\s*,?\s*(.+)\)$/i.exec(text); if (mix) return evalMix(mix[1], vars, depth); const v = /^var\(\s*(--[\w-]+)\s*\)$/.exec(text); if (v) { if (!vars.has(v[1])) throw new Error(`未定义的令牌:${v[1]}`); return resolve(vars.get(v[1]), vars, depth + 1); } throw new Error(`无法求值的颜色表达式:${text}`); } /*** 作用:把 color-mix 的参数切成「颜色 百分比」两段(颜色本身可能带括号,需按顶层逗号切) ***/ function splitTop(text) { const out = []; let depth = 0; let cur = ''; for (const ch of text) { if (ch === '(') depth += 1; if (ch === ')') depth -= 1; if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; } cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; } /*** 作用:按 CSS 规范做 srgb 混色(alpha 预乘),返回带 alpha 的颜色 ***/ function evalMix(args, vars, depth) { const parts = splitTop(args); if (parts.length !== 2) throw new Error(`color-mix 需要两个颜色:${args}`); const one = (part) => { const m = /^(.*?)(?:\s+([\d.]+%|var\([^)]+\)))?$/.exec(part.trim()); const color = resolve(m[1].trim(), vars, depth + 1); const raw = m[2]; const pct = raw === undefined ? null : raw.startsWith('var(') ? parseFloat(resolvePct(raw, vars)) : parseFloat(raw); return { color, pct }; }; const a = one(parts[0]); const b = one(parts[1]); const pA = a.pct ?? (b.pct === null ? 50 : 100 - b.pct); const pB = b.pct ?? 100 - pA; const wa = pA / 100; const wb = pB / 100; const alpha = a.color.a * wa + b.color.a * wb; if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 }; const ch = (k) => (a.color[k] * a.color.a * wa + b.color[k] * b.color.a * wb) / alpha; return { r: ch('r'), g: ch('g'), b: ch('b'), a: alpha }; } /*** 作用:求百分比变量(--ua-alpha-*),返回带 % 的字符串 ***/ function resolvePct(expr, vars) { const v = /^var\(\s*(--[\w-]+)\s*\)$/.exec(expr.trim()); if (!v || !vars.has(v[1])) throw new Error(`未定义的百分比令牌:${expr}`); return vars.get(v[1]).trim(); } /*** 作用:把半透明前景压到底色上(WCAG 只看合成后的实际观感) ***/ function over(fg, bg) { if (fg.a >= 1) return fg; const ch = (k) => fg[k] * fg.a + bg[k] * (1 - fg.a); return { r: ch('r'), g: ch('g'), b: ch('b'), a: 1 }; } /*** 作用:WCAG 相对亮度(sRGB 分段线性化,系数取自规范) ***/ function luminance(c) { const lin = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b); } /*** 作用:两色对比度(1~21),前景先合成到底色上再算;半透明底由调用方压到页面底色上 ***/ function ratio(fg, bg) { const solid = bg; const f = over(fg, solid); const l1 = Math.max(luminance(f), luminance(solid)); const l2 = Math.min(luminance(f), luminance(solid)); return (l1 + 0.05) / (l2 + 0.05); } /*** 作用:构建四套主题的变量表:亮/暗 × 常规/高对比(高对比块在文件末尾的媒体查询里,同名变量覆盖即生效) ***/ export function loadThemes(root) { const css = readText(path.join(root, TOKENS_REL)); const light = declsOf(blockOf(css, ':root')); const dark = new Map([...light, ...declsOf(blockOf(css, "[data-theme='dark']"))]); // 系统「提高对比度」偏好:媒体查询里只覆盖部分变量,未覆盖的沿用常规档,因此以对应主题为底再叠加 const media = blockOf(css, '@media (prefers-contrast: more)'); const contrastLight = new Map([...light, ...declsOf(blockOf(media, ':root'))]); const contrastDark = new Map([...dark, ...declsOf(blockOf(media, "[data-theme='dark']"))]); return { light, dark, contrastLight, contrastDark }; } /*** 作用:跑一遍全部配对,返回每个主题的明细(纯数据,供 CLI 与测试共用) ***/ export function auditContrast(root) { const themes = loadThemes(root); // 半透明底色(tint / overlay)必须先压到本主题的页面底色上,否则算出来的是「压在白色上」的假对比度 const binder = (vars) => { const pageKey = '--ua-color-bg'; if (!vars.has(pageKey)) throw new Error(`缺少页面底色令牌:${pageKey}`); const page = resolve(vars.get(pageKey), vars); return (name) => { const key = `--ua-color-${name}`; if (!vars.has(key)) throw new Error(`配对引用了不存在的令牌:${key}`); return over(resolve(vars.get(key), vars), page); }; }; const lightOf = binder(themes.light); const darkOf = binder(themes.dark); const contrastLightOf = binder(themes.contrastLight); const contrastDarkOf = binder(themes.contrastDark); const rows = PAIRS.map((pair) => { const lightRatio = ratio(lightOf(pair.fg), lightOf(pair.bg)); const darkRatio = ratio(darkOf(pair.fg), darkOf(pair.bg)); const contrastLightRatio = ratio(contrastLightOf(pair.fg), contrastLightOf(pair.bg)); const contrastDarkRatio = ratio(contrastDarkOf(pair.fg), contrastDarkOf(pair.bg)); const bad = []; if (lightRatio < pair.min) bad.push('亮色'); if (darkRatio < pair.min) bad.push('暗色'); if (contrastLightRatio < pair.min) bad.push('高对比亮'); if (contrastDarkRatio < pair.min) bad.push('高对比暗'); return { ...pair, light: lightRatio, dark: darkRatio, contrastLight: contrastLightRatio, contrastDark: contrastDarkRatio, bad }; }); return { rows, failures: rows.filter((r) => r.bad.length > 0) }; } /*** 作用:把明细打成对齐的文本表(人读模式,CLI 默认输出) ***/ export function formatReport({ rows, failures }) { const fixed = (n) => n.toFixed(2).padStart(6); const lines = ['配对 亮色 暗色 高亮 高暗 阈值 结论']; for (const r of rows) { const ok = r.bad.length === 0 ? 'OK' : `FAIL(${r.bad.join('+')})`; lines.push(`${r.label.padEnd(24)}${fixed(r.light)} ${fixed(r.dark)} ${fixed(r.contrastLight)} ${fixed(r.contrastDark)} ${String(r.min).padStart(4)} ${ok}`); } lines.push(''); lines.push(failures.length === 0 ? '全部配对达标' : `不达标 ${failures.length} 条:${failures.map((f) => f.label).join(' / ')}`); return lines.join('\n'); }