Files
novalon-website/scripts/audit/hardcoded-color-audit.mjs
zhangxiang 441a748f32 chore(audit): UI 审计工具链增强与噪音根治
- ui-audit.mjs:路由发现扩展至 33 路由(standalone 构建产物 + erp-upgrade 系列);
  GA/gtm 请求 mock(gtag/js 空 JS + collect 204);第三方统计噪音与同源
  ERR_ABORTED(重调度成功/prefetch)降级 INFO;LCP/CLS 断言按系统负载门控
  (load >= 5 跳过并注明,防测量环境假阳性);LCP 超阈值热缓存 reload 复测
  取优(lcpCold 留存冷值);clip 探针 sr-only 白名单;onReqFail 防御性修复
- ui-audit-report.mjs(新增):多主题探针聚合 HTML 报告,含截图画廊与 INFO 分级
- hardcoded-color-audit.mjs:bg-black/50 半透明遮罩误报修正(负向前瞻)+ 白名单
- 新增 brand-usage-scan.mjs / brand-ink-migrate.mjs(品牌令牌迁移辅助)
2026-09-20 11:50:58 +08:00

205 lines
10 KiB
JavaScript
Raw Permalink 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.
// 硬编码颜色审计(暗黑模式 Phase 2 盘点)
//
// 权威约束:CONTEXT.md / globals.css
// - 项目 tokenbg-bg-primary / text-text-primary / border-border-primary 等)
// 已重映射到 --color-*-rgb,在 html[data-theme='dark'] 下**会翻转**。
// - Tailwind 原生色(bg-white / bg-gray-100 / text-gray-900 等)是**硬编码值**
// 深色主题下**不翻转** → 浅底/深字在深底上不可读。
//
// 判定准则(务必区分,勿一刀切):
// P0 背景类浅色 bg-white / bg-gray-50..300 → 深底上仍是白块,视觉破损
// P0 文字类深色 text-gray-600..900 / text-black → 深底上对比度不足
// P1 边框浅色 border-gray-100..300 → 深底上过亮,层次错乱
// INFO 需人工判 text-white / border-white / bg-black
// (品牌红按钮白字、深色覆盖层白字是**正确**的,不是违规)
// INFO 半透明叠加 bg-white/10 / bg-black/20 等带 alpha 修饰符
// ——alpha 叠加是相对底色取混合,浅深两主题下**都成立**,非违规
//
// 排除:_archive / *.test.* / *.spec.* / node_modules
//
// CLI: node scripts/audit/hardcoded-color-audit.mjs [--json]
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
const OUT = 'dogfood-color-audit';
mkdirSync(OUT, { recursive: true });
const files = execSync(
"find src -type f \\( -name '*.ts' -o -name '*.tsx' \\) " +
"! -path '*/_archive/*' ! -name '*.test.*' ! -name '*.spec.*' | sort",
{ encoding: 'utf8' }
).trim().split('\n');
// Tailwind 原生中性色(会随主题翻转的 token 色不在其中)
const NEUTRAL = '(?:white|black|gray|slate|neutral|zinc|stone)';
// 规则表:顺序敏感,先匹配更具体的
const RULES = [
// 半透明叠加层:alpha 混合相对底色取值,浅深两主题下都成立 —— 非违规
// (务必放在实色规则之前,并用 (?!\/) 排除实色规则误吃 alpha 写法)
{ re: /\bbg-(?:white|black)\/\d+\b/g, sev: 'INFO', why: '半透明叠加层,两主题均成立' },
{ re: new RegExp(`\\b(?:text|border|divide)-${NEUTRAL}-\\d+/\\d+\\b`, 'g'), sev: 'INFO', why: '半透明叠加层,两主题均成立' },
// 背景:浅色背景在深底上变成白块 —— P0
{ re: new RegExp(`\\bbg-${NEUTRAL}-(?:50|100|200|300)(?!/)\\b`, 'g'), sev: 'P0', why: '浅色背景不翻转' },
{ re: /\bbg-white(?!\/)\b/g, sev: 'P0', why: '纯白背景不翻转' },
{ re: /\bbg-black(?!\/)\b/g, sev: 'P0', why: '纯黑背景不翻转' },
// 背景:深色背景在浅色主题下是黑块 —— P0(对称违规)
{ re: new RegExp(`\\bbg-${NEUTRAL}-(?:800|900|950)\\b`, 'g'), sev: 'P0', why: '深色背景不翻转' },
{ re: /\bbg-black(?!\/)\b/g, sev: 'P0', why: '纯黑背景不翻转' },
{ re: new RegExp(`\\bbg-${NEUTRAL}-(?:400|500|600|700)\\b`, 'g'), sev: 'P1', why: '中性背景不翻转' },
// 文字:深色文字在深底上不可读 —— P0
{ re: new RegExp(`\\btext-${NEUTRAL}-(?:600|700|800|900|950)\\b`, 'g'), sev: 'P0', why: '深色文字在深底上对比度不足' },
{ re: /\btext-black\b/g, sev: 'P0', why: '黑色文字在深底上不可读' },
{ re: new RegExp(`\\btext-${NEUTRAL}-(?:400|500)\\b`, 'g'), sev: 'P1', why: '中性文字不翻转' },
// 文字:浅色文字需人工判(品牌红按钮 / 深色覆盖层上是正确的)
{ re: /\btext-white\b/g, sev: 'INFO', why: '需人工判:品牌红按钮/深色覆盖层上为正确用法' },
{ re: new RegExp(`\\btext-${NEUTRAL}-(?:100|200|300)\\b`, 'g'), sev: 'INFO', why: '需人工判:深色底上为正确用法' },
// 边框:浅色边框在深底上过亮 —— P1
{ re: new RegExp(`\\bborder-${NEUTRAL}-(?:100|200|300)\\b`, 'g'), sev: 'P1', why: '浅色边框不翻转' },
{ re: new RegExp(`\\bborder-${NEUTRAL}-(?:700|800|900)\\b`, 'g'), sev: 'P1', why: '深色边框不翻转' },
{ re: /\bborder-white\b/g, sev: 'INFO', why: '需人工判' },
{ re: new RegExp(`\\bborder-${NEUTRAL}-(?:400|500|600)\\b`, 'g'), sev: 'P2', why: '中性边框不翻转' },
// 分割线 / 渐变 / 描边
{ re: new RegExp(`\\bdivide-${NEUTRAL}-(?:100|200|300)\\b`, 'g'), sev: 'P1', why: '分割线不翻转' },
{ re: new RegExp(`\\bplaceholder-${NEUTRAL}-\\d+\\b`, 'g'), sev: 'P2', why: '占位符文字不翻转' },
];
/** 按路径归类区域,便于分期推进 */
function areaOf(file) {
if (file.includes('/admin') || file.includes('app/admin')) return 'admin';
if (file.includes('/(marketing)/')) return 'marketing';
if (file.includes('/components/ui/')) return 'ui-kit';
if (file.includes('/components/layout/')) return 'layout';
if (file.includes('/components/')) return 'components';
return 'other';
}
// 恒定深色区块排除清单(2026-09-04 修正)
// 判据:这些文件的**根容器**使用 bg-dark-bg(深色专用 token**不随主题翻转**),
// 即该区块在设计上恒为深色。区块内的中性硬编码色(text-gray-400 / bg-gray-900 /
// border-gray-800)在恒定深底上是**正确用法**,不是「不翻转的 bug」。
// 未加此清单会把 footer 的 10 处正确用法误判为 P0/P1。
const CONSTANT_DARK = [
{ file: 'src/components/layout/footer.tsx', reason: '根容器 bg-dark-bg,恒定深色区块' },
];
// 恒定品牌色区块内的白色元素(反转设计)排除清单(2026-09-04 补)
// 判据:这些元素**所在区块恒定深色/品牌红**bg-brand-section / style backgroundColor var(--color-brand)
// 两主题都不翻转),区块上的白底元素 + 品牌红实色文字是高对比**反转设计**,不是「不翻转 bug」。
// 若未来这些区块改为随主题翻转,需同步删除对应条目。
const ALLOW_INVERTED_ON_BRAND = [
{ file: 'src/components/sections/case-detail-page.tsx', frag: 'bg-white rounded-full', reason: '品牌红指标块上的白底「核心指标」角标' },
{ file: 'src/components/detail/detail-cta-section.tsx', frag: 'px-10 py-4 bg-white text-brand', reason: '品牌深红 CTA 区块上的白底主按钮' },
{ file: 'src/components/ui/slider.tsx', frag: 'border-brand bg-white', reason: '滑轨上的白底圆钮(thumb),深色下仍需高对比可拖拽' },
{ file: 'src/components/ui/switch.tsx', frag: 'rounded-full bg-white shadow-lg ring-0', reason: '开关上的白底圆钮(thumb),深色下仍需高对比可拖拽' },
];
const constantDarkSet = new Set(CONSTANT_DARK.map((c) => c.file));
const rows = [];
for (const f of files) {
let src;
try { src = readFileSync(f, 'utf8'); } catch { continue; }
const lines = src.split('\n');
const seen = new Set(); // 同一行同一 class 只记一次
lines.forEach((line, i) => {
const ln = i + 1;
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
for (const rule of RULES) {
rule.re.lastIndex = 0;
// 反转设计行命中判定(须在 push 前算好,供行内违规降级 NA)
const inverted = ALLOW_INVERTED_ON_BRAND.find((a) => a.file === f && line.includes(a.frag));
const downgrade = inverted && (rule.sev === 'P0' || rule.sev === 'P1' || rule.sev === 'P2');
for (const m of line.matchAll(rule.re)) {
const key = `${ln}:${m[0]}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({
file: f, ln, cls: m[0],
sev: downgrade ? 'NA' : rule.sev,
why: downgrade ? '反转设计(恒定品牌/深色区块上的白元素)' : rule.why,
area: areaOf(f),
excludedReason: downgrade ? inverted.reason : undefined,
});
}
}
});
}
// 应用排除:恒定深色区块内的中性色降级为 NA(不计违规,保留记录可追溯)
for (const r of rows) {
if (constantDarkSet.has(r.file) && (r.sev === 'P0' || r.sev === 'P1' || r.sev === 'P2')) {
r.sev = 'NA';
r.excludedReason = CONSTANT_DARK.find((c) => c.file === r.file)?.reason ?? '';
}
}
// 聚合
const byFile = {};
for (const r of rows) {
byFile[r.file] ??= { P0: 0, P1: 0, P2: 0, INFO: 0, NA: 0, area: r.area, items: [] };
byFile[r.file][r.sev]++;
byFile[r.file].items.push(r);
}
const counts = { P0: 0, P1: 0, P2: 0, INFO: 0, NA: 0 };
for (const r of rows) counts[r.sev]++;
const byArea = {};
for (const r of rows) {
byArea[r.area] ??= { P0: 0, P1: 0, P2: 0, INFO: 0, NA: 0 };
byArea[r.area][r.sev]++;
}
const report = {
generatedAt: new Date().toISOString(),
scannedFiles: files.length,
totalMatches: rows.length,
counts,
byArea,
filesWithViolations: Object.entries(byFile)
.filter(([, v]) => v.P0 + v.P1 + v.P2 > 0)
.map(([file, v]) => ({ file, area: v.area, P0: v.P0, P1: v.P1, P2: v.P2, INFO: v.INFO }))
.sort((a, b) => (b.P0 - a.P0) || (b.P1 - a.P1) || (b.P2 - a.P2)),
allViolations: rows.filter((r) => r.sev !== 'INFO'),
};
writeFileSync(`${OUT}/color-audit.json`, JSON.stringify(report, null, 2));
// Markdown 摘要
const md = [];
md.push('# 硬编码颜色审计(暗黑模式 Phase 2 盘点)\n');
md.push(`- 扫描文件:${files.length}(排除 _archive / test / spec`);
md.push(`- 匹配总数:${rows.length}\n`);
md.push('## 按严重度\n');
md.push('| 级别 | 数量 | 含义 |');
md.push('|---|---|---|');
md.push(`| **P0** | ${counts.P0} | 深底上白块 / 深字不可读(真 bug) |`);
md.push(`| P1 | ${counts.P1} | 边框 / 中性色不翻转 |`);
md.push(`| P2 | ${counts.P2} | 占位符等次要 |`);
md.push(`| INFO | ${counts.INFO} | 需人工判(多为正确用法) |\n`);
md.push('## 按区域(分期依据)\n');
md.push('| 区域 | P0 | P1 | P2 | INFO | NA |');
md.push('|---|---|---|---|---|---|');
for (const [area, c] of Object.entries(byArea).sort((a, b) => b[1].P0 - a[1].P0)) {
md.push(`| ${area} | ${c.P0} | ${c.P1} | ${c.P2} | ${c.INFO} | ${c.NA ?? 0} |`);
}
md.push('');
md.push('## P0 文件排行(前 25\n');
md.push('| 文件 | 区域 | P0 | P1 | P2 |');
md.push('|---|---|---|---|---|');
for (const f of report.filesWithViolations.slice(0, 25)) {
md.push(`| \`${f.file}\` | ${f.area} | ${f.P0} | ${f.P1} | ${f.P2} |`);
}
writeFileSync(`${OUT}/color-audit.md`, md.join('\n'));
console.log(md.join('\n'));
console.log(`\n完整数据:${OUT}/color-audit.json`);