Files
novalon-website/scripts/utils/check-color-contrast.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

236 lines
9.7 KiB
TypeScript
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.
import { readFileSync } from 'fs';
import { join } from 'path';
import { meetsWCAGStandard } from '../../src/lib/color-contrast';
/**
* 对比度门禁(验收 A-11-1 重写)。
*
* 旧实现是 7 组硬编码十六进制(全部 #FFFFFF 底),既不读令牌表也不含暗色配对 ——
* 主题令牌改了它仍对陈旧色值报绿,因此全站 62 页的暗色违规能从这道门禁底下漏过。
* 现在:色值一律从 globals.css 的两个主题根解析,缺令牌即失败(不允许静默跳过)。
*/
const THEME_BLOCKS = [
{ theme: 'light', selector: ':root' },
{ theme: 'dark', selector: "html[data-theme='dark']" },
] as const;
interface Pair {
foreground: string;
background: string;
/** 前景 alpha(0-1),用于检验带 alpha 修饰类的正文(如 text-text-secondary 80%) */
alpha?: number;
textSize?: 'normal' | 'large';
}
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token。 */
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token,两主题各解一次。 */
const REQUIRED_GROUPS: Array<{ label: string; pairs: Pair[] }> = [
{
label: '正文层级 × 页面底色',
pairs: [
{ foreground: '--color-text-primary', background: '--color-bg-primary' },
{ foreground: '--color-text-primary', background: '--color-bg-secondary' },
{ foreground: '--color-text-primary', background: '--color-bg-tertiary' },
{ foreground: '--color-text-secondary', background: '--color-bg-primary' },
{ foreground: '--color-text-secondary', background: '--color-bg-secondary' },
{ foreground: '--color-text-tertiary', background: '--color-bg-primary' },
{ foreground: '--color-text-muted', background: '--color-bg-primary' },
],
},
{
label: '品牌红文字通道(text-brand-ink 及 hover 档)',
pairs: [
{ foreground: '--color-brand-ink', background: '--color-bg-primary' },
{ foreground: '--color-brand-ink', background: '--color-bg-secondary' },
{ foreground: '--color-brand-ink', background: '--color-brand-bg' },
{ foreground: '--color-brand-ink-hover', background: '--color-bg-primary' },
{ foreground: '--color-brand-ink-hover', background: '--color-brand-bg' },
// 红底按钮的真实组合是 bg-brand + text-white(button.tsx:14,text-sm 属 normal 字号);
// --color-text-inverse 全仓 0 引用且暗色翻成墨色,不构成 UI 组合,故此处用字面量白。
{ foreground: '#FFFFFF', background: '--color-brand' },
],
},
{
label: 'alpha 修饰正文(带 /80 等修饰类的正文)',
pairs: [{ foreground: '--color-text-secondary', background: '--color-bg-primary', alpha: 0.8 }],
},
{
// 验收 A-12 残留:装饰序号/占位字(aria-hidden + ≥24px)。axe 的 color-contrast 不认 aria-hidden
// (实测 4.11.4 仍按可见文字判定),所以这类文字必须自己过 AA 大字 3:1,不能再靠低 alpha 淡化。
label: '大号装饰文本(text-text-hint)× 各级卡片底色',
pairs: [
{ foreground: '--color-text-hint', background: '--color-bg-primary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-secondary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-tertiary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-elevated', textSize: 'large' },
],
},
{
label: '深色区块(不随主题翻转的独立 token)',
pairs: [
{ foreground: '--color-dark-text-primary', background: '--color-dark-bg' },
{ foreground: '--color-dark-text-secondary', background: '--color-dark-bg' },
{ foreground: '--color-dark-text-muted', background: '--color-dark-bg' },
{ foreground: '--color-brand-section-text', background: '--color-brand-section' },
],
},
];
function extractBlock(css: string, selector: string): string {
const start = css.indexOf(`${selector} {`);
if (start < 0) throw new Error(`globals.css 中找不到主题根:${selector}`);
let depth = 0;
let cursor = css.indexOf('{', start);
const bodyStart = cursor + 1;
while (cursor < css.length) {
const ch = css[cursor];
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) break;
}
cursor += 1;
}
return css.slice(bodyStart, cursor);
}
function parseTokens(block: string): Map<string, string> {
const tokens = new Map<string, string>();
for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
const name = match[1] as string;
const value = (match[2] as string).trim();
tokens.set(name, tokens.get(name) ?? value);
}
return tokens;
}
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
function parseColor(raw: string, tokens: Map<string, string>, depth = 0): Rgba {
if (depth > 5) throw new Error(`var() 递归超过 5 层:${raw}`);
const value = raw.startsWith('var(') ? resolveVar(raw, tokens) : raw;
if (value !== raw && value.startsWith('var(')) return parseColor(value, tokens, depth + 1);
const hex = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i)?.[1];
if (hex) {
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
a: 1,
};
}
const rgb = value.match(/^rgba?\(([^)]+)\)$/);
if (rgb) {
const parts = (rgb[1] as string).split(/[\s,]+/).map((p) => Number.parseFloat(p));
const [r, g, b, a] = [parts[0], parts[1], parts[2], parts[3]];
if ([r, g, b].some((n) => n === undefined || Number.isNaN(n))) throw new Error(`无法解析颜色:${value}`);
return { r: r ?? 0, g: g ?? 0, b: b ?? 0, a: a === undefined || Number.isNaN(a) ? 1 : a };
}
throw new Error(`无法解析颜色:${raw}`);
}
function resolveVar(raw: string, tokens: Map<string, string>): string {
const inner = raw.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/);
if (!inner) return raw;
const name = inner[1] as string;
const fallback = inner[2];
const value = tokens.get(name) ?? fallback;
if (value === undefined) throw new Error(`令牌未定义:${name}`);
return value.trim();
}
function composite(fg: Rgba, bg: Rgba): Rgba {
const a = fg.a;
return {
r: Math.round(fg.r * a + bg.r * (1 - a)),
g: Math.round(fg.g * a + bg.g * (1 - a)),
b: Math.round(fg.b * a + bg.b * (1 - a)),
a: 1,
};
}
function toHex({ r, g, b }: Rgba): string {
const hex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return `#${hex(r)}${hex(g)}${hex(b)}`.toUpperCase();
}
function resolve(tokenName: string, tokens: Map<string, string>): string {
if (tokenName.startsWith('#')) return tokenName;
const raw = tokens.get(tokenName);
if (raw === undefined) throw new Error(`主题令牌缺失:${tokenName}`);
return raw;
}
function check() {
const cssPath = join(process.cwd(), 'src', 'app', 'globals.css');
const css = readFileSync(cssPath, 'utf8');
let passes = 0;
const failures: string[] = [];
const missing: string[] = [];
for (const { theme, selector } of THEME_BLOCKS) {
const own = parseTokens(extractBlock(css, selector));
// 按 CSS 层叠语义取色:暗色块只声明需要翻转的令牌,其余沿用 :root。
// 不做层叠就会把「刻意不翻」的底色通道误报成令牌缺失(DESIGN.md 双通道红规则)。
const tokens = theme === 'dark' ? new Map([...parseTokens(extractBlock(css, ':root')), ...own]) : own;
console.log(`\n🎨 ${theme.toUpperCase()} 主题(${selector},${tokens.size} 个令牌)`);
for (const group of REQUIRED_GROUPS) {
console.log(` ${group.label}`);
for (const pair of group.pairs) {
let label = `${pair.foreground} on ${pair.background}${pair.alpha !== undefined ? ` @${pair.alpha * 100}%` : ''}`;
try {
const fgToken = parseColor(resolve(pair.foreground, tokens), tokens);
const bgToken = parseColor(resolve(pair.background, tokens), tokens);
const fg = pair.alpha === undefined ? fgToken : { ...fgToken, a: pair.alpha };
const composited = composite(fg, bgToken);
const bgHex = toHex(bgToken);
const fgHex = toHex(composited);
const result = meetsWCAGStandard(fgHex, bgHex, 'AA', pair.textSize ?? 'normal');
const ratio = result.ratio.toFixed(2);
if (result.passes) {
passes += 1;
console.log(` ✅ ${label} — ${ratio}:1`);
} else {
failures.push(`${theme} · ${label} = ${ratio}:1(需 ${result.requiredRatio}:1;${fgHex} on ${bgHex})`);
console.log(` ❌ ${label} — ${ratio}:1(需 ${result.requiredRatio}:1)`);
}
} catch (error) {
missing.push(`${theme} · ${label} — ${(error as Error).message}`);
console.log(` ❌ ${label} — ${(error as Error).message}`);
}
}
}
}
console.log(`\n📊 合计 ${passes + failures.length + missing.length} 组:✅ ${passes} / ❌ ${failures.length} 不达标 / ⚠️ ${missing.length} 令牌缺失`);
if (failures.length > 0) {
console.log('\n不达标:');
failures.forEach((f) => console.log(` · ${f}`));
}
if (missing.length > 0) {
console.log('\n令牌缺失(门禁视为失败,避免对着陈旧色值报绿):');
missing.forEach((m) => console.log(` · ${m}`));
}
if (failures.length > 0 || missing.length > 0) {
console.log('\n⚠️ 存在不满足 WCAG 2.1 AA 的令牌配对');
process.exit(1);
}
console.log('\n✅ 全部令牌配对满足 WCAG 2.1 AA');
process.exit(0);
}
check();