用户原话:「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 不变,无新增失败。
587 lines
28 KiB
JavaScript
587 lines
28 KiB
JavaScript
/**
|
||
* 深色主题的结构性检查。
|
||
*
|
||
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
|
||
* 真正的视觉验收靠手工脚本(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);
|