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

@ -0,0 +1,259 @@
import { create } from 'zustand';
/**
* 自定义背景。
*
* 三件事分开表达,因为它们可以组合:
* - `kind` 背景来源(不设 / 预设渐变 / 自定义图片)
* - `dim` 压暗强度 —— 背景越花,正文越需要一层遮罩才读得动
* - `blur` 模糊强度 —— 图片作背景时通常要虚化,否则细节会跟正文抢注意力
*
* # 为什么背景不放进主题 store
*
* 主题light/dark/system是**必须全局一致**的语义:同一个界面里不能一半深色
* 一半浅色。背景是**纯装饰偏好**,可以随时关掉而不影响任何功能,而且它的取值
* 空间(预设 id / 图片数据 / 两个数值)与主题毫无关系。塞在一起会让主题 store
* 承担两种生命周期的状态,也会让「跟随系统」的实现被背景字段淹没。
*
* # 为什么图片要压缩后再存
*
* 存 localStorage。一张手机直出照片 48MB而 localStorage 配额通常只有 5MB
* 写失败会抛异常,用户看到的是「选了图片但没反应」。所以在**存入之前**先等比
* 缩到 MAX_EDGE 并转 JPEG超限则明确拒绝并告知而不是静默失败。
* (不使用 IndexedDB它的异步/事务模型会把这个纯展示功能复杂化,而压缩后
* 的尺寸已经足够小。)
*/
export type BackgroundKind = 'none' | 'preset' | 'image';
export interface BackgroundState {
kind: BackgroundKind;
/** 预设 id如 'aurora')。仅 kind === 'preset' 时有效。 */
presetId: string;
/** 压缩后的 data URL。仅 kind === 'image' 时有效。 */
imageDataUrl: string;
/** 压暗强度 080百分比。 */
dim: number;
/** 模糊强度 024px。 */
blur: number;
}
export const STORAGE_KEY = 'agentmail.background';
/** 预设清单。**渐变的实际色值定义在 index.css**,这里只有 id 与显示名。 */
export const PRESETS: { id: string; label: string }[] = [
{ id: 'aurora', label: '极光' },
{ id: 'dusk', label: '暮色' },
{ id: 'mint', label: '薄荷' },
{ id: 'sand', label: '沙丘' },
{ id: 'ink', label: '墨色' },
{ id: 'mesh', label: '网格' }
];
export const DEFAULT_BACKGROUND: BackgroundState = {
kind: 'none',
presetId: 'aurora',
imageDataUrl: '',
dim: 24,
blur: 8
};
/** 图片最长边。超过就等比缩小 —— 背景是满屏铺开的,再大也看不出来。 */
export const MAX_EDGE = 2560;
/** 压缩后 data URL 的长度上限(约 2.4MB 文本),留足 localStorage 余量。 */
export const MAX_DATA_URL_BYTES = 2_400_000;
export function clampDim(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.dim;
return Math.min(80, Math.max(0, Math.round(v)));
}
export function clampBlur(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.blur;
return Math.min(24, Math.max(0, Math.round(v)));
}
/** 归一化磁盘上可能存在的脏数据(旧版本、手改 localStorage、字段缺失。 */
export function normalizeBackground(raw: unknown): BackgroundState {
const o = (typeof raw === 'object' && raw !== null ? raw : {}) as Partial<BackgroundState>;
const kind: BackgroundKind =
o.kind === 'preset' || o.kind === 'image' || o.kind === 'none' ? o.kind : 'none';
const presetId = PRESETS.some(p => p.id === o.presetId) ? String(o.presetId) : DEFAULT_BACKGROUND.presetId;
const imageDataUrl = typeof o.imageDataUrl === 'string' && o.imageDataUrl.startsWith('data:image/')
? o.imageDataUrl
: '';
return {
// 选了 image 却没有可用图片(被清理/写坏)→ 退回不设,而不是留一个空壳状态
kind: kind === 'image' && !imageDataUrl ? 'none' : kind,
presetId,
imageDataUrl,
dim: clampDim(o.dim ?? DEFAULT_BACKGROUND.dim),
blur: clampBlur(o.blur ?? DEFAULT_BACKGROUND.blur)
};
}
export function readStored(): BackgroundState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_BACKGROUND;
return normalizeBackground(JSON.parse(raw));
} catch {
// 隐私模式或脏 JSON退回默认背景页面照常可用
return DEFAULT_BACKGROUND;
}
}
/**
* 把背景写进 DOM。
*
* 用 CSS 变量 + 一个 `data-bg` 标记,而不是给每个组件加 class
* 全站有 27 个组件,逐个改不现实,漏一处就是「一块不透明卡片浮在背景上」。
* 变量定义在 index.css玻璃化处理也集中在那里。
*/
export function applyBackground(state: BackgroundState = readStored()): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const active = state.kind !== 'none' && (state.kind === 'preset' || !!state.imageDataUrl);
root.dataset.bg = active ? 'on' : 'off';
root.style.setProperty('--bg-dim', `${clampDim(state.dim)}%`);
root.style.setProperty('--bg-blur', `${clampBlur(state.blur)}px`);
if (!active) {
root.style.removeProperty('--bg-image');
root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`));
return;
}
root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`));
if (state.kind === 'image') {
root.style.setProperty('--bg-image', `url("${state.imageDataUrl}")`);
} else {
// 预设的渐变由 class 提供,避免把色值写进 JS写死十六进制就绕过了主题变量
// 深色模式下会原样落下浅色渐变 —— 与组件里写死颜色是同一类错误)。
root.style.removeProperty('--bg-image');
root.classList.add(`bg-preset-${state.presetId}`);
}
}
interface BackgroundStore extends BackgroundState {
setKind: (kind: BackgroundKind) => void;
setPreset: (presetId: string) => void;
setImage: (dataUrl: string) => void;
setDim: (v: number) => void;
setBlur: (v: number) => void;
reset: () => void;
}
function persist(state: BackgroundState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// 配额满:本次会话仍生效,只是下次打开会退回默认值。
// 不抛给调用方 —— 背景是装饰,不该让「换背景失败」打断任何操作。
}
}
export const useBackgroundStore = create<BackgroundStore>((set, get) => {
/** 统一的提交口:先落 DOM再持久化最后更新 state。 */
const commit = (patch: Partial<BackgroundState>) => {
const next: BackgroundState = normalizeBackground({ ...get(), ...patch });
applyBackground(next);
persist(next);
set(next);
};
return {
...readStored(),
setKind: kind => commit({ kind }),
setPreset: presetId => commit({ presetId, kind: 'preset' }),
setImage: imageDataUrl => commit({ imageDataUrl, kind: 'image' }),
setDim: dim => commit({ dim: clampDim(dim) }),
setBlur: blur => commit({ blur: clampBlur(blur) }),
reset: () => commit({ ...DEFAULT_BACKGROUND })
};
});
/**
* 把 disk 上的背景在首屏套用一次。
*
* 不做「内联脚本防闪屏」:背景是装饰层,晚一帧出现只是不够顺滑,
* 不会像主题那样闪出刺眼的白底(主题已有 index.html 的内联脚本)。
*/
export function initBackground(): void {
applyBackground(readStored());
}
/**
* 读取用户选择的图片并压缩成可存储的 data URL。
*
* 失败一律返回带原因的 `{ ok: false }` 而不是抛异常 —— 调用方需要在界面上
* 说明「为什么没换成」,静默失败会让人以为按钮坏了。
*/
export async function prepareImage(
file: File
): Promise<{ ok: true; dataUrl: string } | { ok: false; reason: string }> {
if (!file.type.startsWith('image/')) {
return { ok: false, reason: '请选择图片文件' };
}
// 已压缩过的上限:单张原始文件超过 20MB 就不必读了,解码本身会卡住主线程
if (file.size > 20 * 1024 * 1024) {
return { ok: false, reason: '图片过大(超过 20MB请先裁剪' };
}
if (typeof document === 'undefined') {
return { ok: false, reason: '当前环境不支持图片处理' };
}
try {
const bitmap = await loadImage(file);
const { canvas, width, height } = drawScaled(bitmap, MAX_EDGE);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
if (dataUrl.length > MAX_DATA_URL_BYTES) {
// 缩小一档再试一次。直接拒绝会让「一张 4K 照片」这种完全正常的需求不可用。
const smaller = drawScaled(bitmap, Math.round(MAX_EDGE / 2));
const retry = smaller.canvas.toDataURL('image/jpeg', 0.78);
if (retry.length > MAX_DATA_URL_BYTES) {
return { ok: false, reason: '图片压缩后仍过大,请换一张更小的图片' };
}
return { ok: true, dataUrl: retry };
}
void width;
void height;
return { ok: true, dataUrl };
} catch {
return { ok: false, reason: '图片读取失败,请换一张试试' };
}
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('decode failed'));
};
img.src = url;
});
}
/** 等比缩放到最长边不超过 maxEdge并画出到 canvas。 */
function drawScaled(img: HTMLImageElement, maxEdge: number) {
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const scale = Math.min(1, maxEdge / Math.max(w, h || 1));
const width = Math.max(1, Math.round(w * scale));
const height = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
}
return { canvas, width, height };
}