// 硬编码颜色审计(暗黑模式 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: '纯白背景不翻转' }, { 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`);