iter(g242): 新增令牌对比度门禁,修掉暗色主按钮与淡底强调字不达标
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/*** @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);
|
||||
}
|
||||
|
||||
/*** 作用:构建两套主题的变量表:亮色 = :root,暗色 = :root 叠加 [data-theme='dark'] ***/
|
||||
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']"))]);
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
/*** 作用:跑一遍全部配对,返回每个主题的明细(纯数据,供 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 rows = PAIRS.map((pair) => {
|
||||
const lightRatio = ratio(lightOf(pair.fg), lightOf(pair.bg));
|
||||
const darkRatio = ratio(darkOf(pair.fg), darkOf(pair.bg));
|
||||
const bad = [];
|
||||
if (lightRatio < pair.min) bad.push('亮色');
|
||||
if (darkRatio < pair.min) bad.push('暗色');
|
||||
return { ...pair, light: lightRatio, dark: darkRatio, 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(26)}${fixed(r.light)} ${fixed(r.dark)} ${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');
|
||||
}
|
||||
Reference in New Issue
Block a user