Files
MailUI4Agents/web/test/manual/theme-verify.mjs
JianFeeeee d74f356f41 feat(web): 深色主题 —— 反转灰阶而非逐处 dark: 前缀
**逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里,
漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现,
且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍,
那种约定活不过三次改动。

改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身
就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线,
gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成
深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。

变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS ——
那些半透明高亮会静默失效(不报错,只是不透明)。

---

实测撞了三个必须分离的语义,每一个共用变量就坏:

**1. text-white 不能跟 bg-white 走。**
`white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字
(深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在
bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。

**2. 侧栏与底部导航不能跟 gray 走。**
它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。
并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区
(rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、
保持「框架比内容更沉」。

**3. 强调色不能反转。**
blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量,
而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。

---

顺带修的三处真实对比度不足(实测量出来的,不是猜的):
- 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1
  (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急)
- 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**,
  不是次要装饰,读不动等于页面空白
- 列表头计数:同上

---

主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话,
白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时
才跟随系统,显式选了的人不该因为日落被切换。

index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到
React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。
外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键
(不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。

body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。

入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以
表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」——
那是个回不去的单向门。

测试:test/theme.test.mjs 20 条结构性断言(已进 npm test),
test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG
比值,往上找第一个不透明背景)。两种模式各 4 项全过。
2026-09-04 06:30:26 +08:00

152 lines
5.8 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.

/**
* 深色主题手工验收。
*
* 需要共享 ChromiumCDP 9222。结构性检查已在 test/theme.test.mjs 里,
* 这里验的是**真实渲染出来的对比度** —— 那是唯一能发现白底白字的判据。
*
* 用法:
* ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
* node web/test/manual/theme-verify.mjs
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
let pass = 0, fail = 0;
const check = (name, ok, detail = '') => {
if (ok) { pass++; console.log(` 通过 ${name}`); }
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
};
/** 相对亮度WCAG。用于判断 body 底色是否真的变暗。 */
function luminance([r, g, b]) {
const f = c => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
const parseRgb = s => {
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
};
const { browser, page, issues } = await openApp(WIDE);
try {
for (const mode of ['light', 'dark']) {
console.log(`\n── ${mode} ──`);
await page.evaluate(m => {
localStorage.setItem('agentmail.theme', m);
document.documentElement.classList.toggle('dark', m === 'dark');
document.documentElement.style.colorScheme = m;
}, mode);
await page.waitForTimeout(400);
const body = parseRgb(await page.evaluate(() =>
getComputedStyle(document.body).backgroundColor));
check(`${mode}: body 底色可读取`, body !== null, String(body));
if (mode === 'dark') {
// 深色下 body 必须是暗的。这一条挂掉说明变量没生效
check('dark: body 底色确实是暗的', luminance(body) < 0.2,
`亮度 ${luminance(body).toFixed(3)}`);
}
// 遍历可见文本节点,算每个的前景/背景对比度。
// 4.5:1 是 WCAG AA 的正文标准;大字放宽到 3:1。
const bad = await page.evaluate(() => {
const out = [];
const els = document.querySelectorAll('button, a, h1, h2, h3, p, span, div, label, li');
const lum = ([r, g, b]) => {
const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const parse = s => {
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
return m ? [+m[1], +m[2], +m[3]] : null;
};
/** 往上找第一个不透明的背景。 */
const bgOf = el => {
let cur = el;
while (cur && cur !== document.documentElement) {
const cs = getComputedStyle(cur);
const c = parse(cs.backgroundColor);
const alpha = String(cs.backgroundColor).match(/rgba?\([^)]*,\s*([\d.]+)\)/);
if (c && (!alpha || Number(alpha[1]) > 0.85)) return c;
cur = cur.parentElement;
}
return parse(getComputedStyle(document.body).backgroundColor);
};
for (const el of els) {
// 只看直接含文本的元素
const text = [...el.childNodes]
.filter(n => n.nodeType === 3)
.map(n => n.textContent.trim())
.join('');
if (!text) continue;
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.opacity === '0') continue;
const fg = parse(cs.color);
const bg = bgOf(el);
if (!fg || !bg) continue;
const la = lum(fg), lb = lum(bg);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
const ratio = (hi + 0.05) / (lo + 0.05);
const size = parseFloat(cs.fontSize);
const bold = Number(cs.fontWeight) >= 700;
const large = size >= 24 || (size >= 18.66 && bold);
const need = large ? 3 : 4.5;
if (ratio < need) {
out.push({
text: text.slice(0, 24),
ratio: Number(ratio.toFixed(2)),
need,
fg: cs.color,
bg: `rgb(${bg.join(',')})`
});
}
}
return out;
});
// 允许少量刻意的低对比装饰(占位符、禁用态)
const severe = bad.filter(x => x.ratio < 2.5);
check(`${mode}: 无严重低对比文本(< 2.5:1`, severe.length === 0,
severe.slice(0, 4).map(x => `"${x.text}" ${x.ratio}`).join(' | '));
if (bad.length) {
console.log(` ${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1`);
// 逐条列出来而不只报个数:不知道是哪一处就没法修
for (const x of bad.slice(0, 8)) {
console.log(` ${x.ratio}:1 (需 ${x.need}) "${x.text}" ${x.fg} on ${x.bg}`);
}
}
// 白底白字的典型形态:前景与背景几乎相同
const invisible = bad.filter(x => x.ratio < 1.3);
check(`${mode}: 没有不可见文本(< 1.3:1`, invisible.length === 0,
invisible.slice(0, 3).map(x => `"${x.text}"`).join(' | '));
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
}
// 刷新后主题必须保持localStorage + 内联脚本)
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'dark'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
const stillDark = await page.evaluate(() =>
document.documentElement.classList.contains('dark'));
check('刷新后深色保持(内联脚本生效)', stillDark);
check('无 JS 运行时错误', issues.length === 0, issues.slice(0, 3).join(' | '));
} finally {
await page.close();
await browser.close();
}
console.log(`\n主题验收:${pass} 通过${fail ? `${fail} 失败` : ''}`);
console.log('截图:/tmp/theme-light.png /tmp/theme-dark.png');
process.exit(fail ? 1 : 0);