231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
/*** @ui-arch 对比度审计:把「文字色 × 底色」的语义配对算成 WCAG 对比度,做成可回归的门禁
|
|
*
|
|
* 为什么需要它:`audit:visual` 只保证「没有硬编码字面量」,令牌用对了但令牌本身可能对比度不够——
|
|
* 改一个 --ua-color-text-muted 就能让全站辅助文字跌到 4.4,人眼在亮底上未必察觉,暗色下更看不出来。
|
|
* 这里只做四步:解析令牌 → 求值(含 color-mix 混色)→ 按固定配对表算比值 → 与阈值比对。
|
|
* 配对表写死在代码里(而不是外置配置):它是「设计契约」,加一条配对等于承认一种新的文字/底色组合,
|
|
* 必须在评审里看见;阈值分三档(正文 7 / 常规文字 4.5 / 禁用态 2.6)对应 WCAG AA/AAA 与豁免口径。
|
|
*/
|
|
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 },
|
|
];
|
|
|
|
/*** 作用:读文件并归一换行,保证解析稳定 ***/
|
|
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');
|
|
}
|