feat(webui): 自定义背景 + 外观现代化;修正实心按钮白字在深色下的对比度

# 自定义背景(新功能)

三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。

**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(50–300)在深色下本来就是暗的(见 .dark 与 theme.test.mjs 第 19 条),
于是浅色得到柔和 pastel、深色得到低沉暗调,不需要维护两套渐变,也不会
出现「深色模式下原样落下浅色渐变」这类绕过主题变量的错误。

图片路径的关键取舍:
- **先压缩再存**。手机直出照片 4–8MB,而 localStorage 配额约 5MB,直接写会抛
  异常,用户看到的是「选了图片没反应」。等比缩到最长边 2560px、转 JPEG;
  仍超限则再缩一档;再不行就**明确拒绝并说明原因**(不是静默失败)。
- 失败一律返回 `{ok:false, reason}` 并渲染成 `role="alert"`。

# 背景层为什么不放进主题 store

主题(light/dark/system)是必须全局一致的语义;背景是纯装饰偏好,取值空间
与主题毫无关系。混在一起会让「跟随系统」的实现被背景字段淹没。

# 背景层实现在 CSS,不改 27 个组件

按 Tailwind 生成的实际类名统一接管:背景开启时让出不透明的页面底
(body / bg-gray-50 / bg-slate-100 → 透明),并把卡片(bg-white)与框架
(bg-chrome-800/900)变成半透明 + 背景模糊。

逐个组件加 class 必然漏 —— 漏掉的那块就是一张不透明卡片浮在背景上。
这段 CSS **刻意放在所有 @layer 之外**:它要覆盖的正是 utilities 生成的
`.bg-white`,写进 @layer components 会被 utilities 压过去(静默失效),
而分层 CSS 恒输给未分层 CSS,这是唯一稳定可靠的位置。

**chrome-600/700 刻意保持不透明**:它们不是大面板,而是导航项与 15px 的
计数徽标。真实渲染量得半透明会把徽标上的数字压到 4.46:1,低于 AA 4.5 ——
小控件的可读性优先于装饰效果(已用脚本量出,见下)。

# 「跟随系统」的可见性

三态本来就已实现(system 为默认值 + matchMedia 监听)。这次做的是让它可被
发现与信任:选择器改成分段控件(role=radiogroup + aria-checked),说明文案
写清「跟随系统会随系统的深色开关自动切换」,并保留单选按钮入口的
「当前跟随系统:深色/浅色」提示。

# 外观现代化

- **圆角整体调大一档**(默认 0.25→0.5rem)。原值是几年前的紧凑风格,
  在宽屏桌面应用上偏硬。只改比例尺,200 处圆角一次性刷新,不产生
  「新组件大圆角、旧组件小圆角」的断层。
- 语义化圆角令牌:`rounded-card` / `rounded-control`(数值档位答的是「多大」,
  这两个名字答的是「用在哪」)。
- 自定义滚动条(桌面应用里常驻可见,系统默认样式偏旧)。
- 键盘焦点环(`:focus-visible`,仅键盘导航时出现;可访问性硬要求)。
- 交互元素统一过渡;并尊重 `prefers-reduced-motion`。

# 顺带修正两处真实问题(都由真实渲染量出,不是估算)

1. **实心按钮白字在深色下 4.46:1,低于 AA**。
   深色 `--c-on-accent` 是「近白」244 246 250(为了不刺眼),而结构检查第 23
   条只拿**浅色**的纯白 255 去算 → 4.83 通过。**测试存在盲区**:
   同一个实心底,白字换暗一点点就越过 AA 线。导航未读徽标「12」正是这个组合。

   两处都修:把第 23 条改成**两种模式的 on-accent 都算**(闭合盲区),
   并把深色 on-accent 抬到 250 250 252(4.65:1,仍非纯白,保留原初衷)。

2. **theme.test.mjs 切颜色块的方式很脆**:它用 `indexOf('.dark')` 切片,于是在
   :root 的注释里写一句带点的选择器写法就会把浅色块提前截断(我加注释时
   真的踩到了,第 8 条假失败)。更危险的是反向情形:块被截短后变量集合变小,
   「覆盖齐全」这类断言可能**真空通过**。改为所有块切分都基于**剥注释后**的文本。

# 测试

- 新增 `test/background.test.mjs`(15 条结构检查):遮罩两主题各一份、
  背景层必须负 z-index(0 会盖住界面)、背景开启时必须让出页面底、
  玻璃化只在 data-bg=on 下、悬停态一并接管、预设复用调色板变量、
  图片上限与失败原因存在、尊重 reduced-motion 等。
- 新增 `test/stores/background.test.ts`(16 条):脏数据归一化(未知预设、
  kind=image 却无图、越界数值)、CSS 变量写入与清理成对(残留 --bg-image 会
  让「关掉背景」后仍显示旧图)、localStorage 抛异常不打断操作。
- 新增 `test/manual/background-verify.mjs`:连真实 Chromium 验收**渲染结果**
  (背景层是否真的可见、玻璃化的计算样式、正文在背景之上是否仍达 WCAG AA、
  自动模式在**不刷新**页面时跟随系统切换、显式选择不被系统覆盖)。
  它拦住了上面两个真问题,也拦住了我自己两次写错的判据。

# 验证

- typecheck 干净
- 主题 30/30、背景 15/15、vitest 216/216(新增 16)
- 真实渲染验收 23/23(AGENTMAIL_DIST 注入本地构建 + 活 Gateway,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
This commit is contained in:
2026-09-12 08:02:30 +08:00
parent dc7bf57ceb
commit 84c1d749cd
13 changed files with 1537 additions and 28 deletions

View File

@ -30,6 +30,20 @@ const css = read('../src/index.css');
const cfg = read('../tailwind.config.js');
const html = read('../index.html');
/**
* 剥掉注释后的 CSS。
*
* 下面几处按 `indexOf(':root')` / `indexOf('.dark')` 切颜色块,而**注释里出现
* 选择器字面量会把块提前截断**:在 :root 的注释里写一句「深色主题里换值」
* (原文用了带点的选择器写法)就足以让浅色块在 color-scheme 之前被切开,
* 于是第 8 条真假失败。更危险的是反向情形:块被截短后变量集合变小,
* 「覆盖齐全」这类断言可能**真空通过**。
*
* 所以所有块切分都基于剥注释后的文本。字符串字面量里的 /* 在本文件里
* 不存在,直接用非贪婪匹配去掉块注释即可。
*/
const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
// 1) tailwind 必须走 class 策略。
// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
@ -54,8 +68,11 @@ check(
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
const darkBlock = css.slice(css.indexOf('.dark {'));
const lightBlock = cssNoComments.slice(
cssNoComments.indexOf(':root'),
cssNoComments.indexOf('.dark')
);
const darkBlock = cssNoComments.slice(cssNoComments.indexOf('.dark {'));
const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
const missing = [...lightVars].filter(v => !darkVars.has(v));
@ -302,8 +319,16 @@ check(
!/--s-[a-z]+-\d+:/.test(darkBlock)
);
// 23) 白字落在实心按钮底上必须达到 4.5:1(两种模式同一组值,只需算一次)
const solidBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
// 23) 白字落在实心按钮底上必须达到 4.5:1。
//
// **两种模式都要算**。之前只拿浅色的 on-accent纯白 255去算而深色用的是
// 「近白」244 246 250为了不刺眼—— 同一个实心底,白字换暗一点点就从
// 4.83 掉到 4.46,路过了 AA 线。盲区在真实渲染里才被量出来(导航未读徒标
// 「12」
const solidBlock = cssNoComments.slice(
cssNoComments.indexOf(':root'),
cssNoComments.indexOf('.dark')
);
const sRgb = name => {
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
@ -314,18 +339,23 @@ const solidPairs = [
['red-600'], ['red-700'],
['green-700'], ['orange-700']
];
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
const onAccentValues = [
['浅色', rgbOf(lightBlock, 'on-accent')],
['深色', rgbOf(darkBlock, 'on-accent')]
];
const weakButtons = [];
for (const [name] of solidPairs) {
const bg = sRgb(name);
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
// 这些按钮文字只有 914px按普通文字执行 WCAG AA 4.5:1。
const r = contrast(onAccentLight, bg);
if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`);
for (const [mode, onAccent] of onAccentValues) {
if (!onAccent) { weakButtons.push(`${mode}:on-accent 缺失`); continue; }
for (const [name] of solidPairs) {
const bg = sRgb(name);
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
// 这些按钮文字只有 914px按普通文字执行 WCAG AA 4.5:1。
const r = contrast(onAccent, bg);
if (r < 4.5) weakButtons.push(`${mode}/${name}:${r.toFixed(2)}`);
}
}
check(
'白字在实心按钮底上达到 4.5:1',
'白字在实心按钮底上达到 4.5:1(两种模式)',
weakButtons.length === 0,
weakButtons.join(' ')
);