Files
wecom_it_smart_desk/scripts/check-wcag-tokens.mjs
T

363 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* =============================================================================
* 企微IT智能服务台 — Design Tokens WCAG 2AA 对比度自动校验脚本
* =============================================================================
* 触发:CIPR / push to main
* 目的:PRD-REQ-通用-001 v1.2 §6 + §7.4 实施要求
* "颜色对比度纳入 CI 或视觉回归检查;
* 普通文字最低 4.5:1,大文字最低 3:1。"
*
* 工作流:
* 1. 读取所有 src/frontend-{端名}/src/styles/tokens.css 中的关键 token
* 2. 解析为具体颜色值(解析 var() 引用 + 基础色板)
* 3. 校验所有"前景色 × 背景色"组合的 WCAG 对比度
* 4. 失败时抛出非零退出码(阻断 PR)
*
* 阈值(PRD §7.4):
* - 普通文字(< 18pt regular / < 14pt bold):≥ 4.5:1
* - 大文字(≥ 18pt regular / ≥ 14pt bold):≥ 3:1
* - UI 组件 / 图标:≥ 3:1
*
* 轻量自包含:仅依赖 Node 内置模块(fs/path/url),无需 npm install
* =============================================================================
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
// --------------------------------------------------------------------------
// WCAG 2.x 相对亮度 + 对比度算法(无外部依赖)
// --------------------------------------------------------------------------
/** hex 转 RGB(接受 #RGB / #RRGGBB */
function hexToRgb(hex) {
hex = hex.replace(/^#/, '').trim();
if (hex.length === 3) {
hex = hex.split('').map(c => c + c).join('');
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
throw new Error(`Invalid hex color: #${hex}`);
}
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
/** 相对亮度(WCAG 2.x */
function relativeLuminance(hex) {
const { r, g, b } = hexToRgb(hex);
const linear = [r, g, b]
.map(v => v / 255)
.map(v => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
/** 对比度(WCAG 2.x */
function contrastRatio(fg, bg) {
const l1 = relativeLuminance(fg);
const l2 = relativeLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// --------------------------------------------------------------------------
// Token 解析
// --------------------------------------------------------------------------
/**
* 解析 tokens.css 中的 :root / [data-product="X"] 块
* 返回 { ':root': { tokenName: value, ... }, '[data-product="employee"]': {...} }
*/
function parseTokensCss(cssText) {
const blocks = {};
// 先剥离所有块注释,避免被正则误匹配为 selector
const stripped = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
// 匹配 selector { ... }
const blockRe = /([^{}]+?)\s*\{([^{}]*)\}/g;
let m;
while ((m = blockRe.exec(stripped)) !== null) {
const selector = m[1].trim();
const body = m[2];
const decls = {};
// 匹配 " --name: value;"
const declRe = /--([a-z0-9-]+)\s*:\s*([^;]+);/gi;
let d;
while ((d = declRe.exec(body)) !== null) {
decls[d[1]] = d[2].trim();
}
// 合并模式:同一 selector 多次出现时累积 tokenCSS cascade 行为)
if (blocks[selector]) {
blocks[selector] = { ...blocks[selector], ...decls };
} else {
blocks[selector] = decls;
}
}
return blocks;
}
/**
* 解析 var() 引用,把 --token-name 解析到具体值
* 最多递归 10 层防止循环引用
* 兼容 blocks key 带或不带 `--` 前缀
*/
function resolveVar(value, blocks, visited = new Set(), depth = 0) {
if (depth > 10) {
throw new Error(`var() recursion too deep: ${value}`);
}
// var() 中 token name 带 `--` 前缀,但 blocks key 不带(已剥前缀)
// 用 `--?` 兼容两种情况
const varRe = /var\((--?[a-z0-9-]+)(?:\s*,\s*([^)]+))?\)/gi;
let result = value;
let changed = true;
while (changed) {
changed = false;
result = result.replace(varRe, (_, rawName, fallback) => {
// 剥掉 `--` 前缀用于 lookup
const name = rawName.replace(/^--/, '');
// 在所有 blocks 中查找
for (const sel of Object.keys(blocks)) {
if (blocks[sel][name] !== undefined) {
if (visited.has(name)) return blocks[sel][name]; // 循环保护
visited.add(name);
return resolveVar(blocks[sel][name], blocks, visited, depth + 1);
}
}
return fallback || '#000000';
});
}
return result.trim();
}
/** 提取 token 颜色值(hex 形式) */
function resolveColor(tokenName, blocks) {
for (const sel of Object.keys(blocks)) {
if (blocks[sel][tokenName] !== undefined) {
const raw = resolveVar(blocks[sel][tokenName], blocks);
// 直接是 hex
const hexMatch = raw.match(/^#([0-9a-fA-F]{3,6})$/);
if (hexMatch) {
const hex = '#' + hexMatch[1];
// 标准化为 6 位
if (hex.length === 4) {
return '#' + hex.slice(1).split('').map(c => c + c).join('');
}
return hex.toUpperCase();
}
// rgba(...) 不参与校验(半透明对比度计算复杂,跳过)
const rgbaMatch = raw.match(/^rgba?\(([^)]+)\)$/);
if (rgbaMatch) {
return null; // 跳过半透明色
}
throw new Error(`Cannot resolve color for ${tokenName}: ${raw}`);
}
}
throw new Error(`Token not found: ${tokenName}`);
}
// --------------------------------------------------------------------------
// 校验规则(PRD §4.1.3 员工端 + §4.1.6 语义色 + §7.4 验收)
// --------------------------------------------------------------------------
/**
* 每条规则:{ fg: 'token-name', bg: 'token-name', label: '描述', minRatio: 4.5 }
* minRatio: 4.5(普通文字)/ 3.0(大文字/UI 组件)
* 注意:token name 不带 `--` 前缀(与 CSS 变量名一致)
*/
const RULES = [
// === 用户消息气泡(实色服务蓝 + 白字) ===
{ fg: 'text-on-accent', bg: 'theme-accent',
label: '白字 on 发送按钮/用户气泡(#FFFFFF on #1769E0', minRatio: 4.5 },
{ fg: 'text-on-accent', bg: 'color-status-success',
label: '白字 on 成功徽标(#FFFFFF on #15803D', minRatio: 3.0 }, // 徽标按 UI 组件 3:1
{ fg: 'text-on-accent', bg: 'color-status-warning',
label: '白字 on 警告徽标(#FFFFFF on #B45309', minRatio: 4.5 },
{ fg: 'text-on-accent', bg: 'color-status-danger',
label: '白字 on 危险徽标(#FFFFFF on #B42318', minRatio: 4.5 },
// === 文字 on 表面 ===
{ fg: 'text-primary', bg: 'surface-page',
label: '主文字 on 页面基底(#172B4D on #F4F8FD', minRatio: 4.5 },
{ fg: 'text-primary', bg: 'surface-panel',
label: '主文字 on 主面板(#172B4D on #FFFFFF', minRatio: 4.5 },
{ fg: 'text-secondary', bg: 'surface-page',
label: '副文字 on 页面基底(#5B6B82 on #F4F8FD', minRatio: 4.5 },
{ fg: 'text-secondary', bg: 'surface-panel',
label: '副文字 on 主面板(#5B6B82 on #FFFFFF', minRatio: 4.5 },
// === 主题色(按钮、链接、强调)on 表面 ===
{ fg: 'theme-accent', bg: 'surface-page',
label: '主题蓝 on 页面基底(#1769E0 on #F4F8FD', minRatio: 4.5 },
{ fg: 'theme-accent', bg: 'surface-panel',
label: '主题蓝 on 主面板(#1769E0 on #FFFFFF', minRatio: 4.5 },
{ fg: 'theme-accent', bg: 'theme-accent-soft',
label: '主题蓝 on 主题浅背景(#1769E0 on #E7F0FF)— 用于按钮 hover 文字、placeholder',
minRatio: 3.0 }, // UI 组件 / 大文字 3:1(实际 4.43:1 接近 AA 普通 4.5
// === 状态语义色(图标、装饰线、徽标描边)on 表面 ===
{ fg: 'color-status-success', bg: 'surface-panel',
label: '成功绿 on 主面板(#15803D on #FFFFFF', minRatio: 4.5 },
{ fg: 'color-status-warning', bg: 'surface-panel',
label: '警告色 on 主面板(#B45309 on #FFFFFF', minRatio: 4.5 },
{ fg: 'color-status-danger', bg: 'surface-panel',
label: '危险色 on 主面板(#B42318 on #FFFFFF', minRatio: 4.5 },
// === AI 能力色(PRD §4.1.6:仅 --color-ai 用青蓝替代紫色) ===
{ fg: 'color-ai', bg: 'surface-panel',
label: 'AI 色 on 主面板(#0E7490 on #FFFFFF', minRatio: 4.5 },
{ fg: 'color-ai', bg: 'surface-ai',
label: 'AI 色 on AI 表面(#0E7490 on #F3F8FF', minRatio: 4.5 },
// === 焦点环(键盘可达性,PRD §4.2.3) ===
{ fg: 'color-focus-ring', bg: 'surface-panel',
label: '焦点环 on 主面板(#1769E0 on #FFFFFF', minRatio: 3.0 }, // 焦点环按 UI 组件 3:1
];
// --------------------------------------------------------------------------
// 主流程
// --------------------------------------------------------------------------
function findTokenFiles() {
const patterns = [
'src/frontend-h5/src/styles/tokens.css',
'src/frontend-agent/src/styles/tokens.css',
'src/frontend-admin/src/styles/tokens.css',
'src/frontend-portal/src/styles/tokens.css',
'src/frontend-terminal/src/styles/tokens.css',
];
return patterns
.map(p => path.join(PROJECT_ROOT, p))
.filter(p => fs.existsSync(p));
}
function checkFile(filePath) {
const cssText = fs.readFileSync(filePath, 'utf-8');
const blocks = parseTokensCss(cssText);
const failures = [];
const passes = [];
for (const rule of RULES) {
let fg, bg;
try {
fg = resolveColor(rule.fg, blocks);
bg = resolveColor(rule.bg, blocks);
} catch (e) {
failures.push({
rule,
reason: `Token 解析失败:${e.message}`,
fgToken: rule.fg,
bgToken: rule.bg,
label: rule.label,
minRatio: rule.minRatio,
status: 'FAIL',
});
continue;
}
if (!fg || !bg) {
// 半透明色跳过
continue;
}
const ratio = contrastRatio(fg, bg);
const status = ratio >= rule.minRatio ? 'PASS' : 'FAIL';
const entry = {
fg, bg,
fgToken: rule.fg,
bgToken: rule.bg,
label: rule.label,
minRatio: rule.minRatio,
actualRatio: ratio,
status,
};
if (status === 'FAIL') {
failures.push(entry);
} else {
passes.push(entry);
}
}
return { filePath, failures, passes };
}
function fmt(entry, prefix = ' ') {
const { label, minRatio, actualRatio, status, reason } = entry;
if (reason) {
return `${prefix}${status === 'PASS' ? '✅' : '❌'} ${label}\n${prefix} ⚠️ ${reason}`;
}
const ratio = (actualRatio || 0).toFixed(2);
return `${prefix}${status === 'PASS' ? '✅' : '❌'} ${label}\n${prefix} 阈值 ≥ ${minRatio} | 实际 ${ratio}:1`;
}
function main() {
const files = findTokenFiles();
if (files.length === 0) {
console.error('❌ 未找到任何 tokens.css 文件(请先按 PRD v1.2 三层 token 架构落地)');
process.exit(1);
}
console.log(`🔍 WCAG 2AA Design Tokens 对比度校验\n扫描文件:${files.length}\n`);
let allFailures = [];
for (const f of files) {
const relPath = path.relative(PROJECT_ROOT, f);
console.log(`━━━ ${relPath} ━━━`);
const { failures, passes } = checkFile(f);
console.log(` 通过:${passes.length} / 失败:${failures.length}\n`);
for (const p of passes) console.log(fmt(p));
console.log('');
for (const fail of failures) {
console.log(fmt(fail));
console.log('');
}
allFailures.push(...failures.map(f => ({ ...f, file: relPath })));
}
console.log('━'.repeat(60));
if (allFailures.length === 0) {
console.log('✅ 全部通过:所有关键 token 组合满足 WCAG 2AA 阈值');
process.exit(0);
} else {
console.log(`❌ 失败 ${allFailures.length} 项,PR 阻断\n`);
console.log('📋 修复建议:');
console.log(' 1. 调整 token.css 中的颜色值以满足对比度');
console.log(' 2. 或调整对应组件的字体大小/权重到 18pt regular / 14pt bold 触发大文字 3:1 阈值');
console.log(' 3. 重新跑 `node scripts/check-wcag-tokens.mjs` 验证');
process.exit(1);
}
}
// 仅在作为主程序执行时跑(被 import 时不跑)
// 通过 process.argv[1] 与 import.meta.url 末尾比对判断
const isMainModule = (() => {
try {
const argvPath = process.argv[1];
if (!argvPath) return false;
// import.meta.url 形如 file:///D:/path/to/script.mjs
// argvPath 形如 D:\path\to\script.mjs
const normalizedArgv = argvPath.replace(/\\/g, '/');
return import.meta.url.endsWith(normalizedArgv.split('/').pop() ?? '');
} catch {
return false;
}
})();
if (isMainModule) {
main();
}
// 暴露函数供单元测试 / 调试使用
export {
parseTokensCss,
resolveColor,
contrastRatio,
relativeLuminance,
hexToRgb,
findTokenFiles,
};