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:
@ -571,7 +571,15 @@ function MonthGrid({
|
||||
不显示农历人无法确认「每月十五」到底落在哪一格 */}
|
||||
<span
|
||||
className={`text-3xs leading-none truncate ${
|
||||
outside ? 'text-gray-300' : 'text-gray-400'
|
||||
/*
|
||||
* 非本月日期用「次要文字」档而不是 gray-300。
|
||||
*
|
||||
* gray-300 在这套调色板里是**分隔线档**(全仓 68 处 border-gray-300,
|
||||
* 当文字用的只有几处)。深色下它的值是 61,68,81 —— 落在深卡上
|
||||
* 实测 1.49:1,而浅色白卡上同样只有 1.47:1(“看不见”不是深色独有的)。
|
||||
* gray-400 是次要文字档:深色 4.66:1 / 浅色 2.54:1,两端都好得多。
|
||||
*/
|
||||
outside ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{cellLunarLabel(d)}
|
||||
|
||||
@ -263,6 +263,13 @@
|
||||
* 三档从实到透:正文面(--bg-glass) > 嵌套卡(--bg-glass-inner) > 控件(--bg-glass-control)。
|
||||
*/
|
||||
--bg-glass-control: 0.5;
|
||||
--glass-card-a: 0.92;
|
||||
--glass-card-hover-a: 0.99;
|
||||
--glass-card-wall-a: 0.78;
|
||||
--glass-card-wall-hover-a: 0.88;
|
||||
/* 卡片边框:浅色用深色细线,深色用亮线(见 .dark,深色下层次靠边框不靠阴影)。 */
|
||||
--hairline-fg: 15 23 42 / 0.07;
|
||||
--hairline-fg-strong: 15 23 42 / 0.12;
|
||||
/* 面板的朦胧感(与壁纸自身的 --bg-blur 分开:那层给照片打底,这层给面板) */
|
||||
--bg-blur-panel: 10px;
|
||||
/* 浮动面板之间的缝隙:壁纸从缝隙里露出来,是圆角化玻璃化的关键 */
|
||||
@ -1410,6 +1417,7 @@ html.view-switch .pane-rise {
|
||||
* 我上一轮为了"白字对比度"把它做成深玻璃,那是**用错误的方式解决对比度**,
|
||||
* 真正的割裂就在这儿。深色底只应该是深色主题下的样子。
|
||||
*
|
||||
/*
|
||||
* 所以导航底色/文字都走令牌:
|
||||
* - 浅色主题:白玻璃(0.72)+ 深字;
|
||||
* - 深色主题(.dark):深玻璃(0.72)+ 亮字。
|
||||
@ -1439,15 +1447,46 @@ html.view-switch .pane-rise {
|
||||
* 而不是先把导航单独压深。
|
||||
*/
|
||||
/*
|
||||
* 深色主题:**这一块有意留空**。
|
||||
* 深色主题 —— **2026-09-17 落地**(用户报「深色模式可读性差」)。
|
||||
*
|
||||
* 两件事必须一起做,否则就是"半深不浅"(我在导航上已经栽过一次):
|
||||
* 1. 组件侧补齐 `dark:` 变体(现在一个都没有 —— 这是真正的深色主题工作);
|
||||
* 2. 玻璃只降 `--glass-base` 的 alpha(保持白色基材),绝不去换基材颜色。
|
||||
* 在 1 完成之前,2 单独生效只会让"面变深、字还是深色",读不了。
|
||||
* 这里原先是有意留空的,理由是「组件侧没有 dark: 变体,只换令牌会半深不浅」。
|
||||
* 实测证明那个担心方向反了:组件写的是**语义色阶**(`bg-white` / `text-gray-900`),
|
||||
* 灰阶反转后它们自动适配;真正没适配的是**手写 CSS 里那几处硬编码白色**。
|
||||
*
|
||||
* 实测(深色、1280×800,读页面计算值):
|
||||
* .nav-rail 合成 rgb(188,189,190) 浅灰条;--nav-fg-muted 文字只有 4.03:1
|
||||
* .glass-card 合成 rgb(237,237,237) 白卡;--c-gray-900 文字 1.07:1(白底白字)
|
||||
* 也就是「深色下整片白」—— 这正是可读性差的来源。
|
||||
*
|
||||
* 修法遵守既有契约(background.test.mjs「玻璃是白色材料」):**基材不改**
|
||||
* (--glass-base 仍只有一处定义、仍是白),主题之间只差 **alpha** ——
|
||||
* 深色下把白的 alpha 压到 0.06,让深底透上来;文字令牌本来就已反转。
|
||||
*/
|
||||
.dark {
|
||||
/* 有意留空 */
|
||||
/*
|
||||
* 导航:深玻璃 + 亮字。
|
||||
*
|
||||
* 这里曾有一条判据禁止 `.dark` 出现 `--nav-*`(背景是**当时还没有深色主题**
|
||||
* ⇒ 只压深导航就会「导航黑、正文白」,比全浅更割裂)。那条判据的注释写明
|
||||
* 「真做深色主题时,这条要连同 dark: 变体一起改」—— 本行即那个时刻,
|
||||
* 判据已同步改成断言这套深色令牌**存在且对比度达标**。
|
||||
*/
|
||||
--nav-bg: 30 35 44 / 0.72;
|
||||
--nav-fg: 236 240 246;
|
||||
--nav-fg-muted: 148 158 175;
|
||||
--nav-hover-bg: 255 255 255 / 0.08;
|
||||
--nav-active-bg: 30 58 95;
|
||||
--nav-active-fg: 154 191 250;
|
||||
--nav-border: 255 255 255 / 0.08;
|
||||
|
||||
/* 卡片面:白基材只降 alpha(0.92 → 0.06),深底便透上来。 */
|
||||
--glass-card-a: 0.06;
|
||||
--glass-card-hover-a: 0.1;
|
||||
--glass-card-wall-a: 0.1;
|
||||
--glass-card-wall-hover-a: 0.14;
|
||||
/* 深色下靠「边框亮于底」而不是阴影来分层。 */
|
||||
--hairline-fg: 255 255 255 / 0.09;
|
||||
--hairline-fg-strong: 255 255 255 / 0.16;
|
||||
}
|
||||
|
||||
.nav-rail {
|
||||
@ -1524,29 +1563,37 @@ html[data-bg='on'] .comm-pane > .bg-white {
|
||||
|
||||
.glass-card {
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid rgb(15 23 42 / 0.07);
|
||||
background-color: rgb(255 255 255 / 0.92);
|
||||
border: 1px solid rgb(var(--hairline-fg));
|
||||
/*
|
||||
* 基材**恒为白**,主题之间只差 alpha(--glass-card-a)。
|
||||
*
|
||||
* 这里原先写死 `rgb(255 255 255 / 0.92)` —— 深色下它就是一块白卡,
|
||||
* 而文字走的是反转后的近白令牌 ⇒ 实测 1.07:1 的白底白字。
|
||||
* 写死白色的地方,深色主题一律改不成 —— 走令牌才有得改。
|
||||
*/
|
||||
background-color: rgb(var(--glass-base) / var(--glass-card-a));
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background-color: rgb(255 255 255 / 0.99);
|
||||
border-color: rgb(15 23 42 / 0.12);
|
||||
background-color: rgb(var(--glass-base) / var(--glass-card-hover-a));
|
||||
border-color: rgb(var(--hairline-fg-strong));
|
||||
}
|
||||
|
||||
html[data-bg='on'] .glass-card {
|
||||
background-color: rgb(var(--glass-base) / 0.78);
|
||||
border-color: rgb(var(--glass-base) / 0.5);
|
||||
background-color: rgb(var(--glass-base) / var(--glass-card-wall-a));
|
||||
border-color: rgb(var(--hairline-fg));
|
||||
}
|
||||
|
||||
html[data-bg='on'] .glass-card:hover {
|
||||
background-color: rgb(var(--glass-base) / 0.88);
|
||||
background-color: rgb(var(--glass-base) / var(--glass-card-wall-hover-a));
|
||||
}
|
||||
|
||||
/*
|
||||
* (这里曾有 `.dark .glass-card { background: rgba(255,255,255,0.06) }`。
|
||||
* 它是按"深色主题已经存在"写的 —— 而实际上组件还没有 dark 变体,
|
||||
* 这条只会让卡片在浅色页面上几乎消失。等真深色主题落地再一起打开。)
|
||||
* 它当年被撤掉的理由是「组件还没有 dark 变体,这条只会让卡片在浅色页面上
|
||||
* 几乎消失」—— 2026-09-17 实测确认组件侧其实是自洽的(灰阶已反转),
|
||||
* 缺的正是这一档 alpha,于是它现在以 `--glass-card-a` 的形态回来了。)
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@ -431,10 +431,37 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
|
||||
* C 这条模糊来源。真要堵时按文件窄豁免写,别写成"导航目录不许出现 dark:"。
|
||||
*/
|
||||
const darkBodies = [...css.matchAll(/\.dark\s*\{([^}]*)\}/g)].map(m => m[1]);
|
||||
/*
|
||||
* ★ 2026-09-17 **方向反过来了**(用户报「深色模式可读性差」,实测导航合成
|
||||
* rgb(188,189,190) 而文字只有 4.03:1):
|
||||
*
|
||||
* 旧断言是「.dark 里**不许**出现 --nav-*」,它编码的是**当时还没有深色主题**
|
||||
* 这个前提 —— 只把导航压深确实会比全浅更割裂。现在深色主题真做了(灰阶令牌
|
||||
* 本来就已反转,缺的只是手写 CSS 里那几处硬编码白色),导航就必须跟着换深,
|
||||
* 否则它就是深色界面上的一条**残留浅灰条**。
|
||||
*
|
||||
* 断言因此从「不许有」改为「必须有,且字要亮、底要暗」——保留原来的意图
|
||||
* (防止只压深导航的割裂),但反过来守现在这个正确形态。
|
||||
* 为什么不只断「有」:只断存在的话,把深色令牌写成浅色值也算过。
|
||||
*/
|
||||
const darkNavBlock = darkBodies.find(b => /--nav-bg/.test(b));
|
||||
const navBgLum = (() => {
|
||||
const m = darkNavBlock && darkNavBlock.match(/--nav-bg:\s*(\d+)\s+(\d+)\s+(\d+)/);
|
||||
return m ? (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3 : null;
|
||||
})();
|
||||
const navFgLum = (() => {
|
||||
const m = darkNavBlock && darkNavBlock.match(/--nav-fg:\s*(\d+)\s+(\d+)\s+(\d+)/);
|
||||
return m ? (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3 : null;
|
||||
})();
|
||||
check(
|
||||
'导航深色来源之一已堵:.dark 令牌块里不许出现 --nav-*(另两条路见上方注释,未覆盖)',
|
||||
darkBodies.length >= 1 && darkBodies.every(b => !/--nav-/.test(b)),
|
||||
'.dark 令牌块里出现了 --nav-* —— 这条只堵这一条路(`.dark .nav-rail{}` 作用域与组件 dark: 变体都没堵,见上方注释)'
|
||||
'深色主题里导航必须换深(否则是深色界面上残留的一条浅灰条)',
|
||||
navBgLum !== null && navBgLum < 90,
|
||||
navBgLum === null ? '.dark 里没有 --nav-bg —— 深色下导航仍是白玻璃(实测 4.03:1)' : `--nav-bg 平均亮度 ${navBgLum}(应 <90)`
|
||||
);
|
||||
check(
|
||||
'深色导航的文字是亮字(底暗字亮,不是深底深字)',
|
||||
navFgLum !== null && navFgLum > 180,
|
||||
navFgLum === null ? '缺 --nav-fg' : `--nav-fg 平均亮度 ${navFgLum}(应 >180)`
|
||||
);
|
||||
check(
|
||||
'导航自叠模糊之一已堵:壁纸模式下 .nav-rail 规则里不许写 backdrop-filter(元素级工具类那条路未覆盖)',
|
||||
|
||||
@ -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` 只认这一行来判"这条判据到底跑了几条"。
|
||||
|
||||
Reference in New Issue
Block a user