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:
@ -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>
|
||||
|
||||
{/* 退出登录。
|
||||
|
||||
241
client/electron/src/components/BackgroundPicker.tsx
Normal file
241
client/electron/src/components/BackgroundPicker.tsx
Normal 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 };
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user