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:
@ -19,7 +19,7 @@
|
||||
"build:linux": "vite build && electron-builder --linux",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && vitest run",
|
||||
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && vitest run",
|
||||
"test:narrow": "node test/manual/narrow-verify.mjs",
|
||||
"test:wide": "node test/manual/wide-regression.mjs",
|
||||
"test:components": "vitest run",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -197,6 +197,51 @@
|
||||
--s-yellow-700: 161 98 7;
|
||||
--s-yellow-800: 133 77 14;
|
||||
--s-yellow-900: 113 63 18;
|
||||
|
||||
/*
|
||||
* ─── 背景层 ───
|
||||
*
|
||||
* 自定义背景是**装饰层**,它的取值空间与主题色板无关,所以不复用 --c-*:
|
||||
* --c-* 是要被主题反转的语义色,而这里只是「叠在图片/渐变上的一层遮罩」。
|
||||
*
|
||||
* --bg-scrim 是遮罩颜色:浅色下用白(把花哨的图案洗淡),
|
||||
* 深色下用黑(压暗)。两者都在 .dark 里换值。
|
||||
* --bg-dim 与 --bg-blur 由 backgroundStore 在运行时写入,这里是初值。
|
||||
*/
|
||||
--bg-scrim: 255 255 255;
|
||||
--bg-dim: 24%;
|
||||
--bg-blur: 8px;
|
||||
/* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */
|
||||
--bg-glass: 0.82;
|
||||
|
||||
/*
|
||||
* ─── 尺寸与动效 ───
|
||||
*
|
||||
* 提取成变量的理由与颜色相同:一处定义、全站一致。写死数值时「圆角"
|
||||
* 会随组件作者的随手一写而漂移(本仓库曾同时存在 4 种圆角),
|
||||
* 而动效时长不一致会让界面显得格崩。
|
||||
*/
|
||||
--radius-card: 0.875rem;
|
||||
--radius-control: 0.5rem;
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 180ms;
|
||||
|
||||
/*
|
||||
* 抬高阴影。
|
||||
*
|
||||
* 浅色下靠阴影表达层次;深色下黑色阴影几乎看不见,层次改由更亮的边框
|
||||
* 与表层面亮度差承担 —— 因此深色主题里换一整组值(见下),
|
||||
* 而不是继续加深同一个阴影。
|
||||
*
|
||||
* 注:本注释刻意不写出深色选择器的字面量 —— theme.test.mjs 用
|
||||
* indexOf(选择器) 切分两个颜色块,在 :root 的注释里出现那个字符串
|
||||
* 会把它提前截断(已踩过一次)。该测试现已先剥注释,但这里仍避开。
|
||||
*/
|
||||
--shadow-1: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.1);
|
||||
--shadow-2: 0 2px 4px rgb(15 23 42 / 0.05), 0 4px 12px rgb(15 23 42 / 0.1);
|
||||
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12), 0 2px 6px rgb(15 23 42 / 0.08);
|
||||
--hairline: 0 0 0;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@ -226,8 +271,15 @@
|
||||
.dark {
|
||||
--c-white: 24 27 33;
|
||||
|
||||
/* 近白而非纯白:深色页面上纯白字偏刺眼。关键是它不跟着 --c-white 变暗。 */
|
||||
--c-on-accent: 244 246 250;
|
||||
/*
|
||||
* 近白而非纯白:深色页面上纯白字偏刺眼。
|
||||
*
|
||||
* 但它同时是**实心彩底按钮上的文字**,而那儿的对比度是硬指标:实测 244
|
||||
* 时「红底白字」只有 4.46:1,低于 WCAG AA 的 4.5(真实渲染量出,见
|
||||
* test/manual/background-verify.mjs)。抬到 250 后为 4.65:1,
|
||||
* 仍不是纯白,观感上仍保留了「不刺眼」的初衷。
|
||||
*/
|
||||
--c-on-accent: 250 250 252;
|
||||
|
||||
--c-gray-50: 17 19 24;
|
||||
--c-gray-100: 32 36 44;
|
||||
@ -254,6 +306,23 @@
|
||||
--c-chrome-800: 30 35 44;
|
||||
--c-chrome-900: 12 14 18;
|
||||
|
||||
/*
|
||||
* 背景遮罩换成黑:浅色下用白把图案洗淡,深色下必须压暗,
|
||||
* 否则一张浅色照片会在深色界面里舗成一块亮斑,正文完全读不动。
|
||||
*/
|
||||
--bg-scrim: 0 0 0;
|
||||
/* 深色下面板要更实:背景亮部与深色卡片对比过强时,文字会显得发灰。 */
|
||||
--bg-glass: 0.86;
|
||||
|
||||
/*
|
||||
* 深色下的层次靠「边框亮于底」而不是阴影。
|
||||
* 保留一行极淡的黑色阴影只为了与浅色统一接管机制,真正的边界感来自 --hairline。
|
||||
*/
|
||||
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--shadow-2: 0 2px 6px rgb(0 0 0 / 0.45);
|
||||
--shadow-3: 0 10px 30px rgb(0 0 0 / 0.55);
|
||||
--hairline: 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
|
||||
/*
|
||||
* ─── 强调色(深色)───
|
||||
*
|
||||
@ -492,3 +561,223 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* 自定义背景 + 现代化打磨
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* # 为什么这一段放在所有 @layer 之外
|
||||
*
|
||||
* Tailwind 的层序是 base < components < utilities,而这里要覆盖的正是
|
||||
* **utilities 生成的** `.bg-white` / `.bg-gray-50`。写进 @layer components
|
||||
* 会被 utilities 压过去(静默失效,只有肉眼能看出「背景没生效」);
|
||||
* 写进 @layer utilities 则取决于 Tailwind 内部的合并顺序,不可靠。
|
||||
* 未分层的 CSS 在层叠中恒胜过分层 CSS —— 这是唯一稳定可靠的位置。
|
||||
*/
|
||||
|
||||
/* ─── 背景层本体 ─── */
|
||||
|
||||
.app-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/*
|
||||
* 必须是**负** z-index。
|
||||
*
|
||||
* 层叠顺序里 z-index:0/auto 的定位元素画在常规流内容**之上**,用它会把
|
||||
* 整个界面盖住(背板是 fixed 全屏的,连点击区都会挡住 —— 虽然有
|
||||
* pointer-events:none 兵底,但视觉上完全遮住)。负值才画在常规流内容
|
||||
* 之下、同时在父元素背景之上,正是「缓景」该在的位置。
|
||||
*
|
||||
* 它能露出来还依赖另一条:背景开启时把所有不透明的页面底
|
||||
* (bg-gray-50 / bg-slate-100 / body)改成透明,见下面。
|
||||
*/
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
/* 默认不可见;背景开启时才铺开(data-bg 由 backgroundStore 写在 <html> 上)。 */
|
||||
background-image: var(--bg-image, none);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(var(--bg-blur));
|
||||
/* 模糊会把边缘拖进画面,放大 2% 抵消。 */
|
||||
transform: scale(1.02);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-base) var(--ease-out-soft);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
html[data-bg='on'] .app-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* 遮罩:把图案压下去,让正文读得动。
|
||||
*
|
||||
* 用单独一层而不是直接调低 background 的透明度:透明度会让背景变淡但仍与
|
||||
* 正文争夺对比度,而遮罩是**在两者之间插一层**,颜色随主题反转(浅色用白、
|
||||
* 深色用黑),因此两种模式下都是「背景退后、内容在前」。
|
||||
*/
|
||||
.app-backdrop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgb(var(--bg-scrim) / var(--bg-dim));
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── 预设渐变 ───
|
||||
*
|
||||
* 全部用现有调色板的**表面段**(50–300)拼出来,因此:
|
||||
* - 无需新增任何写死的十六进制值
|
||||
* - 自动随主题变化 —— 那一段在深色下本来就是暗的(见 index.css 的 .dark
|
||||
* 与 theme.test.mjs 第 19 条),浅色得到柔和pastel、深色得到低沉暗调
|
||||
*/
|
||||
.bg-preset-aurora {
|
||||
--bg-image: radial-gradient(at 18% 22%, rgb(var(--c-blue-200)) 0%, transparent 55%),
|
||||
radial-gradient(at 82% 12%, rgb(var(--c-green-100)) 0%, transparent 50%),
|
||||
radial-gradient(at 68% 82%, rgb(var(--c-blue-100)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-dusk {
|
||||
--bg-image: radial-gradient(at 12% 80%, rgb(var(--c-orange-100)) 0%, transparent 55%),
|
||||
radial-gradient(at 85% 25%, rgb(var(--c-blue-200)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-mint {
|
||||
--bg-image: radial-gradient(at 25% 30%, rgb(var(--c-green-100)) 0%, transparent 55%),
|
||||
radial-gradient(at 78% 70%, rgb(var(--c-blue-100)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-sand {
|
||||
--bg-image: radial-gradient(at 20% 25%, rgb(var(--c-amber-100)) 0%, transparent 60%),
|
||||
radial-gradient(at 80% 75%, rgb(var(--c-orange-100)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-ink {
|
||||
--bg-image: radial-gradient(at 30% 20%, rgb(var(--c-gray-200)) 0%, transparent 60%),
|
||||
linear-gradient(160deg, rgb(var(--c-gray-100)), rgb(var(--c-gray-200)));
|
||||
}
|
||||
.bg-preset-mesh {
|
||||
--bg-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgb(var(--c-gray-200) / 0.55) 0 1px,
|
||||
transparent 1px 28px
|
||||
),
|
||||
repeating-linear-gradient(90deg, rgb(var(--c-gray-200) / 0.55) 0 1px, transparent 1px 28px);
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── 背景开启时的表面处理 ───
|
||||
*
|
||||
* 不改 27 个组件的 class:背景是全局装饰,逐个组件加 class 必然漏(漏掉的那块
|
||||
* 就是一张不透明卡片浮在背景上)。这里按 Tailwind 生成的实际类名统一接管。
|
||||
*
|
||||
* 被接管的是三类:
|
||||
* - 页面底(bg-gray-50 / bg-slate-100)→ 完全透明,让出背景
|
||||
* - 卡片/面板(bg-white) → 半透明 + 背景模糊(玻璃)
|
||||
* - 应用框架(bg-chrome-*) → 半透明,保持「框架比内容沉」
|
||||
*/
|
||||
html[data-bg='on'] body,
|
||||
html[data-bg='on'] .bg-gray-50,
|
||||
html[data-bg='on'] .bg-slate-100 {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
html[data-bg='on'] .bg-white {
|
||||
background-color: rgb(var(--c-white) / var(--bg-glass));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
}
|
||||
|
||||
/* 悬停态也要接管:不接管的话鼠标一进面板就会闪成不透明(明显跳动)。 */
|
||||
html[data-bg='on'] .hover\:bg-gray-50:hover,
|
||||
html[data-bg='on'] .hover\:bg-gray-100:hover {
|
||||
background-color: rgb(var(--c-gray-100) / var(--bg-glass));
|
||||
}
|
||||
|
||||
html[data-bg='on'] .bg-chrome-900 {
|
||||
background-color: rgb(var(--c-chrome-900) / 0.86);
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
}
|
||||
html[data-bg='on'] .bg-chrome-800 {
|
||||
background-color: rgb(var(--c-chrome-800) / 0.82);
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
}
|
||||
/*
|
||||
* chrome-600/700 **刻意保持不透明**。
|
||||
*
|
||||
* 它们不是大面板,而是导航项与 15px 的计数徽标(如「收件 12」)。给它们加
|
||||
* 透明度有两个代价:一是看不出背景(本来就太小),二是**正文对比度被拉低**
|
||||
* ——实测徽标上的数字从原值降到 4.46:1,低于 WCAG AA 的 4.5(真实渲染量得,
|
||||
* 不是估算)。小控件的可读性优先于装饰效果。
|
||||
*/
|
||||
|
||||
/*
|
||||
* ─── 现代化打磨 ───
|
||||
*/
|
||||
|
||||
/* 滚动条:桌面应用里它常驻可见,系统默认样式(尤其窄屏上的粗条)很旧。 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--c-gray-300)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(var(--c-gray-300));
|
||||
border-radius: 9999px;
|
||||
/* 透明边框 + background-clip:让滑块比轨道窄,不贴边,观感更轻。 */
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(var(--c-gray-400));
|
||||
}
|
||||
.dark * {
|
||||
scrollbar-color: rgb(var(--c-gray-600)) transparent;
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(var(--c-gray-600));
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(var(--c-gray-500));
|
||||
}
|
||||
|
||||
/*
|
||||
* 键盘焦点环。
|
||||
*
|
||||
* 只在**键盘**导航时出现(:focus-visible),鼠标点击不画 —— 常驻焦点框会让
|
||||
* 界面显得脏。这也是可访问性的硬要求:没有可见焦点,键盘用户无法定位。
|
||||
*/
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--c-blue-500));
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。 */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[role='button'] {
|
||||
transition: background-color var(--dur-fast) var(--ease-out-soft),
|
||||
border-color var(--dur-fast) var(--ease-out-soft),
|
||||
color var(--dur-fast) var(--ease-out-soft), box-shadow var(--dur-base) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
/* 尊重系统的「减弱动态效果」:前庭功能障碍者会因动画不适。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initTheme } from './stores/themeStore';
|
||||
import { initBackground } from './stores/backgroundStore';
|
||||
import './index.css';
|
||||
|
||||
// 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。
|
||||
@ -9,6 +10,11 @@ import './index.css';
|
||||
// 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。
|
||||
initTheme();
|
||||
|
||||
// 自定义背景同样要在 render 之前套用(读 localStorage + 写 CSS 变量)。
|
||||
// 它只是装饰层,晚一帧不会像主题那样闪出刺眼白底,但提前套用能避免
|
||||
// 「先看到纯色底、再变成背景」的抽动。
|
||||
initBackground();
|
||||
|
||||
/**
|
||||
* 用 Visual Viewport 驱动应用高度。
|
||||
*
|
||||
@ -39,6 +45,15 @@ document.addEventListener('focusout', () => requestAnimationFrame(syncEditingSta
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
{/*
|
||||
背景层挂在 App **之外**。
|
||||
|
||||
挂在 App 里面的话,它只会存在于主界面的那几个分支上 —— 登录页、加载页、
|
||||
初始化向导都各自 return 自己的外层 div。背景是全局装饰,用户不该
|
||||
「一进登录页背景就没了」。它 position:fixed + z-index:-1,与 App 的
|
||||
布局无关,放这里最稳。
|
||||
*/}
|
||||
<div className="app-backdrop" aria-hidden="true" />
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
259
client/electron/src/stores/backgroundStore.ts
Normal file
259
client/electron/src/stores/backgroundStore.ts
Normal file
@ -0,0 +1,259 @@
|
||||
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 };
|
||||
}
|
||||
@ -94,6 +94,34 @@ export default {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
/**
|
||||
* 圆角整体调大一档。
|
||||
*
|
||||
* 原值(rounded 0.25rem / md 0.375rem)是好几年前 Tailwind 默认的
|
||||
* 紧凑风格,在宽屏桌面应用上显得硬。这里只动默认比例尺,不改任何
|
||||
* 组件的类名 —— 全站 200 处圆角一次性刷新,也不会出现「新组件用大圆角、
|
||||
* 旧组件还是小圆角」的断层。
|
||||
*
|
||||
* 不用 CSS 变量:Tailwind 的 borderRadius 不参与主题切换,
|
||||
* 两种模式下圆角本就相同,走变量只会多一层间接。
|
||||
*/
|
||||
borderRadius: {
|
||||
DEFAULT: '0.5rem',
|
||||
md: '0.625rem',
|
||||
lg: '0.75rem',
|
||||
xl: '1rem',
|
||||
'2xl': '1.25rem',
|
||||
/*
|
||||
* 语义化令牌(定义在 index.css 的 :root)。
|
||||
*
|
||||
* 数值档位是「大中小」;这两个名字回答的是「用在哪里」:
|
||||
* - card 卡片/面板(比控件更大,观感更轻)
|
||||
* - control 按钮/输入/滑杆(可点控件的统一形状)
|
||||
* 两者都是显式选择,不依赖作者记得选 md 还是 lg。
|
||||
*/
|
||||
card: 'var(--radius-card)',
|
||||
control: 'var(--radius-control)'
|
||||
},
|
||||
/**
|
||||
* textColor 单独覆盖 white。
|
||||
*
|
||||
|
||||
157
client/electron/test/background.test.mjs
Normal file
157
client/electron/test/background.test.mjs
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 自定义背景的结构性检查。
|
||||
*
|
||||
* 与 theme.test.mjs 同一风格:判据是「源码里存在/不存在某种形态」,不需要浏览器。
|
||||
* 真正的视觉验收靠手工脚本(test/manual/)。
|
||||
*
|
||||
* 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是
|
||||
* 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const read = p => readFileSync(join(here, p), 'utf8');
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const css = read('../src/index.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const store = read('../src/stores/backgroundStore.ts');
|
||||
const picker = read('../src/components/BackgroundPicker.tsx');
|
||||
const main = read('../src/main.tsx');
|
||||
|
||||
const rootBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const darkBlock = css.slice(css.indexOf('.dark {'));
|
||||
|
||||
// 1) 遮罩颜色必须两种主题各一份。
|
||||
// 只有一个值时,深色模式下用白色遮罩会把照片洗成一块亮斑,正文完全读不动。
|
||||
check(
|
||||
'--bg-scrim 在浅色与深色下都有定义',
|
||||
/--bg-scrim:\s*\d+\s+\d+\s+\d+/.test(rootBlock) && /--bg-scrim:\s*\d+\s+\d+\s+\d+/.test(darkBlock),
|
||||
'深色缺少遮罩色时浅色照片会压不住'
|
||||
);
|
||||
|
||||
// 2) 遮罩色必须是 RGB 三元组(与色板同一约定),否则 rgb(var() / var()) 无效。
|
||||
check(
|
||||
'--bg-scrim 存 RGB 三元组',
|
||||
/--bg-scrim:\s*\d+\s+\d+\s+\d+;/.test(rootBlock) && !/--bg-scrim:\s*#/.test(css)
|
||||
);
|
||||
|
||||
// 3) 背景层必须画在内容**之下**。
|
||||
// z-index:0 / auto 的定位元素会画在常规流内容之上,把整个界面盖住。
|
||||
check(
|
||||
'背景层用负 z-index 且不吃点击',
|
||||
/\.app-backdrop\s*\{[^}]*z-index:\s*-1/.test(css) &&
|
||||
/\.app-backdrop\s*\{[^}]*pointer-events:\s*none/.test(css),
|
||||
'z-index 不为负会盖住界面'
|
||||
);
|
||||
|
||||
// 4) 背景开启时必须让出不透明的页面底,否则背景永远看不见。
|
||||
// 这是最容易漏的一条:写好了渐变、挂好了层,却被 bg-gray-50 挡住。
|
||||
//
|
||||
// 正则要写成「选择器列表 … { 声明体 }」而不是 [^{]* 直接跨到声明:
|
||||
// [^{] 遇 { 即停,而这里要跨过的正是选择器后面的那个 {。
|
||||
check(
|
||||
'data-bg=on 时页面底变透明',
|
||||
/html\[data-bg='on'\]\s+body/.test(css) &&
|
||||
/html\[data-bg='on'\][^{}]*\.bg-gray-50/.test(css) &&
|
||||
/html\[data-bg='on'\][^{}]*\{[^}]*background-color:\s*transparent/.test(css)
|
||||
);
|
||||
|
||||
// 5) 玻璃化只应在背景开启时生效。
|
||||
// 若无条件给 .bg-white 加半透明/模糊,关闭背景的用户会看到一层发灰的卡片。
|
||||
const glassRules = css.match(/html\[data-bg='on'\][^{]*\{[^}]*backdrop-filter/g) || [];
|
||||
const bareGlass = /(^|\n)\s*\.bg-white\s*\{[^}]*backdrop-filter/.test(css);
|
||||
check(
|
||||
'backdrop-filter 仅在 data-bg=on 下使用',
|
||||
glassRules.length > 0 && !bareGlass,
|
||||
bareGlass ? '存在无条件的 .bg-white 模糊规则' : '未找到玻璃化规则'
|
||||
);
|
||||
|
||||
// 6) 悬停态也要接管。
|
||||
// 不接管的话鼠标一进面板就从不透明闪回,观感是明显的跳动。
|
||||
check(
|
||||
'悬停态一并在背景模式下接管',
|
||||
/html\[data-bg='on'\][^{]*\.hover\\:bg-gray-50:hover/.test(css)
|
||||
);
|
||||
|
||||
// 7) 预设渐变只能由 CSS 提供色值,组件里不得写死颜色。
|
||||
// 写死十六进制会绕过主题变量 —— 深色模式下会原样落下浅色渐变。
|
||||
const presetClasses = (css.match(/\.bg-preset-[a-z]+\s*\{/g) || []).length;
|
||||
const hexInPicker = picker.match(/#[0-9a-fA-F]{3,6}\b/g) || [];
|
||||
check(
|
||||
'预设渐变定义在 CSS 且组件无写死颜色',
|
||||
presetClasses >= 4 && hexInPicker.length === 0,
|
||||
hexInPicker.length ? `组件含 ${hexInPicker.join(',')}` : `只找到 ${presetClasses} 个预设`
|
||||
);
|
||||
|
||||
// 8) 预设必须复用调色板变量(因此自动随主题变),而不是字面色值。
|
||||
check(
|
||||
'预设渐变复用调色板变量',
|
||||
/\.bg-preset-aurora\s*\{[^}]*rgb\(var\(--c-/.test(css)
|
||||
);
|
||||
|
||||
// 9) 背景必须在 render 之前套用,且在 App 之外挂载。
|
||||
// 挂在 App 内会只存在于主界面分支上,登录页/加载页没有背景。
|
||||
check(
|
||||
'启动时套用背景且挂在 App 之外',
|
||||
/initBackground\(\)/.test(main) && /className="app-backdrop"/.test(main),
|
||||
'缺少 initBackground 或背景层不在 App 外'
|
||||
);
|
||||
|
||||
// 10) 存储键与归一化入口存在(旧数据/脏数据不能让页面白屏)。
|
||||
check(
|
||||
'有独立存储键与脏数据归一化',
|
||||
/STORAGE_KEY\s*=\s*'agentmail\.background'/.test(store) && /normalizeBackground/.test(store)
|
||||
);
|
||||
|
||||
// 11) 图片必须压缩后再存,且有明确上限。
|
||||
// 手机直出照片 4–8MB,直接塞 localStorage 会超配额并抛异常 ——
|
||||
// 用户看到的是「选了图片没反应」。
|
||||
check(
|
||||
'图片有缩放与体积上限',
|
||||
/MAX_EDGE\s*=\s*\d+/.test(store) &&
|
||||
/MAX_DATA_URL_BYTES\s*=\s*[\d_]+/.test(store) &&
|
||||
/drawScaled/.test(store)
|
||||
);
|
||||
|
||||
// 12) 失败必须给出原因,不能静默。
|
||||
check(
|
||||
'图片处理失败返回原因',
|
||||
/ok:\s*false;\s*reason:\s*string/.test(store) && /role="alert"/.test(picker)
|
||||
);
|
||||
|
||||
// 13) 背景是装饰偏好,写 DOM 失败不得抛出打断操作。
|
||||
check(
|
||||
'背景写入 DOM 前有环境判断',
|
||||
/typeof document === 'undefined'/.test(css.slice(0, 0) + store)
|
||||
);
|
||||
|
||||
// 14) 动效必须尊重 prefers-reduced-motion。
|
||||
check(
|
||||
'尊重 prefers-reduced-motion',
|
||||
/@media\s*\(prefers-reduced-motion:\s*reduce\)/.test(css)
|
||||
);
|
||||
|
||||
// 15) 组件里不得出现未映射色族(emerald/purple 等会绕过主题)。
|
||||
const compDir = join(here, '../src/components');
|
||||
const unmapped = [];
|
||||
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
|
||||
const src = readFileSync(join(compDir, f), 'utf8');
|
||||
if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f);
|
||||
}
|
||||
check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' '));
|
||||
|
||||
console.log(`\n背景:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
301
client/electron/test/manual/background-verify.mjs
Normal file
301
client/electron/test/manual/background-verify.mjs
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 自定义背景 + 外观现代化的手工验收。
|
||||
*
|
||||
* 需要共享 Chromium(CDP 9222)与一个活着的 Gateway。与 theme.test.mjs /
|
||||
* background.test.mjs 的分工:那两个守「源码写成了什么形态」,这里量的是
|
||||
* **真实渲染结果** —— 背景层有没有真的露出来、玻璃化有没有生效、
|
||||
* 正文在背景之上还能不能读。这些只有真实渲染能回答。
|
||||
*
|
||||
* 用法:
|
||||
* AGENTMAIL_DIST=$PWD/client/electron/dist \
|
||||
* AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* ADMIN_USER=gui-lab ADMIN_PW=... \
|
||||
* node test/manual/background-verify.mjs
|
||||
*
|
||||
* 设 AGENTMAIL_DIST 时,页面壳与静态资源从本地 dist 注入,API/SSE 仍走真实
|
||||
* Gateway —— 因此可以在不部署、不重启的前提下验收本次构建。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
|
||||
const OUT = process.env.SHOT_DIR || '/tmp/appearance-shots';
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** WCAG 相对亮度与对比度。 */
|
||||
const srgb = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const luminance = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = luminance(a);
|
||||
const l2 = luminance(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const parseRgb = s => {
|
||||
const m = String(s).match(/(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
|
||||
const root = () => process.env.SHOT_DIR;
|
||||
void root;
|
||||
|
||||
await mkdir(OUT, { recursive: true });
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
/** 切到某个主题 + 某个背景,并等一帧让过渡稳定。 */
|
||||
async function configure(theme, kind, presetId) {
|
||||
await page.evaluate(
|
||||
([t, k, p]) => {
|
||||
localStorage.setItem('agentmail.theme', t);
|
||||
localStorage.setItem(
|
||||
'agentmail.background',
|
||||
JSON.stringify({
|
||||
kind: k,
|
||||
presetId: p,
|
||||
imageDataUrl: '',
|
||||
dim: 24,
|
||||
blur: 8
|
||||
})
|
||||
);
|
||||
},
|
||||
[theme, kind, presetId]
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
// reload 后要重新登录态?不需要:cookie 仍在。等应用壳渲染出来。
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
|
||||
try {
|
||||
// 确认已登录(helper 内部在需要时会填表;这里兜底判断一次)
|
||||
const loggedIn = await page.evaluate(() => !!document.querySelector('nav, aside, main'));
|
||||
if (!loggedIn) {
|
||||
console.log(' 提示:未进入主界面,可能登录失败');
|
||||
}
|
||||
|
||||
// ── 1. 关闭背景时不应有任何背景层可见 ──
|
||||
console.log('\n── 背景:无 ──');
|
||||
await configure('light', 'none', 'aurora');
|
||||
const noneState = await page.evaluate(() => ({
|
||||
dataBg: document.documentElement.dataset.bg,
|
||||
opacity: getComputedStyle(document.querySelector('.app-backdrop')).opacity,
|
||||
bodyBg: getComputedStyle(document.body).backgroundColor
|
||||
}));
|
||||
check('data-bg 为 off', noneState.dataBg === 'off');
|
||||
check('背景层透明(opacity 0)', Number(noneState.opacity) === 0, noneState.opacity);
|
||||
check('页面底仍是不透明的灰底', parseRgb(noneState.bodyBg) !== null && !/transparent/.test(noneState.bodyBg), noneState.bodyBg);
|
||||
await page.screenshot({ path: `${OUT}/01-none-light.png` });
|
||||
|
||||
// ── 2. 预设背景在浅色下真的露出来 ──
|
||||
console.log('\n── 背景:预设(极光) 浅色 ──');
|
||||
await configure('light', 'preset', 'aurora');
|
||||
const lightPreset = await page.evaluate(() => ({
|
||||
dataBg: document.documentElement.dataset.bg,
|
||||
opacity: getComputedStyle(document.querySelector('.app-backdrop')).opacity,
|
||||
bodyBg: getComputedStyle(document.body).backgroundColor,
|
||||
image: getComputedStyle(document.querySelector('.app-backdrop')).backgroundImage,
|
||||
preset: document.documentElement.className.includes('bg-preset-aurora'),
|
||||
dim: getComputedStyle(document.documentElement).getPropertyValue('--bg-dim').trim()
|
||||
}));
|
||||
check('data-bg 为 on', lightPreset.dataBg === 'on');
|
||||
check('背景层可见', Number(lightPreset.opacity) === 1, lightPreset.opacity);
|
||||
check('预设类已挂到 html', lightPreset.preset);
|
||||
check('渐变已解析成真实背景图', /gradient/.test(lightPreset.image), lightPreset.image.slice(0, 60));
|
||||
check('页面底已让出(透明)', /transparent|rgba\(0, 0, 0, 0\)/.test(lightPreset.bodyBg), lightPreset.bodyBg);
|
||||
check('压暗变量已写入', lightPreset.dim === '24%', lightPreset.dim);
|
||||
await page.screenshot({ path: `${OUT}/02-preset-aurora-light.png` });
|
||||
|
||||
// ── 3. 玻璃化:面板必须半透明且有背景模糊 ──
|
||||
//
|
||||
// 选择器必须排除**完全透明**的元素:背景开启后大量 .bg-gray-50 会变成
|
||||
// rgba(0, 0, 0, 0),它们是「让出背景」的空白容器,不是玻璃面板。
|
||||
// 上一版取「第一个 alpha<1 的元素」正好命中它们,得出「玻璃化没生效」的
|
||||
// 错误结论(实测面板其实是 rgba(255,255,255,0.82) + blur(16px))。
|
||||
const glass = await page.evaluate(() => {
|
||||
const alphaOf = s => {
|
||||
const parts = String(s).split(',');
|
||||
return parts.length > 3 ? parseFloat(parts[3]) : 1;
|
||||
};
|
||||
const candidates = [...document.querySelectorAll('div,aside,nav')].filter(el => {
|
||||
const a = alphaOf(getComputedStyle(el).backgroundColor);
|
||||
return a > 0.05 && a < 1; // 排除全透明容器
|
||||
});
|
||||
if (!candidates.length) return { found: false };
|
||||
// 取面积最大的那块:它才是真正承载内容的玻璃面板
|
||||
const el = candidates.sort((a, b) => {
|
||||
const ra = a.getBoundingClientRect();
|
||||
const rb = b.getBoundingClientRect();
|
||||
return rb.width * rb.height - ra.width * ra.height;
|
||||
})[0];
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
found: true,
|
||||
bg: cs.backgroundColor,
|
||||
filter: cs.backdropFilter || cs.webkitBackdropFilter,
|
||||
cls: el.className.toString().slice(0, 80)
|
||||
};
|
||||
});
|
||||
check('存在半透明面板', glass.found, JSON.stringify(glass));
|
||||
if (glass.found) {
|
||||
// 用 parseFloat 而不是 Number:getComputedStyle 给的是 `rgba(255, 255, 255, 0.82)`,
|
||||
// 最后一段带右括号,Number(' 0.82)') 是 NaN(上一版就踩了这个)。
|
||||
const alpha = parseFloat(String(glass.bg).split(',')[3] ?? '1');
|
||||
check('面板不透明度在 (0,1) 之间', alpha > 0 && alpha < 1, String(alpha));
|
||||
check('面板启用了背景模糊', /blur/.test(glass.filter || ''), String(glass.filter));
|
||||
}
|
||||
|
||||
// ── 4. 深色 + 背景:遮罩必须跟着反转(否则照片会压不住) ──
|
||||
console.log('\n── 背景:预设(极光) 深色 ──');
|
||||
await configure('dark', 'preset', 'aurora');
|
||||
const darkState = await page.evaluate(() => {
|
||||
const backdrop = document.querySelector('.app-backdrop');
|
||||
const scrim = getComputedStyle(backdrop, '::after');
|
||||
return {
|
||||
isDark: document.documentElement.classList.contains('dark'),
|
||||
dataBg: document.documentElement.dataset.bg,
|
||||
scrim: scrim.backgroundColor,
|
||||
colorScheme: getComputedStyle(document.documentElement).colorScheme
|
||||
};
|
||||
});
|
||||
const scrimRgb = parseRgb(darkState.scrim);
|
||||
check('深色类已生效', darkState.isDark);
|
||||
check('深色下遮罩是暗色', scrimRgb !== null && luminance(scrimRgb) < 0.2, darkState.scrim);
|
||||
check('color-scheme 随主题声明', /dark/.test(darkState.colorScheme), darkState.colorScheme);
|
||||
await page.screenshot({ path: `${OUT}/03-preset-aurora-dark.png` });
|
||||
|
||||
// ── 5. 正文在背景之上仍然可读 ──
|
||||
// 逐元素算真实对比度:把最近的不透明祖先底色当作背景(与 theme-verify 同法)。
|
||||
const contrastReport = await page.evaluate(() => {
|
||||
const parse = s => {
|
||||
const m = String(s).match(/(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
const srgb = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const lum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = lum(a);
|
||||
const l2 = lum(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const effBg = el => {
|
||||
let cur = el;
|
||||
while (cur && cur !== document.documentElement) {
|
||||
const cs = getComputedStyle(cur);
|
||||
const bg = parse(cs.backgroundColor);
|
||||
const alpha = cs.backgroundColor.split(',').length > 3 ? parseFloat(cs.backgroundColor.split(',')[3]) : 1;
|
||||
if (bg && alpha > 0.85) return bg;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return parse(getComputedStyle(document.body).backgroundColor) || [255, 255, 255];
|
||||
};
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll('p, span, h1, h2, h3, a, button, li, label')) {
|
||||
const text = (el.textContent || '').trim();
|
||||
if (!text || text.length < 2) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 6) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.5) continue;
|
||||
const fg = parse(cs.color);
|
||||
if (!fg) continue;
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const bold = Number(cs.fontWeight) >= 700;
|
||||
const need = size >= 24 || (size >= 18.66 && bold) ? 3 : 4.5;
|
||||
const ratio = contrast(fg, effBg(el));
|
||||
if (ratio < need) {
|
||||
out.push({ text: text.slice(0, 24), ratio: Number(ratio.toFixed(2)), need, size });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
check(
|
||||
'深色 + 背景:无低对比正文(WCAG AA)',
|
||||
contrastReport.length === 0,
|
||||
contrastReport.slice(0, 4).map(x => `${x.text}:${x.ratio}<${x.need}`).join(' | ')
|
||||
);
|
||||
for (const miss of contrastReport.slice(0, 6)) {
|
||||
console.log(` · "${miss.text}" ${miss.ratio} < ${miss.need}(${miss.size}px)`);
|
||||
}
|
||||
|
||||
// ── 6. 自动模式:跟随系统深浅色 ──
|
||||
console.log('\n── 主题:跟随系统 ──');
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'system'));
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const autoDark = await page.evaluate(() => ({
|
||||
dark: document.documentElement.classList.contains('dark'),
|
||||
colorScheme: document.documentElement.style.colorScheme,
|
||||
pref: localStorage.getItem('agentmail.theme')
|
||||
}));
|
||||
check('系统深色 → 应用深色', autoDark.dark === true, JSON.stringify(autoDark));
|
||||
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
// 真实用户改系统设置时不会刷新页面 —— 这里也必须**不刷新**地跟随
|
||||
await page.waitForTimeout(600);
|
||||
const autoLight = await page.evaluate(() => ({
|
||||
dark: document.documentElement.classList.contains('dark'),
|
||||
colorScheme: document.documentElement.style.colorScheme
|
||||
}));
|
||||
check('系统切浅色 → 应用实时跟随(无需刷新)', autoLight.dark === false, JSON.stringify(autoLight));
|
||||
await page.screenshot({ path: `${OUT}/04-auto-light.png` });
|
||||
|
||||
// ── 7. 显式选择时不得被系统覆盖 ──
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'light'));
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const pinned = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
check('显式选浅色时不被系统深色覆盖', pinned === false);
|
||||
|
||||
console.log('\n── 现代化打磨 ──');
|
||||
const polish = await page.evaluate(() => {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const btn = document.querySelector('button');
|
||||
return {
|
||||
focusRuleExists: true,
|
||||
radiusCard: cs.getPropertyValue('--radius-card').trim(),
|
||||
radiusControl: cs.getPropertyValue('--radius-control').trim(),
|
||||
buttonRadius: btn ? getComputedStyle(btn).borderRadius : '',
|
||||
reducedMotionRule: [...document.styleSheets].some(s => {
|
||||
try {
|
||||
return [...s.cssRules].some(r => r.conditionText?.includes('prefers-reduced-motion'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
check('圆角令牌已定义', !!polish.radiusCard && !!polish.radiusControl, JSON.stringify(polish));
|
||||
check('按钮圆角变大(0.5rem 系而非 0.25rem)', /(8|10|12|16)px/.test(polish.buttonRadius), polish.buttonRadius);
|
||||
check('减弱动效媒体查询已生效', polish.reducedMotionRule);
|
||||
|
||||
if (issues.length) {
|
||||
console.log('\n页面错误:');
|
||||
for (const i of issues.slice(0, 8)) console.log(' ' + i);
|
||||
}
|
||||
check('无页面级错误', issues.length === 0, issues.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await page.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n外观:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
console.log(`截图:${OUT}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
169
client/electron/test/stores/background.test.ts
Normal file
169
client/electron/test/stores/background.test.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_BACKGROUND,
|
||||
MAX_DATA_URL_BYTES,
|
||||
STORAGE_KEY,
|
||||
applyBackground,
|
||||
clampBlur,
|
||||
clampDim,
|
||||
normalizeBackground,
|
||||
useBackgroundStore
|
||||
} from '../../src/stores/backgroundStore';
|
||||
|
||||
/**
|
||||
* 背景 store 的行为锁。
|
||||
*
|
||||
* 这里测的是**纯逻辑与 DOM 写入**(不需要真浏览器):
|
||||
* - 脏数据不能让界面白屏(localStorage 可能被手改、也可能来自旧版本)
|
||||
* - 数值必须被夹在合法区间(滑杆之外的写入路径只有代码,但状态可能来自磁盘)
|
||||
* - CSS 变量的写入/清理必须成对 —— 残留的 `--bg-image` 会让「关掉背景」
|
||||
* 之后仍然显示上一张图
|
||||
*/
|
||||
|
||||
const presetClasses = () =>
|
||||
Array.from(document.documentElement.classList).filter(c => c.startsWith('bg-preset-'));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-bg');
|
||||
document.documentElement.removeAttribute('style');
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
describe('数值夹取', () => {
|
||||
it('压暗与模糊都被夹在声明区间内', () => {
|
||||
expect(clampDim(-10)).toBe(0);
|
||||
expect(clampDim(999)).toBe(80);
|
||||
expect(clampDim(30)).toBe(30);
|
||||
expect(clampBlur(-1)).toBe(0);
|
||||
expect(clampBlur(100)).toBe(24);
|
||||
expect(clampBlur(10)).toBe(10);
|
||||
});
|
||||
|
||||
it('非数值退回默认值而不是 NaN', () => {
|
||||
// NaN 会写成 `--bg-dim: NaN%`,整条声明失效 —— 表现为滑杆无效但不报错
|
||||
expect(clampDim(Number.NaN)).toBe(DEFAULT_BACKGROUND.dim);
|
||||
expect(clampBlur(Number.NaN)).toBe(DEFAULT_BACKGROUND.blur);
|
||||
});
|
||||
});
|
||||
|
||||
describe('脏数据归一化', () => {
|
||||
it('非对象输入退回默认', () => {
|
||||
for (const bad of [null, undefined, 42, 'x', []]) {
|
||||
const n = normalizeBackground(bad);
|
||||
expect(n.kind).toBe('none');
|
||||
expect(n.presetId).toBe(DEFAULT_BACKGROUND.presetId);
|
||||
}
|
||||
});
|
||||
|
||||
it('未知预设 id 退回默认预设', () => {
|
||||
expect(normalizeBackground({ presetId: 'no-such-preset' }).presetId).toBe(
|
||||
DEFAULT_BACKGROUND.presetId
|
||||
);
|
||||
});
|
||||
|
||||
it('kind=image 但没有可用图片时退回 none', () => {
|
||||
// 图片可能被浏览器清理或写坏。留一个空壳 image 状态会让界面显示「已选图片」
|
||||
// 却什么都没有 —— 不如退回 none,至少状态是诚实的
|
||||
expect(normalizeBackground({ kind: 'image', imageDataUrl: '' }).kind).toBe('none');
|
||||
expect(normalizeBackground({ kind: 'image', imageDataUrl: 'https://x/y.png' }).kind).toBe('none');
|
||||
});
|
||||
|
||||
it('合法 data URL 被保留', () => {
|
||||
const url = 'data:image/jpeg;base64,AAAA';
|
||||
const n = normalizeBackground({ kind: 'image', imageDataUrl: url });
|
||||
expect(n.kind).toBe('image');
|
||||
expect(n.imageDataUrl).toBe(url);
|
||||
});
|
||||
|
||||
it('越界数值被夹回区间', () => {
|
||||
const n = normalizeBackground({ dim: 500, blur: -5 });
|
||||
expect(n.dim).toBe(80);
|
||||
expect(n.blur).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('写 DOM', () => {
|
||||
it('预设背景写入 data-bg、预设类与两个数值变量', () => {
|
||||
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'dusk', dim: 40, blur: 12 });
|
||||
const root = document.documentElement;
|
||||
expect(root.dataset.bg).toBe('on');
|
||||
expect(presetClasses()).toEqual(['bg-preset-dusk']);
|
||||
expect(root.style.getPropertyValue('--bg-dim')).toBe('40%');
|
||||
expect(root.style.getPropertyValue('--bg-blur')).toBe('12px');
|
||||
// 预设的渐变由 class 提供,不得写 --bg-image(否则会盖住 class 的定义)
|
||||
expect(root.style.getPropertyValue('--bg-image')).toBe('');
|
||||
});
|
||||
|
||||
it('切换预设时清掉上一个预设类', () => {
|
||||
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'aurora' });
|
||||
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'mint' });
|
||||
expect(presetClasses()).toEqual(['bg-preset-mint']);
|
||||
});
|
||||
|
||||
it('图片背景写入 --bg-image 并清掉预设类', () => {
|
||||
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'aurora' });
|
||||
applyBackground({
|
||||
...DEFAULT_BACKGROUND,
|
||||
kind: 'image',
|
||||
imageDataUrl: 'data:image/png;base64,BBBB'
|
||||
});
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--bg-image')).toContain('data:image/png;base64,BBBB');
|
||||
expect(presetClasses()).toEqual([]);
|
||||
});
|
||||
|
||||
it('关闭背景时清掉图片与预设,且 data-bg 变 off', () => {
|
||||
// 这一条守的是「关掉背景后仍显示上一张图」:只把 data-bg 设成 off 而不清
|
||||
// --bg-image,切换回来的瞬间会闪出旧图
|
||||
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'image', imageDataUrl: 'data:image/png;base64,CCCC' });
|
||||
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'none' });
|
||||
const root = document.documentElement;
|
||||
expect(root.dataset.bg).toBe('off');
|
||||
expect(root.style.getPropertyValue('--bg-image')).toBe('');
|
||||
expect(presetClasses()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store 动作', () => {
|
||||
it('setPreset 同时把 kind 切到 preset 并落盘', () => {
|
||||
useBackgroundStore.getState().setPreset('sand');
|
||||
const s = useBackgroundStore.getState();
|
||||
expect(s.kind).toBe('preset');
|
||||
expect(s.presetId).toBe('sand');
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).presetId).toBe('sand');
|
||||
});
|
||||
|
||||
it('setImage 记录图片并落盘', () => {
|
||||
useBackgroundStore.getState().setImage('data:image/jpeg;base64,DDDD');
|
||||
expect(useBackgroundStore.getState().kind).toBe('image');
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).imageDataUrl).toContain('DDDD');
|
||||
});
|
||||
|
||||
it('reset 回到默认并落盘', () => {
|
||||
useBackgroundStore.getState().setPreset('ink');
|
||||
useBackgroundStore.getState().reset();
|
||||
const s = useBackgroundStore.getState();
|
||||
expect(s.kind).toBe('none');
|
||||
expect(s.presetId).toBe(DEFAULT_BACKGROUND.presetId);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).kind).toBe('none');
|
||||
});
|
||||
|
||||
it('localStorage 抛异常时不打断操作', () => {
|
||||
// 隐私模式 / 配额满:背景是装饰,绝不能让存储失败冒泡成一次崩溃
|
||||
const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
expect(() => useBackgroundStore.getState().setDim(55)).not.toThrow();
|
||||
expect(useBackgroundStore.getState().dim).toBe(55);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('上限常量', () => {
|
||||
it('图片上限留足 localStorage 余量', () => {
|
||||
// 典型 localStorage 配额 5MB;超过一半就很容易与其它键一起写爆
|
||||
expect(MAX_DATA_URL_BYTES).toBeGreaterThan(100_000);
|
||||
expect(MAX_DATA_URL_BYTES).toBeLessThan(5_000_000 / 2);
|
||||
});
|
||||
});
|
||||
@ -30,6 +30,20 @@ const css = read('../src/index.css');
|
||||
const cfg = read('../tailwind.config.js');
|
||||
const html = read('../index.html');
|
||||
|
||||
/**
|
||||
* 剥掉注释后的 CSS。
|
||||
*
|
||||
* 下面几处按 `indexOf(':root')` / `indexOf('.dark')` 切颜色块,而**注释里出现
|
||||
* 选择器字面量会把块提前截断**:在 :root 的注释里写一句「深色主题里换值」
|
||||
* (原文用了带点的选择器写法)就足以让浅色块在 color-scheme 之前被切开,
|
||||
* 于是第 8 条真假失败。更危险的是反向情形:块被截短后变量集合变小,
|
||||
* 「覆盖齐全」这类断言可能**真空通过**。
|
||||
*
|
||||
* 所以所有块切分都基于剥注释后的文本。字符串字面量里的 /* 在本文件里
|
||||
* 不存在,直接用非贪婪匹配去掉块注释即可。
|
||||
*/
|
||||
const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// 1) tailwind 必须走 class 策略。
|
||||
// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
|
||||
check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
|
||||
@ -54,8 +68,11 @@ check(
|
||||
|
||||
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
|
||||
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
|
||||
const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const darkBlock = css.slice(css.indexOf('.dark {'));
|
||||
const lightBlock = cssNoComments.slice(
|
||||
cssNoComments.indexOf(':root'),
|
||||
cssNoComments.indexOf('.dark')
|
||||
);
|
||||
const darkBlock = cssNoComments.slice(cssNoComments.indexOf('.dark {'));
|
||||
const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const missing = [...lightVars].filter(v => !darkVars.has(v));
|
||||
@ -302,8 +319,16 @@ check(
|
||||
!/--s-[a-z]+-\d+:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 23) 白字落在实心按钮底上必须达到 4.5:1(两种模式同一组值,只需算一次)。
|
||||
const solidBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
// 23) 白字落在实心按钮底上必须达到 4.5:1。
|
||||
//
|
||||
// **两种模式都要算**。之前只拿浅色的 on-accent(纯白 255)去算,而深色用的是
|
||||
// 「近白」(244 246 250,为了不刺眼)—— 同一个实心底,白字换暗一点点就从
|
||||
// 4.83 掉到 4.46,路过了 AA 线。盲区在真实渲染里才被量出来(导航未读徒标
|
||||
// 「12」)。
|
||||
const solidBlock = cssNoComments.slice(
|
||||
cssNoComments.indexOf(':root'),
|
||||
cssNoComments.indexOf('.dark')
|
||||
);
|
||||
const sRgb = name => {
|
||||
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
@ -314,18 +339,23 @@ const solidPairs = [
|
||||
['red-600'], ['red-700'],
|
||||
['green-700'], ['orange-700']
|
||||
];
|
||||
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
||||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
||||
const onAccentValues = [
|
||||
['浅色', rgbOf(lightBlock, 'on-accent')],
|
||||
['深色', rgbOf(darkBlock, 'on-accent')]
|
||||
];
|
||||
const weakButtons = [];
|
||||
for (const [name] of solidPairs) {
|
||||
const bg = sRgb(name);
|
||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||
// 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。
|
||||
const r = contrast(onAccentLight, bg);
|
||||
if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`);
|
||||
for (const [mode, onAccent] of onAccentValues) {
|
||||
if (!onAccent) { weakButtons.push(`${mode}:on-accent 缺失`); continue; }
|
||||
for (const [name] of solidPairs) {
|
||||
const bg = sRgb(name);
|
||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||
// 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。
|
||||
const r = contrast(onAccent, bg);
|
||||
if (r < 4.5) weakButtons.push(`${mode}/${name}:${r.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'白字在实心按钮底上达到 4.5:1',
|
||||
'白字在实心按钮底上达到 4.5:1(两种模式)',
|
||||
weakButtons.length === 0,
|
||||
weakButtons.join(' ')
|
||||
);
|
||||
|
||||
@ -7,9 +7,11 @@ import react from '@vitejs/plugin-react';
|
||||
* 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在
|
||||
* 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。
|
||||
*
|
||||
* 只跑 test/components/ 下的用例。test/ 顶层那几个(markdown-xss、
|
||||
* narrow-layout)是 node:test / 手写断言脚本,由 `npm test` 直接用 node 跑 ——
|
||||
* 它们不需要 DOM,套一层 vitest 只是变慢。
|
||||
* 只跑 test/components/ 与 test/stores/ 下的用例。test/ 顶层那几个
|
||||
* (markdown-xss、narrow-layout、theme、background)是手写断言的 node 脚本,
|
||||
* 由 `npm test` 直接用 node 跑 —— 它们只读源码或纯逻辑,不需要 DOM 运行环境,
|
||||
* 套一层 vitest 只是变慢。存到 stores 里的状态机则需要 jsdom(要断言 DOM 写入),
|
||||
* 所以走 vitest。
|
||||
*/
|
||||
export default defineConfig({
|
||||
// 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。
|
||||
@ -18,7 +20,7 @@ export default defineConfig({
|
||||
},
|
||||
plugins: [react()],
|
||||
test: {
|
||||
include: ['test/components/**/*.test.tsx'],
|
||||
include: ['test/components/**/*.test.tsx', 'test/stores/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['test/components/setup.ts'],
|
||||
|
||||
Reference in New Issue
Block a user