Files
MailUI4Agents/client/electron/src/stores/backgroundStore.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

260 lines
9.7 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 { 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 };
}