Files
MailUI4Agents/client/electron/test/stores/background.test.ts
JianFeeeee 84c1d749cd 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,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
2026-09-12 08:02:30 +08:00

170 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_BACKGROUND,
MAX_DATA_URL_BYTES,
STORAGE_KEY,
applyBackground,
clampBlur,
clampDim,
normalizeBackground,
useBackgroundStore
} from '../../src/stores/backgroundStore';
/**
* 背景 store 的行为锁。
*
* 这里测的是**纯逻辑与 DOM 写入**(不需要真浏览器):
* - 脏数据不能让界面白屏localStorage 可能被手改、也可能来自旧版本)
* - 数值必须被夹在合法区间(滑杆之外的写入路径只有代码,但状态可能来自磁盘)
* - CSS 变量的写入/清理必须成对 —— 残留的 `--bg-image` 会让「关掉背景」
* 之后仍然显示上一张图
*/
const presetClasses = () =>
Array.from(document.documentElement.classList).filter(c => c.startsWith('bg-preset-'));
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-bg');
document.documentElement.removeAttribute('style');
document.documentElement.className = '';
});
describe('数值夹取', () => {
it('压暗与模糊都被夹在声明区间内', () => {
expect(clampDim(-10)).toBe(0);
expect(clampDim(999)).toBe(80);
expect(clampDim(30)).toBe(30);
expect(clampBlur(-1)).toBe(0);
expect(clampBlur(100)).toBe(24);
expect(clampBlur(10)).toBe(10);
});
it('非数值退回默认值而不是 NaN', () => {
// NaN 会写成 `--bg-dim: NaN%`,整条声明失效 —— 表现为滑杆无效但不报错
expect(clampDim(Number.NaN)).toBe(DEFAULT_BACKGROUND.dim);
expect(clampBlur(Number.NaN)).toBe(DEFAULT_BACKGROUND.blur);
});
});
describe('脏数据归一化', () => {
it('非对象输入退回默认', () => {
for (const bad of [null, undefined, 42, 'x', []]) {
const n = normalizeBackground(bad);
expect(n.kind).toBe('none');
expect(n.presetId).toBe(DEFAULT_BACKGROUND.presetId);
}
});
it('未知预设 id 退回默认预设', () => {
expect(normalizeBackground({ presetId: 'no-such-preset' }).presetId).toBe(
DEFAULT_BACKGROUND.presetId
);
});
it('kind=image 但没有可用图片时退回 none', () => {
// 图片可能被浏览器清理或写坏。留一个空壳 image 状态会让界面显示「已选图片」
// 却什么都没有 —— 不如退回 none至少状态是诚实的
expect(normalizeBackground({ kind: 'image', imageDataUrl: '' }).kind).toBe('none');
expect(normalizeBackground({ kind: 'image', imageDataUrl: 'https://x/y.png' }).kind).toBe('none');
});
it('合法 data URL 被保留', () => {
const url = 'data:image/jpeg;base64,AAAA';
const n = normalizeBackground({ kind: 'image', imageDataUrl: url });
expect(n.kind).toBe('image');
expect(n.imageDataUrl).toBe(url);
});
it('越界数值被夹回区间', () => {
const n = normalizeBackground({ dim: 500, blur: -5 });
expect(n.dim).toBe(80);
expect(n.blur).toBe(0);
});
});
describe('写 DOM', () => {
it('预设背景写入 data-bg、预设类与两个数值变量', () => {
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'dusk', dim: 40, blur: 12 });
const root = document.documentElement;
expect(root.dataset.bg).toBe('on');
expect(presetClasses()).toEqual(['bg-preset-dusk']);
expect(root.style.getPropertyValue('--bg-dim')).toBe('40%');
expect(root.style.getPropertyValue('--bg-blur')).toBe('12px');
// 预设的渐变由 class 提供,不得写 --bg-image否则会盖住 class 的定义)
expect(root.style.getPropertyValue('--bg-image')).toBe('');
});
it('切换预设时清掉上一个预设类', () => {
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'aurora' });
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'mint' });
expect(presetClasses()).toEqual(['bg-preset-mint']);
});
it('图片背景写入 --bg-image 并清掉预设类', () => {
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'aurora' });
applyBackground({
...DEFAULT_BACKGROUND,
kind: 'image',
imageDataUrl: 'data:image/png;base64,BBBB'
});
const root = document.documentElement;
expect(root.style.getPropertyValue('--bg-image')).toContain('data:image/png;base64,BBBB');
expect(presetClasses()).toEqual([]);
});
it('关闭背景时清掉图片与预设,且 data-bg 变 off', () => {
// 这一条守的是「关掉背景后仍显示上一张图」:只把 data-bg 设成 off 而不清
// --bg-image切换回来的瞬间会闪出旧图
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'image', imageDataUrl: 'data:image/png;base64,CCCC' });
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'none' });
const root = document.documentElement;
expect(root.dataset.bg).toBe('off');
expect(root.style.getPropertyValue('--bg-image')).toBe('');
expect(presetClasses()).toEqual([]);
});
});
describe('store 动作', () => {
it('setPreset 同时把 kind 切到 preset 并落盘', () => {
useBackgroundStore.getState().setPreset('sand');
const s = useBackgroundStore.getState();
expect(s.kind).toBe('preset');
expect(s.presetId).toBe('sand');
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).presetId).toBe('sand');
});
it('setImage 记录图片并落盘', () => {
useBackgroundStore.getState().setImage('data:image/jpeg;base64,DDDD');
expect(useBackgroundStore.getState().kind).toBe('image');
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).imageDataUrl).toContain('DDDD');
});
it('reset 回到默认并落盘', () => {
useBackgroundStore.getState().setPreset('ink');
useBackgroundStore.getState().reset();
const s = useBackgroundStore.getState();
expect(s.kind).toBe('none');
expect(s.presetId).toBe(DEFAULT_BACKGROUND.presetId);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).kind).toBe('none');
});
it('localStorage 抛异常时不打断操作', () => {
// 隐私模式 / 配额满:背景是装饰,绝不能让存储失败冒泡成一次崩溃
const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() => useBackgroundStore.getState().setDim(55)).not.toThrow();
expect(useBackgroundStore.getState().dim).toBe(55);
spy.mockRestore();
});
});
describe('上限常量', () => {
it('图片上限留足 localStorage 余量', () => {
// 典型 localStorage 配额 5MB超过一半就很容易与其它键一起写爆
expect(MAX_DATA_URL_BYTES).toBeGreaterThan(100_000);
expect(MAX_DATA_URL_BYTES).toBeLessThan(5_000_000 / 2);
});
});