363 lines
13 KiB
JavaScript
363 lines
13 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* =============================================================================
|
|||
|
|
* 企微IT智能服务台 — Design Tokens WCAG 2AA 对比度自动校验脚本
|
|||
|
|
* =============================================================================
|
|||
|
|
* 触发:CI(PR / 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 多次出现时累积 token(CSS 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,
|
|||
|
|
};
|