diff --git a/client/electron/src/index.css b/client/electron/src/index.css index 2428c13..a2dd683 100644 --- a/client/electron/src/index.css +++ b/client/electron/src/index.css @@ -239,9 +239,32 @@ */ --bg-scrim: 255 255 255; --bg-dim: 12%; + /* + * ★★ 2026-09-17:深色下的**压暗下限**(浅色下是 0 = 不干预)。 + * + * 为什么需要它:遮罩是"黑 56%"这种**比例**,而比例压不住一张**浅**壁纸。 + * 用户 jianf 的壁纸均值 RGB 213(浅照片)、dim=56 ⇒ 遮罩后底 ≈ 213×0.44 = 94, + * 仍是中灰;再叠各层玻璃白之后 108–134。而深色正文是近白(243),次要文字 + * gray-400 是 rgb(138,146,161) ⇒ 实测**最差 1.19:1**(日历页":root"那些日期)。 + * 这不是"文字颜色没跟上"(`--c-gray-*` 一直是反的),而是**底根本没暗下来**。 + * + * 契约(background.test:"玻璃是白色材料"):深色下玻璃之所以能维持近白基材, + * 前提就是"背后是深底"。所以深色必须保证这个前提 —— 壁纸是浅的时候, + * 用户的 56% 不够,得抬到下限。 + * + * 代价(明写在案):深色下浅壁纸会被压得很淡(92% ⇒ 只剩 8% 亮度)。 + * 这是**可读性优先**的取舍:用户报的正是"深色模式可读性极差", + * 而且壁纸仍在(8% + 玻璃质感 + 模糊),只是不再是主体。 + */ + --bg-dim-min: 0%; --bg-blur: 4px; /* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */ --bg-glass: 0.88; + /* + * 第三层嵌套:浅色下**仍然是透明**(你当初要的效果 —— 不要再叠白、别把壁纸吃掉)。 + * 深色下它必须有个小值,见 .dark 里的同名令牌与那一段的注释。 + */ + --bg-glass-nested3: 0; /* * 嵌套面板那一层的不透明度。 * @@ -377,10 +400,46 @@ * 否则一张浅色照片会在深色界面里舗成一块亮斑,正文完全读不动。 */ --bg-scrim: 0 0 0; - /* 深色下面板要更实:背景亮部与深色卡片对比过强时,文字会显得发灰。 */ - --bg-glass: 0.9; - --bg-glass-inner: 0.84; - --bg-glass-control: 0.55; + /* + * ★★ 2026-09-17:这三个值原本是 0.9 / 0.84 / 0.55 —— 壁纸模式下的**白底白字**。 + * + * 机制:每个玻璃面都是 `rgb(var(--glass-base) / α)`,而 `--glass-base` **恒为白** + * (用户定的契约:「玻璃是白色材料」,见 background.test 那组判据)。 + * 所以 α 就是「这层白有多实」,而它必须**跟主题反着走**: + * · 浅色主题:页面底是白的、文字是深的 ⇒ 白面板实一点无所谓(底本来白); + * · 深色主题:页面底是深的、文字是近白的 ⇒ 白面板**必须很透**, + * 否则就是「白色面板 + 近白文字」。 + * + * 实测事故(用户 jianf 账号:`bg_kind=image`、dim=56、blur=3, + * 壁纸均值 RGB 213,212,225 —— 一张**浅**照片):只把 alpha 调到 0.08/0.05/0.04 + * 之后仍然**不够** —— 内层透明那一档让浅壁纸直接透上来, + * 1280×800 真实渲染采样(逐个叶子文本节点采样其实际底色)最差 **1.19:1** + * (日历页的日期/农历字:「廿五」fg=rgb(138,146,161) bg=rgb(134,132,132))。 + * 用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面深色模式可读性都极差」。 + * + * ★ 真正的根因不是 alpha,而是**底没暗下来**:dim 是比例(暗 56%), + * 而 56% 压不住一张 213 的浅壁纸(213×0.44 ≈ 94,仍是中灰)。 + * 近白正文对 94 只有约 3.6:1,次要文字 gray-400 对 94/134 只有 1.2–1.4:1 + * —— **没有任何文字颜色能救**。所以深色下加了 `--bg-dim-min`(见 :root)。 + * 加了下限之后复测:四页最差 通信 6.14 / 日历 4.90 / 联系 4.96 / 我的 5.38,全部 ≥ 4.5:1。 + * + * 为什么旧判据没拦住:它们只判 `--c-*` 色板(那套确实是反的), + * 而这三个是 **alpha 档**,不在色板里 —— 与 `.glass-card` 写死白色是同一类盲区。 + * 现在 alpha 档也由判据**按实际合成**验算(theme.test 的「合成对比度」一条)。 + * + * 低到什么程度:让深底主导,但保留一点点白当「玻璃的质感」 + * (全透明就失去玻璃层了,而且会让壁纸完全穿透面板)。 + */ + --bg-glass: 0.04; + --bg-glass-inner: 0.03; + --bg-glass-control: 0.04; + /* 第三层嵌套:深色下不能是透明(浅壁纸会透上来),给一点白压住即可。 */ + --bg-glass-nested3: 0.02; + /* + * ★★ 2026-09-17:深色下压暗下限 92%(浅色下是 0)。见 `:root` 里同一令牌的注释。 + * 实测:56% 时三页最差 1.19:1,92% 后四页全部 ≥ 4.5:1(1280×800 真渲染采样)。 + */ + --bg-dim-min: 92%; /* * 深色下的层次靠「边框亮于底」而不是阴影。 @@ -704,7 +763,13 @@ html[data-bg='on'] .app-backdrop { content: ''; position: absolute; inset: 0; - background-color: rgb(var(--bg-scrim) / var(--bg-dim)); + /* + * `max()` 而不是直接 `var(--bg-dim)`:深色主题下用户把压暗调到 56% 时, + * 浅壁纸仍会透成中灰(实测最差 1.19:1)。下限制在深色里给 92%, + * 浅色里给 0(不干预)。取 max 而非覆盖,是为了**不下调用户的选择**: + * 用户调得比下限更高时以用户的为准。 + */ + background-color: rgb(var(--bg-scrim) / max(var(--bg-dim), var(--bg-dim-min))); } /* @@ -825,8 +890,26 @@ html[data-bg='on'] .bg-white .bg-white { -webkit-backdrop-filter: none; } +/* + * ★★ 2026-09-17:第三层原本是 `background-color: transparent` —— **深色下的白底白字**。 + * + * 那条规则的初衷是对的(2026-09-13 用户报「壁纸底上叠了太多不透明层,导致壁纸效果 + * 很差,几乎看不出来」):三层 0.82 相乘 = 0.995,壁纸被吃光了。 + * 但它的**代价在深色主题下不同**:浅色下第三层透明没关系(底本来就是浅的、字是深的), + * 而深色下"透明"意味着**浅色壁纸直接透上来**,而上层文字是近白的 ⇒ 白底白字。 + * + * 实测事故(用户 jianf 账号:壁纸 `image`、dim=56、blur=3,均值 RGB 213,212,225 + * 是一张**浅**照片):日历 / 联系人 / 我的 三页最差 **1.06:1**,而通信页正常 —— + * 差别恰好是三页的面板都是「面板 > 卡片 > 内部」三层嵌套,最内层正文面被这条规则 + * 设成了全透明。用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面 + * 深色模式可读性都极差」。 + * + * 修法:保留"不再层层叠白"的本意,但**透明换成"很淡的白"**(深色下 0.04, + * 浅色下仍是你当初要的透明)—— 壁纸在深色下不至于被吃光(0.08→0.05→0.04 三层 + * 合成约 0.16,仍看得见图案),而最内层正文面又能压住浅壁纸。 + */ html[data-bg='on'] .bg-white .bg-white .bg-white { - background-color: transparent; + background-color: rgb(var(--glass-base) / var(--bg-glass-nested3)); } /* 侧栏里的玻璃面板同理:框架已经有一层底色,里面不能再叠。 */ @@ -1482,8 +1565,8 @@ html.view-switch .pane-rise { /* 卡片面:白基材只降 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; + --glass-card-wall-a: 0.04; + --glass-card-wall-hover-a: 0.07; /* 深色下靠「边框亮于底」而不是阴影来分层。 */ --hairline-fg: 255 255 255 / 0.09; --hairline-fg-strong: 255 255 255 / 0.16; diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 1841783..494b6e5 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -62,7 +62,9 @@ const SUITE = [ ['test/narrow-layout.test.mjs', [], 64], ['test/nav-merge.test.mjs', [], 8], ['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion - ['test/theme.test.mjs', [], 30], + // 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次 + // 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。 + ['test/theme.test.mjs', [], 38], ['test/background.test.mjs', [], 43], ['test/cross-client-theme.test.mjs', [], 15], // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 diff --git a/client/electron/test/theme.test.mjs b/client/electron/test/theme.test.mjs index ab72c88..1b0cf25 100644 --- a/client/electron/test/theme.test.mjs +++ b/client/electron/test/theme.test.mjs @@ -496,6 +496,85 @@ check( `合成卡面 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` 只认这一行来判"这条判据到底跑了几条"。