Files
MailUI4Agents/client/electron/test/theme.test.mjs
JianFeeeee 5621cf97fa fix(webui): 深色模式「只有通信页正常」—— 根因不是 alpha,是底没暗下来
用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面深色模式
可读性都极差」。99a2d7a 只修了 `.glass-card` 那块(通信页走的就是它),
其余三页的面板走 `html[data-bg='on'] .bg-white` → `--bg-glass`,没跟进。

我第一版把这三档 alpha 从 0.9/0.84/0.55 降到 0.08/0.05/0.04,**仍然不够**。

## 正确判据:逐个叶子文本节点,采样它**真实渲染的底色**

之前的探测全在数 DOM 祖先链上的 backgroundColor,而 `.app-backdrop` 是
`position:fixed` 的**兄弟节点**(不是祖先)⇒ 永远采不到壁纸与遮罩,
只能退回"壁纸均值 213"→ 得出"底是亮的"但与屏幕不符。

改成截图后用 pngread.mjs 逐像素采样:对每个叶子文本节点取其 bbox 内
出现最多的颜色当作它的实际底色,再算 WCAG 对比度。得到决定性的数字:

  通信 最差 1.87:1 / 日历 最差 **1.19:1** / 联系 1.64:1 / 我的 1.42:1
  (日历页「廿五」fg=rgb(138,146,161) bg=rgb(134,132,132) —— 字和底几乎同色)

## 根因:`--bg-dim` 是**比例**,比例压不住一张**浅**壁纸

用户 jianf 的壁纸均值 RGB 213(浅照片)、dim=56 ⇒ 213×0.44 ≈ 94,仍是中灰。
近白正文对 94 只有约 3.6:1;次要文字 gray-400 对 94~134 只有 1.2–1.4:1。
**没有任何文字颜色能救** —— 这与 background.test 的契约「玻璃是白色材料」
是同一件事的两面:深色下敢用近白基材,前提就是「背后是深底」,
而这个前提此前没人保证。

## 改动

1. 新增 `--bg-dim-min`(`:root` 0% / `.dark` 92%),遮罩取
   `max(var(--bg-dim), var(--bg-dim-min))` —— **取 max 而非覆盖**,
   用户调得比下限高时仍以用户的为准,不下调他的选择。
2. 玻璃 alpha 收到 0.04/0.03/0.02(第三层从 `transparent` 改为
   `--bg-glass-nested3` 的小值:深色下"透明"= 浅壁纸直接透上来)。
3. `.dark` 的 `--glass-card-wall-a` 0.1 → 0.04 与其它档对齐。
4. 浅色分支完全不变(dimMin=0% ⇒ max() 等价于原值,实测 glass 仍是 0.88、
   遮罩仍是 `rgba(255,255,255,0.56)`)。

## 验收(1280×800 真渲染采样,逐个叶子文本节点)

  修复前:通信 1.87 / 日历 1.19 / 联系 1.64 / 我的 1.42(<4.5 的节点 17/88/22/26)
  修复后:通信 6.14 / 日历 4.90 / 联系 4.96 / 我的 5.38(<4.5 的节点 **0/0/0/0**)

代价(明写在案):深色下浅壁纸被压得很淡(92%)。这是可读性优先的取舍,
壁纸仍在(8% + 玻璃质感 + 模糊),只是不再是主体。

## 判据(theme.test 30 → 38,已同步 run-all 的棘轮)

新增 4 条,针对"归因错"这件事本身:
  · 深色下有压暗下限且 ≥90%(浅色下不干预)
  · 下限真的作用在遮罩上(**不是只定义变量**)
  · 用最坏输入(纯白 255 壁纸)实算 gray-400 对合成底 ≥ 4.5:1
  · 判据自检:拿掉下限必须判红
变异自检跑过:下限改回 56% ⇒ 2 条红;下限定义了但没用上 ⇒ 1 条红。

`--revert-mutation` 之外的基本面:theme 38/0、background 44/0、
cross-client-theme 15/0、appearance-defaults 4/0。套件 broken 由 4 降到 3
(build stamp 因重构建而转绿),red 23 不变,无新增失败。
2026-09-18 01:08:04 +08:00

587 lines
28 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.

/**
* 深色主题的结构性检查。
*
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
* 真正的视觉验收靠手工脚本(test/manual/theme-verify.mjs)。
*
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**,
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
*/
import { code, prose } from './lib/read.mjs';
import { 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 => prose(join(here, p));
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('<alpha-value>'),
'缺少 rgb(var(--x) / <alpha-value>) 形态'
);
// 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')") &&
!/<script[^>]+(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 = prose(join(compDir, f));
// 只看 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 = code(join(here, `../src/components/${f}.tsx`));
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, prose(join(compDir, f))]);
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} 失败` : ''}`);
/*
* 31) ★ 深色下的**卡片面**必须跟着主题走,不能是写死的白色。
*
* 2026-09-17 用户报「深色模式可读性差」。根因不是灰阶没反转(那些令牌一直是
* 对的),而是**手写 CSS 里几处硬编码的白色**没进 `.dark`:
* - `.glass-card { background-color: rgb(255 255 255 / 0.92) }`
* - `--nav-bg`(导航令牌)
* 实测(1280×800,读计算值):“.glass-card” 合成成 rgb(237,237,237),
* 而它上面的 `--c-gray-900` 文字是 rgb(243,245,248) ⇒ **1.07:1 的白底白字**;
* 导航合成 rgb(188,189,190),未选中文字 4.03:1。
*
* 为什么旧判据全绿而界面是坏的:theme.test 只扫 `--c-*` 色板变量,
* 而这两处根本不在色板里 —— **“变量都覆盖了”不等于“颜色都走变量了”**。
* 所以这一条扫的是**整个非注释 CSS 里还有没有裸的不透明白色表面**。
*/
const cssForHardcode = css.replace(/\/\*[\s\S]*?\*\//g, '');
// 只扫**表面**(background),不扫 border/color:
// 白边框(如 .narrow-nav 里那条已被下一条覆盖的 rgb(255 255 255/.12)) 不会
// 造成白底白字,而把它算进来只会逼出“为了让判据变绿而改无关代码”。
// 白框里的注释已在上面切掉(源码里的中文说明不影响)。
const hardcodedWhite = [...cssForHardcode.matchAll(/^\s*background(?:-color)?\s*:\s*[^;]*rgb\(\s*255\s+255\s+255[^;]*;/gm)]
.map(m => m[0].trim())
.filter(line => !/var\(--glass-base\)/.test(line));
check(
'手写 CSS 里没有绕过主题的硬编码白色表面',
hardcodedWhite.length === 0,
hardcodedWhite.length ? `${hardcodedWhite.length} 处:${hardcodedWhite.slice(0, 3).join(' | ')}`
: '白色表面一律走 --glass-base / 令牌的 alpha 档'
);
check('★ 判据自检:硬编码白色必须判红',
/^\s*background-color:\s*rgb\(255 255 255/.test(' background-color: rgb(255 255 255 / 0.92);'));
/*
* 32) 深色下玻璃卡的 alpha 必须真的降下来(否则白色基材还是白的)。
*
* 与 background.test 的「玻璃是白色材料」是**一对**,不矛盾:
* 那边守「基材恒为白」(不许换成深色层),这边守「深色下 alpha 要降」
* (不许维持浅色的 0.92)。两条合起来才是用户要的「深色下透出深底的白玻璃」。
*/
/*
* 取值工具:`lum()` 只认 `--c-*` 前缀,而 alpha 令牌是 `--glass-card-a`,
* 所以单给一个“取任意数值令牌”的函数,不把前者改宽(改宽会让所有
* 现有 `lum(block,'gray-400')` 这类调用的语义变模糊)。
*/
const numOf = (block, name) => {
const m = block.match(new RegExp(`--${name}:\\s*([\\d.]+)`));
return m ? Number(m[1]) : null;
};
const cardA = numOf(darkBlock, 'glass-card-a');
const lightCardA = numOf(lightBlock, 'glass-card-a');
check(
'深色下卡片面的 alpha 已降低(白基材不再是 0.92)',
cardA !== null && lightCardA !== null && cardA < 0.3 && lightCardA > 0.7,
`light=${lightCardA} dark=${cardA}`
);
/*
* 33) 深色下每个不透明卡片面都能承载正文:用实际 alpha 合成后,
* `--c-gray-900`(正文色)对卡面必须 ≥ 4.5:1。
* 这是把 1.07:1 那个具体事故写成可计算的断言,而不是只断言「变量存在」。
*/
const overOn = (fg, a, bg) => [0, 1, 2].map(k => Math.round(fg[k] * a + bg[k] * (1 - a)));
const darkPage = rgbOf(darkBlock, 'gray-50');
const darkBody = rgbOf(darkBlock, 'gray-900');
const glassWhite = [255, 255, 255];
const darkCardComposite = darkPage && cardA !== null ? overOn(glassWhite, cardA, darkPage) : null;
const darkTextContrast = darkCardComposite && darkBody ? contrast(darkBody, darkCardComposite) : 0;
check(
'深色卡片面上正文达到 4.5:1(1.07:1 白底白字那个事故)',
darkTextContrast >= 4.5,
`合成卡面 rgb(${darkCardComposite ? darkCardComposite.join(',') : '?'}) 对比度 ${darkTextContrast.toFixed(2)}`
);
/*
* 34) ★★ 深色下「底必须真的暗」—— 这一条是 2026-09-17 那次事故的回归锁。
*
* 事故:用户 jianf(`bg_kind=image`、dim=56、blur=3,壁纸均值 RGB 213,212,225
* —— 一张**浅**照片)报「只有通信页深色正常,日历/联系/我的三页可读性极差」。
*
* 我第一版归因错了:以为是 alpha 没降(于是把它降到 0.08/0.05/0.04),
* 结果真实渲染采样(逐个叶子文本节点采样其**实际底色**)最差仍只有 **1.19:1**:
* 日历页「廿五」fg=rgb(138,146,161) bg=rgb(134,132,132)。
* 真正的根因是 **dim 是比例(暗 56%),而比例压不住 213 的浅壁纸**:
* 213×0.44 ≈ 94 仍是中灰;近白正文对 94 只有约 3.6:1,
* 次要文字 gray-400 对 94~134 只有 1.2–1.4:1 —— **没有任何文字颜色能救**。
* 这与 background.test 的「玻璃是白色材料」是同一个契约的两面:
* 深色下敢用近白基材的前提,就是「背后是深底」。这个前提得由 CSS 保证。
*
* 所以断言从「alpha 小」改成**按实际合成算对比度**(下面用 213 的浅壁纸当最坏输入):
* (a) 深色下必须有压暗下限,且 ≥ 90%(暗到能承载 gray-400);
* (b) 下限必须真的作用在遮罩上(`max(var(--bg-dim), var(--bg-dim-min))`);
* (c) 用最坏壁纸 + 下限 + 各层玻璃 alpha 实算,gray-400 必须 ≥ 4.5:1。
* 三条缺任何一条,事故都会原样长回来(删掉下限 / 写了下限但没用上 / 下限太小)。
*/
/* numOf 取的是字面数字;`--bg-dim-min` 写作百分比(`92%` ⇒ 92),
而归一化到 0–1 才能参与合成。与 --bg-dim 的存储方式保持一致。 */
const dimMinRaw = numOf(darkBlock, 'bg-dim-min');
const dimMinLightRaw = numOf(lightBlock, 'bg-dim-min');
const dimMin = dimMinRaw === null ? null : dimMinRaw > 1 ? dimMinRaw / 100 : dimMinRaw;
const dimMinLight = dimMinLightRaw === null ? null : dimMinLightRaw > 1 ? dimMinLightRaw / 100 : dimMinLightRaw;
check(
'深色下有压暗下限且 ≥ 90%(浅色下不干预)',
dimMin !== null && dimMin >= 0.9 && dimMinLight === 0,
`light=${dimMinLight} dark=${dimMin}`
);
check(
'压暗下限真的作用在遮罩上(max 而非只定义变量)',
/max\(\s*var\(--bg-dim\)\s*,\s*var\(--bg-dim-min\)\s*\)/.test(cssNoComments),
'遮罩未取 max(--bg-dim, --bg-dim-min)'
);
/*
* 最坏输入:一张纯浅壁纸(255)乘上压暗下限。
* 用 255 而非用户实测的 213 —— 判据该守的是**任何**浅壁纸,不是恰好这一张。
*/
/* 无 `--c-` 前缀的 RGB 三元组令牌(如 `--bg-scrim`)。与 rgbOf 分开,
因为 rgbOf 的构造里写死了 `--c-` 前缀,硬套会永远取不到而静默变 0。 */
const tripleOf = (block, name) => {
const m = block.match(new RegExp(`--${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
};
const worstWall = [255, 255, 255];
const scrimRgb = tripleOf(darkBlock, 'bg-scrim');
const dimmed = scrimRgb && dimMin !== null
? worstWall.map((v, k) => Math.round(v * (1 - dimMin) + scrimRgb[k] * dimMin))
: null;
/* 最内层正文面:三层玻璃依次叠上去(第二/三层是同族嵌套)。 */
const glassA = numOf(darkBlock, 'bg-glass');
const innerA = numOf(darkBlock, 'bg-glass-inner');
const nested3A = numOf(darkBlock, 'bg-glass-nested3');
let composited = dimmed;
for (const a of [glassA, innerA, nested3A]) {
if (composited && a !== null) composited = overOn(glassWhite, a, composited);
}
const darkGray400 = rgbOf(darkBlock, 'gray-400');
const worstTextContrast = composited && darkGray400 ? contrast(darkGray400, composited) : 0;
check(
'最浅壁纸 + 压暗下限下,次要文字 gray-400 仍达到 4.5:1',
worstTextContrast >= 4.5,
`合成底 rgb(${composited ? composited.join(',') : '?'}) 对比度 ${worstTextContrast.toFixed(2)}`
);
/* 变异自检:把下限拿掉(还原成事故时的 0%),合成底必须变亮到判红。 */
const noFloor = worstWall.map((v, k) => scrimRgb ? Math.round(v * (1 - 0.56) + scrimRgb[k] * 0.56) : v);
let noFloorComposite = noFloor;
for (const a of [glassA, innerA, nested3A]) {
if (a !== null) noFloorComposite = overOn(glassWhite, a, noFloorComposite);
}
check(
'判据自检:压暗下限拿掉后必须判红(56% 的浅壁纸)',
darkGray400 !== null && contrast(darkGray400, noFloorComposite) < 4.5,
`还原后对比度 ${darkGray400 ? contrast(darkGray400, noFloorComposite).toFixed(2) : '?'}`
);
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
/*
* 机器可读的汇总(契约):`run-all.mjs` 只认这一行来判"这条判据到底跑了几条"。
* 为什么需要它:光看"文件里存在 check("只能证明**有能红的路径**,不能证明**它跑过** ——
* 把 `check` 的实现换成空函数(合并冲突改坏实现的现实事故)时,文本证据照样成立。
* 计数在 check() **内部**自增,所以"实现被换空"会直接体现为 pass=0。
*/
console.log(`RESULT pass=${pass} fail=${fail}`);
process.exit(fail ? 1 : 0);