# 自定义背景(新功能)
三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。
**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(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,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
260 lines
9.7 KiB
TypeScript
260 lines
9.7 KiB
TypeScript
import { create } from 'zustand';
|
||
|
||
/**
|
||
* 自定义背景。
|
||
*
|
||
* 三件事分开表达,因为它们可以组合:
|
||
* - `kind` 背景来源(不设 / 预设渐变 / 自定义图片)
|
||
* - `dim` 压暗强度 —— 背景越花,正文越需要一层遮罩才读得动
|
||
* - `blur` 模糊强度 —— 图片作背景时通常要虚化,否则细节会跟正文抢注意力
|
||
*
|
||
* # 为什么背景不放进主题 store
|
||
*
|
||
* 主题(light/dark/system)是**必须全局一致**的语义:同一个界面里不能一半深色
|
||
* 一半浅色。背景是**纯装饰偏好**,可以随时关掉而不影响任何功能,而且它的取值
|
||
* 空间(预设 id / 图片数据 / 两个数值)与主题毫无关系。塞在一起会让主题 store
|
||
* 承担两种生命周期的状态,也会让「跟随系统」的实现被背景字段淹没。
|
||
*
|
||
* # 为什么图片要压缩后再存
|
||
*
|
||
* 存 localStorage。一张手机直出照片 4–8MB,而 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;
|
||
/** 压暗强度 0–80(百分比)。 */
|
||
dim: number;
|
||
/** 模糊强度 0–24(px)。 */
|
||
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 };
|
||
}
|