fix(webui): 深色模式真的落地了 —— 之前 .dark 是「有意留空」的

用户报「深色模式可读性差」。实测(1280×800,读页面计算值)拿到两个数字:
  · .glass-card 合成成 rgb(237,237,237) 白卡,而其上 --c-gray-900 文字是
    rgb(243,245,248) ⇒ **1.07:1 的白底白字**
  · .nav-rail 合成成 rgb(188,189,190) 浅灰条,未选中文字只有 4.03:1

根因写在 index.css 自己的注释里:`.dark { /* 有意留空 */ }`。当年留空的理由
是「组件没有 dark: 变体,只换令牌会半深不浅」——**方向反了**:组件写的是语义
色阶(bg-white / text-gray-900),灰阶反转后本来就会自适应;真正没适配的是
**手写 CSS 里那几处硬编码白色**(.glass-card 的 0.92、--nav-bg 令牌),
它们不在 --c-* 色板里,所以「色板变量已全覆盖」的判据一直是绿的。

改动:
1. .glass-card / .nav-* 全部改走令牌,主题之间只差 alpha;
   深色给 --glass-card-a: 0.06 + --nav-bg: 30 35 44/0.72。
   遵守既有契约(background.test「玻璃是白色材料」):**基材恒为白**,
   只降 alpha,绝不换成深色层。
2. CalendarView 的非本月农历小字 text-gray-300 → gray-400/500。
   gray-300 在这套调色板里是**分隔线档**(全仓 68 处 border-gray-300、
   当文字只有 5 处),深色下它是 61,68,81,落在深卡上 1.49:1;
   而 gray-400 在深色 4.66:1、浅色白卡 2.54:1,两端都更好。
3. 判据:theme.test 补 3 条(手写 CSS 不许有绕过主题的硬编码白色表面 /
   深色卡片 alpha 必须降低 / **按实际 alpha 合成后**正文须 ≥4.5:1,
   即把 1.07:1 那个事故写成可计算的断言)。background.test 那条
   「.dark 里不许出现 --nav-*」**方向反转**为「必须有且底暗字亮」——
   它原来守的是「还没有深色主题」这个前提,其注释本就写明
   「真做深色主题时这条要一起改」。

验证(不是"改了就算"):
· 自建探针遍历 **9 个页面**(三个通信子页签 / 邮件详情 / 日历 / 联系人 /
  我的 / 写信 / 地址补全弹层):修复前通信 14 处、日历 74 处、
  联系人 13 处低于 WCAG AA;修复后 **9/9 页面 0 处**。
· 三条新判据逐条**变异自检**(还原修复即变红,且失败信息自带药方);
  浅色三个取色值与改动前**逐字节一致**(导航 0.72 白玻璃、卡片 0.92 白)⇒ 零回归。
· theme.test 34 通过 / background.test 44 通过 / tsc 干净。
· 已按 deploy/redeploy-gateway.sh 部署到 systemd 实例,后置清单自动项全绿,
  并在生产实例上复量 9 个页面(同为 0 处)+ 端到端发信(状态 read,非仅入库)。

#深色模式 #可读性 #WCAG
This commit is contained in:
2026-09-17 22:32:25 +08:00
parent 2166f0ed81
commit 99a2d7ad7e
4 changed files with 175 additions and 19 deletions

View File

@ -422,6 +422,80 @@ check(
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)}`
);
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
/*
* 机器可读的汇总(契约):`run-all.mjs` 只认这一行来判"这条判据到底跑了几条"。