Files
novalon-website/scripts/audit/hardcoded-color-audit.mjs
T
zhangxiang 846585af1f refactor(theme): 营销页 bg-white token 化(暗黑模式 Phase 2 · marketing 批次)
问题:Tailwind 原生 bg-white 是硬编码值,深色主题下不翻转,
导致整页/整 section 恒为纯白,与转深色的页头页脚割裂。
(globals.css:--color-bg-primary-rgb 浅色 255 255 255 → 深色 10 14 20)

改动:10 文件 67 处 bg-white → 项目 token,按语义二分类
- bg-bg-primary  (33 处):页面/大块骨架(min-h-screen 容器、<section>、<main>)
- bg-bg-elevated (34 处):浮起层(带 border 的卡片、表单输入、图标盒、grid 单元格)
  --color-bg-elevated 浅色 #FFFFFF → 深色 #151B23,同样翻转。

浅色主题下 bg-white / bg-bg-primary / bg-bg-elevated 三者同值(纯白),
故本次改动在浅色主题下零像素差异,只修复深色主题。

新增审计工具:
- scripts/audit/hardcoded-color-audit.mjs:硬编码颜色分级盘点
  (含 alpha 误报修正:bg-white/NN 半透明叠加两主题均成立,判 INFO 非 P0)
- scripts/audit/darkmode-bg-verify.mjs:直接验证背景随主题翻转
  (浅色应纯白、深色应 rgb(10,14,20) 且无残留纯白块)

审计结果:marketing P0 67 → 0;全站 P0 97 → 30
(剩余 components 16 / other 8 / layout 3 / ui-kit 3 为第二批;admin 本就为 0)

门禁:
- tsc 0 error
- eslint 0 error(50 warnings 全为既有,改动文件未新增)
- jest 129/130 套件通过,1548 通过 / 2 跳过 / **0 断言失败**
  唯一失败 src/lib/admin-api.test.ts 为**沙箱环境限制非代码问题**:
  CODEBUDDY_BROKER_DENY 拦截 readFileSync,单独复验同样失败,
  与本次改动零交集(改动仅限 src/app/(marketing)/ 下 10 个页面组件)
- 暗黑模式背景验证 18/18 PASS(9 路由 × 浅深双版)
2026-09-04 13:46:29 +08:00

169 lines
7.6 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.
// 硬编码颜色审计(暗黑模式 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: '纯白背景不翻转' },
// 背景:深色背景在浅色主题下是黑块 —— 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';
}
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;
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: rule.sev, why: rule.why,
area: areaOf(f),
});
}
}
});
}
// 聚合
const byFile = {};
for (const r of rows) {
byFile[r.file] ??= { P0: 0, P1: 0, P2: 0, INFO: 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 };
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 };
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 |');
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} |`);
}
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`);