/** * 深色主题的结构性检查。 * * 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 —— * 真正的视觉验收靠手工脚本(test/manual/theme-verify.mjs)。 * * 这些检查存在的理由:深色模式的 bug 形态是**白底白字**, * 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。 */ import { readFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const read = p => readFileSync(join(here, p), 'utf8'); let pass = 0; let fail = 0; const check = (name, ok, detail = '') => { if (ok) { pass++; console.log(` 通过 ${name}`); } else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); } }; const css = read('../src/index.css'); const cfg = read('../tailwind.config.js'); const html = read('../index.html'); /** * 剥掉注释后的 CSS。 * * 下面几处按 `indexOf(':root')` / `indexOf('.dark')` 切颜色块,而**注释里出现 * 选择器字面量会把块提前截断**:在 :root 的注释里写一句「深色主题里换值」 * (原文用了带点的选择器写法)就足以让浅色块在 color-scheme 之前被切开, * 于是第 8 条真假失败。更危险的是反向情形:块被截短后变量集合变小, * 「覆盖齐全」这类断言可能**真空通过**。 * * 所以所有块切分都基于剥注释后的文本。字符串字面量里的 /* 在本文件里 * 不存在,直接用非贪婪匹配去掉块注释即可。 */ const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); // 1) tailwind 必须走 class 策略。 // media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。 check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg)); // 2) 颜色必须经 CSS 变量。写死十六进制的话深色模式无从切换。 check( '调色板指向 CSS 变量', cfg.includes('rgb(var(') && cfg.includes(''), '缺少 rgb(var(--x) / ) 形态' ); // 3) 变量值必须是 RGB 三元组而不是 #hex。 // 代码里有 bg-blue-50/70 这类透明度修饰符,#hex 会生成无效 CSS, // 那些半透明高亮静默失效(不报错,只是不透明)。 const varLines = css.match(/--c-[a-z]+-?\d*:\s*[^;]+;/g) || []; const hexVars = varLines.filter(l => l.includes('#')); check( '色板变量存 RGB 三元组而非 #hex', varLines.length >= 100 && hexVars.length === 0, hexVars.length ? `${hexVars.length} 个变量是 hex:${hexVars[0]}` : `只找到 ${varLines.length} 个变量` ); // 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。 // 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。 const lightBlock = cssNoComments.slice( cssNoComments.indexOf(':root'), cssNoComments.indexOf('.dark') ); const darkBlock = cssNoComments.slice(cssNoComments.indexOf('.dark {')); const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || [])); const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || [])); const missing = [...lightVars].filter(v => !darkVars.has(v)); check( '.dark 覆盖了全部色板变量', lightVars.size >= 15 && missing.length === 0, missing.length ? `深色缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}` : `浅色只有 ${lightVars.size} 个` ); // 5) 灰阶必须真的反转:深色的 white 要比 gray-900 暗。 // 不反转的话组件里的 `bg-white text-gray-900` 在深色下依然是白底黑字。 const lum = (block, name) => { const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`)); if (!m) return null; return (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3; }; const darkWhite = lum(darkBlock, 'white'); const darkG900 = lum(darkBlock, 'gray-900'); check( '深色下灰阶已反转(white 比 gray-900 暗)', darkWhite !== null && darkG900 !== null && darkWhite < darkG900, `white=${darkWhite} gray-900=${darkG900}` ); // 6) 深色的页面底(gray-50)必须比卡片(white)更暗。 // 浅色下页面底比卡片浅,深色下要反过来 —— 否则卡片陷进背景失去边界。 const darkG50 = lum(darkBlock, 'gray-50'); check( '深色下页面底比卡片更暗', darkG50 !== null && darkWhite !== null && darkG50 < darkWhite, `gray-50=${darkG50} white=${darkWhite}` ); // 7) 次要文字(gray-400/500)在深色下必须提亮。 // 照搬浅色值只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— // 实际效果是「看得见但读不动」。 const lightG400 = lum(lightBlock, 'gray-400'); const darkG400 = lum(darkBlock, 'gray-400'); check( '深色下次要文字未沿用浅色值', darkG400 !== null && lightG400 !== null && Math.abs(darkG400 - lightG400) > 5, `light=${lightG400} dark=${darkG400}` ); // 8) color-scheme 两处都要设。 // 不设的话深色页面上会出现浅色滚动条与白底的 autofill 输入框。 check( ':root 与 .dark 都声明 color-scheme', /color-scheme:\s*light/.test(lightBlock) && /color-scheme:\s*dark/.test(darkBlock) ); // 9) index.html 必须有同步内联脚本消除首帧闪屏。 // bundle 有几百 KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色 —— // 深色用户每次刷新都被闪一下白屏。外链或 defer 都晚于首次绘制。 check( 'index.html 内联防闪屏脚本', html.includes('agentmail.theme') && html.includes('prefers-color-scheme') && html.includes("classList.add('dark')") && !/]+(src=|defer)[^>]*>[\s\S]*?agentmail\.theme/.test(html), '缺少同步内联脚本' ); // 10) 内联脚本与 themeStore 必须用同一个 localStorage 键。 // 不一致的后果是首帧按 A 键渲染、React 挂载后按 B 键重渲染 —— 闪一下再变回去 const store = read('../src/stores/themeStore.ts'); const keyInStore = store.match(/STORAGE_KEY\s*=\s*'([^']+)'/); check( '内联脚本与 themeStore 共用同一 storage 键', keyInStore !== null && html.includes(`'${keyInStore[1]}'`), keyInStore ? `store 用 ${keyInStore[1]}` : '未找到 STORAGE_KEY' ); // 11) body 必须有显式底色:移动端橡皮筋回弹露出的是 body 背景, // 不设的话深色下滑到边界会闪出白边。 check( 'body 有显式主题底色', /body\s*\{[^}]*background-color:\s*rgb\(var\(--c-/.test(css) ); // 12) 组件里不该残留写死的十六进制颜色。 // 它们不经变量,深色模式下不会变 —— 而这类遗漏只有肉眼能发现。 const compDir = join(here, '../src/components'); const offenders = []; for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) { const src = readFileSync(join(compDir, f), 'utf8'); // 只看 className 与 style 里的颜色;SVG 的 currentColor 不算 const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []); if (hits.length) offenders.push(`${f}(${hits.join(',')})`); } check( '组件里没有写死的十六进制颜色', offenders.length === 0, offenders.join(' ') ); // 13) 主题三态:system 不能被当成 light 的别名。 // 只给开关的话,白天设浅色之后晚上系统切深色应用不会跟着变 check( 'ThemePref 是三态且含 system', /'light'\s*\|\s*'dark'\s*\|\s*'system'/.test(store) && store.includes("=== 'system'") ); // 14) 只有 pref 为 system 时才跟随系统变化。 // 显式选了 light/dark 的人不该因为日落被切换主题 check( '仅 system 偏好跟随系统变化', /if\s*\(pref === 'system'\)/.test(store) ); // 15) text-white 必须与 bg-white 用不同的变量。 // `white` 服务两种冲突用途:卡片表面(深色下变暗)与彩色按钮上的文字 // (深色下必须保持浅色)。共用一个变量时后者跟着变暗 —— 实测激活导航项的 // 「收件」在 bg-chrome-700 上只剩 1.34:1,几乎看不见。 check( 'textColor.white 指向独立变量(不跟 bg-white 一起反转)', /textColor:\s*\{[^}]*white:\s*withAlpha\('--c-on-accent'\)/.test(cfg) && /--c-on-accent:/.test(lightBlock) && /--c-on-accent:/.test(darkBlock) ); // 16) --c-on-accent 在深色下必须仍然是浅色。 // 它变暗就是上一条描述的那个 bug。 const onAccentDark = lum(darkBlock, 'on-accent'); check( '深色下 on-accent 仍是浅色', onAccentDark !== null && onAccentDark > 200, `on-accent 亮度 ${onAccentDark}` ); // 17) 每个被 Tailwind 实际使用的 CSS 变量都必须在 index.css 有定义。 // // 这一条守的是本项目最贵的一次视觉 bug:tailwind.config.js 里 `colors` // 同时写了固定 hex 与 accent() 两份 red/green/amber/orange/yellow —— // JS 对象字面量重复键**后者胜出**(不报错、不警告),而 index.css 里当时 // 没有对应的变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让整条 // background-color 声明失效,于是 bg-red-600 退回透明、text-white 的白字 // 落在白卡片上:**按钮看不见但点得动**。32 个变量全部缺失, // 波及所有 red/green/amber/orange/yellow 的地方。 // // 判据必须走 resolveConfig 而不是正则扫配置文本:真正出问题的那批变量名 // 是 accent('red') 这类**模板拼出来的**,配置源码里根本没有 `--c-red-600` // 这个字面量 —— 扫文本会漏掉正是要防的那一类。 const resolveConfig = (await import('tailwindcss/resolveConfig.js')).default; const resolved = resolveConfig((await import('../tailwind.config.js')).default); const declared = new Set([...css.matchAll(/--[cs]-[a-z0-9-]+(?=\s*:)/g)].map(m => m[0])); const usedVars = new Set(); const walk = (v) => { if (typeof v === 'string') { for (const m of v.matchAll(/var\((--[a-z0-9-]+)/g)) usedVars.add(m[1]); } else if (v && typeof v === 'object') { for (const k of Object.keys(v)) walk(v[k]); } }; // 只看颜色相关的 theme 段:其余(spacing/fontSize/...)不走变量 for (const key of ['colors', 'textColor', 'backgroundColor', 'borderColor', 'ringColor', 'divideColor']) { walk(resolved.theme[key]); } const undef = [...usedVars].filter(v => !declared.has(v)); check( 'Tailwind 实际使用的每个变量都在 index.css 有定义', usedVars.size >= 100 && undef.length === 0, undef.length ? `${undef.length} 个未定义:${undef.slice(0, 6).join(', ')}` : `只解析出 ${usedVars.size} 个变量引用` ); // 18) colors 里每个颜色名只能定义一次。 // 重复键静默生效,是上一条那个 bug 的**成因**:读代码的人看到固定 hex // 以为在用它,实际生效的是下面那份 accent()。 const colorsBlock = cfg.slice(cfg.indexOf('colors: {')); const dupNames = []; for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow', 'gray', 'chrome']) { const n = (colorsBlock.match(new RegExp(`^\\s{8}${name}:`, 'gm')) || []).length; if (n > 1) dupNames.push(`${name}×${n}`); } check('colors 里没有重复的颜色名', dupNames.length === 0, dupNames.join(' ')); // 19) 强调色的**表面段**(50–300)深色下必须变暗。 // 照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 —— // 那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。 const surfaceOffenders = []; for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) { for (const shade of [50, 100, 200, 300]) { const l = lum(lightBlock, `${name}-${shade}`); const d = lum(darkBlock, `${name}-${shade}`); if (l === null || d === null) { surfaceOffenders.push(`${name}-${shade}:缺失`); continue; } if (d >= l) surfaceOffenders.push(`${name}-${shade}(${d}≥${l})`); } } check( '强调色表面段在深色下变暗', surfaceOffenders.length === 0, surfaceOffenders.slice(0, 6).join(' ') ); // 20) 强调色的**前景段**(400–900)在深色卡片上必须达到 WCAG AA 4.5:1。 // 照搬浅色值时 red-700 只有 2.67:1、amber-900 只有 1.90:1 —— // 「看得见但读不动」跟看不见是同一类 bug。 const rgbOf = (block, name) => { const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`)); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; }; const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; const relLum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b); const contrast = (a, b) => { const l1 = relLum(a), l2 = relLum(b); const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05); }; const darkCard = rgbOf(darkBlock, 'white'); const lowContrast = []; for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) { for (const shade of [400, 500, 600, 700, 800, 900]) { const fg = rgbOf(darkBlock, `${name}-${shade}`); if (!fg) { lowContrast.push(`${name}-${shade}:缺失`); continue; } const r = contrast(fg, darkCard); if (r < 4.5) lowContrast.push(`${name}-${shade}:${r.toFixed(2)}`); } } check( '强调色前景段在深色卡片上达到 4.5:1', darkCard !== null && lowContrast.length === 0, lowContrast.slice(0, 6).join(' ') ); // 21) 实心按钮底走独立的 --s-* 且**两种模式同值**。 // // accent 的 400–900 在深色下被提亮(为了 text-red-600 读得动), // 而 `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。 // 一个名字服务两种语义必然坏掉一头 —— 与 text-white/bg-white 那次同理。 check( '实心按钮底走 --s-* 并只覆盖 backgroundColor', /const solid = \(name\) =>/.test(cfg) && /backgroundColor:\s*\{[^}]*red:\s*solid\('red'\)/s.test(cfg) && /--s-red-600:/.test(css) ); // 22) --s-* 不能出现在 .dark 里:它必须两种模式同值。 // 在 .dark 覆盖等于把「危险操作是红的」这件事也一起反转了。 check( '--s-* 未被 .dark 覆盖(实心底不随主题变)', !/--s-[a-z]+-\d+:/.test(darkBlock) ); // 23) 白字落在实心按钮底上必须达到 4.5:1。 // // **两种模式都要算**。之前只拿浅色的 on-accent(纯白 255)去算,而深色用的是 // 「近白」(244 246 250,为了不刺眼)—— 同一个实心底,白字换暗一点点就从 // 4.83 掉到 4.46,路过了 AA 线。盲区在真实渲染里才被量出来(导航未读徒标 // 「12」)。 const solidBlock = cssNoComments.slice( cssNoComments.indexOf(':root'), cssNoComments.indexOf('.dark') ); const sRgb = name => { const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`)); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; }; // 代码里真正出现过的「实心底 + 白字」组合 const solidPairs = [ ['blue-600'], ['blue-700'], ['red-600'], ['red-700'], ['green-700'], ['orange-700'] ]; const onAccentValues = [ ['浅色', rgbOf(lightBlock, 'on-accent')], ['深色', rgbOf(darkBlock, 'on-accent')] ]; const weakButtons = []; for (const [mode, onAccent] of onAccentValues) { if (!onAccent) { weakButtons.push(`${mode}:on-accent 缺失`); continue; } for (const [name] of solidPairs) { const bg = sRgb(name); if (!bg) { weakButtons.push(`${name}:缺失`); continue; } // 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。 const r = contrast(onAccent, bg); if (r < 4.5) weakButtons.push(`${mode}/${name}:${r.toFixed(2)}`); } } check( '白字在实心按钮底上达到 4.5:1(两种模式)', weakButtons.length === 0, weakButtons.join(' ') ); // 24) 应用框架(侧栏 / 底部导航)必须有独立色阶。 // 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成 // 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。 check( '框架有独立的 chrome 色阶', /chrome:\s*\{/.test(cfg) && /--c-chrome-900:/.test(lightBlock) && /--c-chrome-900:/.test(darkBlock) ); // 25) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。 const chromeDark = lum(darkBlock, 'chrome-900'); check( '深色下框架比内容区更暗', chromeDark !== null && darkG50 !== null && chromeDark < darkG50, `chrome-900=${chromeDark} gray-50=${darkG50}` ); // 26) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray)。 const chromeFiles = ['Sidebar', 'NarrowNav']; const slateLeft = []; for (const f of chromeFiles) { const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8'); const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || []; if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`); } check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | ')); // 27) 可逆灰阶不能作为白字实心按钮底。gray-700/800/900 在深色模式下会 // 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。 const componentSources = readdirSync(compDir) .filter(x => x.endsWith('.tsx')) .map(f => [f, readFileSync(join(compDir, f), 'utf8')]); const graySolid = componentSources.flatMap(([f, src]) => (src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || []) .map(hit => `${f}:${hit}`) ); check('白字实心控件不使用可逆 gray 底色', graySolid.length === 0, graySolid.slice(0, 4).join(' | ')); // 28) 未映射色族会绕过 CSS 变量主题,浅色值会原样落进深色页面。 const unmapped = componentSources.flatMap(([f, src]) => (src.match(/(?:bg|text|border)-(?:emerald|purple)-\d+/g) || []).map(hit => `${f}:${hit}`) ); check('组件未使用未映射的 emerald/purple 色族', unmapped.length === 0, unmapped.slice(0, 6).join(' | ')); // 29) 本地 markdown 层替代未安装 typography 插件时无效的 prose 类。 const inertProse = componentSources.flatMap(([f, src]) => (src.match(/\bprose(?:-sm)?\b/g) || []).map(hit => `${f}:${hit}`) ); check('Markdown 内容使用本地 .markdown 排版层', inertProse.length === 0, inertProse.slice(0, 4).join(' | ')); // 30) gray-400 承担 9–12px 元数据与 placeholder,白卡片上也必须达到 4.5:1。 const lightCard = rgbOf(lightBlock, 'white'); const lightSecondary = rgbOf(lightBlock, 'gray-400'); const secondaryContrast = lightCard && lightSecondary ? contrast(lightCard, lightSecondary) : 0; check( '浅色 gray-400 在白卡片上达到 4.5:1', secondaryContrast >= 4.5, secondaryContrast.toFixed(2) ); console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`); process.exit(fail ? 1 : 0);