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

@ -4,6 +4,7 @@ import * as api from '../api/client';
import { LockIcon, LogoutIcon } from './icons';
import KeyPanel from './KeyPanel';
import ThemePicker from './ThemePicker';
import BackgroundPicker from './BackgroundPicker';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
export default function AccountPage() {
@ -214,8 +215,9 @@ export default function AccountPage() {
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
<section className="border-t border-gray-200 pt-6">
<section className="border-t border-gray-200 pt-6 space-y-6">
<ThemePicker />
<BackgroundPicker />
</section>
{/* 退出登录。

View File

@ -0,0 +1,241 @@
import { useRef, useState } from 'react';
import {
DEFAULT_BACKGROUND,
MAX_DATA_URL_BYTES,
PRESETS,
prepareImage,
useBackgroundStore,
type BackgroundKind
} from '../stores/backgroundStore';
import { UploadIcon, TrashIcon } from './icons';
/**
* 自定义背景设置。
*
* 三条设计约束:
*
* 1. **预览就是真实效果**。预设缩略图直接复用 `.bg-preset-*` 类(与全屏背景
* 同一份 CSS不是另画一张示意图 —— 否则预览与结果必然会漂移。
* 2. **不让用户交出控制权**。背景会直接影响正文对比度,所以「压暗」与「模糊」
* 都给显式滑杆,而不是自动选一个值。默认值取偏保守的一侧。
* 3. **失败要出声**。图片过大/解码失败时给出具体原因;静默失败会让人以为
* 按钮坏了(本仓库在权限按钮上踩过同类问题)。
*/
export default function BackgroundPicker() {
const kind = useBackgroundStore(s => s.kind);
const presetId = useBackgroundStore(s => s.presetId);
const imageDataUrl = useBackgroundStore(s => s.imageDataUrl);
const dim = useBackgroundStore(s => s.dim);
const blur = useBackgroundStore(s => s.blur);
const setKind = useBackgroundStore(s => s.setKind);
const setPreset = useBackgroundStore(s => s.setPreset);
const setImage = useBackgroundStore(s => s.setImage);
const setDim = useBackgroundStore(s => s.setDim);
const setBlur = useBackgroundStore(s => s.setBlur);
const reset = useBackgroundStore(s => s.reset);
const fileRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const active = kind !== 'none';
const pick = async (file: File | undefined) => {
if (!file) return;
setBusy(true);
setError('');
const result = await prepareImage(file);
setBusy(false);
if (result.ok) {
setImage(result.dataUrl);
} else {
setError(result.reason);
}
};
const optionClass = (on: boolean) =>
`px-3 py-2.5 rounded-control border text-xs flex flex-col items-center gap-1.5 transition-colors ${
on
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`;
return (
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-gray-900"></h3>
{active && (
<button
onClick={reset}
className="text-xs text-gray-500 hover:text-gray-700 inline-flex items-center gap-1"
>
<TrashIcon className="w-3 h-3" />
</button>
)}
</div>
<p className="text-xs text-gray-500 mb-3">
</p>
{/* 三选一:不设 / 预设 / 自定义图片。与主题选择器同一套控件形态,
让「外观」这一组看起来是一件事。 */}
<div className="grid grid-cols-3 gap-2 mb-3">
{(
[
{ value: 'none', label: '无' },
{ value: 'preset', label: '预设' },
{ value: 'image', label: '图片' }
] as { value: BackgroundKind; label: string }[]
).map(o => (
<button
key={o.value}
onClick={() => {
setError('');
setKind(o.value);
}}
className={optionClass(kind === o.value)}
>
{o.label}
</button>
))}
</div>
{kind === 'preset' && (
<div className="grid grid-cols-3 gap-2">
{PRESETS.map(p => (
<button
key={p.id}
onClick={() => setPreset(p.id)}
title={p.label}
className={`h-14 rounded-control border overflow-hidden relative transition-transform hover:scale-[1.02] ${
presetId === p.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'
}`}
>
{/* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果 */}
<span
className={`bg-preset-${p.id} absolute inset-0 block`}
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
aria-hidden="true"
/>
<span className="absolute bottom-0 inset-x-0 text-[10px] py-0.5 bg-black/45 text-white">
{p.label}
</span>
</button>
))}
</div>
)}
{kind === 'image' && (
<div className="space-y-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
void pick(e.target.files?.[0]);
// 清空 value否则连续选同一张图不会再触发 change
e.target.value = '';
}}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={busy}
className="w-full px-3 py-2.5 rounded-control border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-50 inline-flex items-center justify-center gap-2 disabled:opacity-60"
>
<UploadIcon className="w-3.5 h-3.5" />
{busy ? '处理中…' : imageDataUrl ? '更换图片' : '选择图片'}
</button>
{imageDataUrl && (
<div className="h-20 rounded-control border border-gray-300 overflow-hidden">
<img src={imageDataUrl} alt="背景预览" className="w-full h-full object-cover" />
</div>
)}
<p className="text-[11px] text-gray-500">
2560px 2.4MB
</p>
{error && (
<p role="alert" className="text-[11px] text-red-600">
{error}
</p>
)}
</div>
)}
{/* 只有真的启用了背景才显示这两条 —— 背景为「无」时它们没有任何作用,
摆在界面上只会让人疑惑「调了为什么没变化」。 */}
{active && (
<div className="mt-3 space-y-3">
<Slider
label="压暗"
hint="背景越花,正文越需要一层遮罩才读得动"
value={dim}
min={0}
max={80}
suffix="%"
onChange={setDim}
/>
<Slider
label="模糊"
hint="虚化细节,避免背景与正文抢注意力"
value={blur}
min={0}
max={24}
suffix="px"
onChange={setBlur}
/>
{/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */}
<p className="text-[11px] text-gray-500">
{Math.round((imageDataUrl.length || 1) / 1024)}KB{' '}
{Math.round(MAX_DATA_URL_BYTES / 1024)}KB
</p>
</div>
)}
</div>
);
}
/** 带数值显示的滑杆。input[type=range] 的原生外观各平台差异很大,这里统一掉。 */
function Slider({
label,
hint,
value,
min,
max,
suffix,
onChange
}: {
label: string;
hint: string;
value: number;
min: number;
max: number;
suffix: string;
onChange: (v: number) => void;
}) {
return (
<label className="block">
<span className="flex items-center justify-between text-xs text-gray-700">
<span className="font-medium">{label}</span>
<span className="text-gray-500 tabular-nums">
{value}
{suffix}
</span>
</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={e => onChange(Number(e.target.value))}
className="w-full mt-1 accent-blue-600"
aria-label={label}
/>
<span className="block text-[11px] text-gray-500">{hint}</span>
</label>
);
}
/** 「恢复默认」用到,导出以便测试断言默认值形状。 */
export { DEFAULT_BACKGROUND };

View File

@ -50,29 +50,45 @@ export default function ThemePicker() {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-medium text-gray-900"></h3>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-gray-900"></h3>
{pref === 'system' && (
<span className="text-xs text-gray-500">
{resolved === 'dark' ? '深色' : '浅色'}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
<p className="text-xs text-gray-500 mb-3">
</p>
{/*
分段控件而不是三个独立卡片。
三选一的语义是「三种互斥的偏好」,分段控件正好表达这个;独立卡片看起来
像可多选。选中态用整块底色(而非仅描边),在深色下也比描边更容易辨认。
*/}
<div
role="radiogroup"
aria-label="主题偏好"
className="inline-flex p-1 rounded-control bg-gray-100 border border-gray-200"
>
{OPTIONS.map(o => {
const active = pref === o.value;
return (
<button
key={o.value}
role="radio"
aria-checked={active}
onClick={() => setPref(o.value)}
title={o.hint}
className={`px-3 py-2.5 rounded border text-xs flex flex-col items-center gap-1.5 transition-colors ${
className={`px-3 py-1.5 rounded-md text-xs inline-flex items-center gap-1.5 transition-colors ${
active
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<o.Icon className="w-4 h-4" />
<o.Icon className="w-3.5 h-3.5" />
{o.label}
</button>
);