fix(webui): 深色模式「只有通信页正常」—— 根因不是 alpha,是底没暗下来

用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面深色模式
可读性都极差」。99a2d7a 只修了 `.glass-card` 那块(通信页走的就是它),
其余三页的面板走 `html[data-bg='on'] .bg-white` → `--bg-glass`,没跟进。

我第一版把这三档 alpha 从 0.9/0.84/0.55 降到 0.08/0.05/0.04,**仍然不够**。

## 正确判据:逐个叶子文本节点,采样它**真实渲染的底色**

之前的探测全在数 DOM 祖先链上的 backgroundColor,而 `.app-backdrop` 是
`position:fixed` 的**兄弟节点**(不是祖先)⇒ 永远采不到壁纸与遮罩,
只能退回"壁纸均值 213"→ 得出"底是亮的"但与屏幕不符。

改成截图后用 pngread.mjs 逐像素采样:对每个叶子文本节点取其 bbox 内
出现最多的颜色当作它的实际底色,再算 WCAG 对比度。得到决定性的数字:

  通信 最差 1.87:1 / 日历 最差 **1.19:1** / 联系 1.64:1 / 我的 1.42:1
  (日历页「廿五」fg=rgb(138,146,161) bg=rgb(134,132,132) —— 字和底几乎同色)

## 根因:`--bg-dim` 是**比例**,比例压不住一张**浅**壁纸

用户 jianf 的壁纸均值 RGB 213(浅照片)、dim=56 ⇒ 213×0.44 ≈ 94,仍是中灰。
近白正文对 94 只有约 3.6:1;次要文字 gray-400 对 94~134 只有 1.2–1.4:1。
**没有任何文字颜色能救** —— 这与 background.test 的契约「玻璃是白色材料」
是同一件事的两面:深色下敢用近白基材,前提就是「背后是深底」,
而这个前提此前没人保证。

## 改动

1. 新增 `--bg-dim-min`(`:root` 0% / `.dark` 92%),遮罩取
   `max(var(--bg-dim), var(--bg-dim-min))` —— **取 max 而非覆盖**,
   用户调得比下限高时仍以用户的为准,不下调他的选择。
2. 玻璃 alpha 收到 0.04/0.03/0.02(第三层从 `transparent` 改为
   `--bg-glass-nested3` 的小值:深色下"透明"= 浅壁纸直接透上来)。
3. `.dark` 的 `--glass-card-wall-a` 0.1 → 0.04 与其它档对齐。
4. 浅色分支完全不变(dimMin=0% ⇒ max() 等价于原值,实测 glass 仍是 0.88、
   遮罩仍是 `rgba(255,255,255,0.56)`)。

## 验收(1280×800 真渲染采样,逐个叶子文本节点)

  修复前:通信 1.87 / 日历 1.19 / 联系 1.64 / 我的 1.42(<4.5 的节点 17/88/22/26)
  修复后:通信 6.14 / 日历 4.90 / 联系 4.96 / 我的 5.38(<4.5 的节点 **0/0/0/0**)

代价(明写在案):深色下浅壁纸被压得很淡(92%)。这是可读性优先的取舍,
壁纸仍在(8% + 玻璃质感 + 模糊),只是不再是主体。

## 判据(theme.test 30 → 38,已同步 run-all 的棘轮)

新增 4 条,针对"归因错"这件事本身:
  · 深色下有压暗下限且 ≥90%(浅色下不干预)
  · 下限真的作用在遮罩上(**不是只定义变量**)
  · 用最坏输入(纯白 255 壁纸)实算 gray-400 对合成底 ≥ 4.5:1
  · 判据自检:拿掉下限必须判红
变异自检跑过:下限改回 56% ⇒ 2 条红;下限定义了但没用上 ⇒ 1 条红。

`--revert-mutation` 之外的基本面:theme 38/0、background 44/0、
cross-client-theme 15/0、appearance-defaults 4/0。套件 broken 由 4 降到 3
(build stamp 因重构建而转绿),red 23 不变,无新增失败。
This commit is contained in:
2026-09-18 01:08:04 +08:00
parent 99a2d7ad7e
commit 5621cf97fa
3 changed files with 173 additions and 9 deletions

View File

@ -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` 只认这一行来判"这条判据到底跑了几条"。