问题: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 路由 × 浅深双版)
169 lines
7.6 KiB
JavaScript
169 lines
7.6 KiB
JavaScript
// 硬编码颜色审计(暗黑模式 Phase 2 盘点)
|
||
//
|
||
// 权威约束:CONTEXT.md / globals.css
|
||
// - 项目 token(bg-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`);
|