**逐处加 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 项全过。
152 lines
5.8 KiB
JavaScript
152 lines
5.8 KiB
JavaScript
/**
|
||
* 深色主题手工验收。
|
||
*
|
||
* 需要共享 Chromium(CDP 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);
|