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

@ -19,7 +19,7 @@
"build:linux": "vite build && electron-builder --linux", "build:linux": "vite build && electron-builder --linux",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "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:narrow": "node test/manual/narrow-verify.mjs",
"test:wide": "node test/manual/wide-regression.mjs", "test:wide": "node test/manual/wide-regression.mjs",
"test:components": "vitest run", "test:components": "vitest run",

View File

@ -4,6 +4,7 @@ import * as api from '../api/client';
import { LockIcon, LogoutIcon } from './icons'; import { LockIcon, LogoutIcon } from './icons';
import KeyPanel from './KeyPanel'; import KeyPanel from './KeyPanel';
import ThemePicker from './ThemePicker'; import ThemePicker from './ThemePicker';
import BackgroundPicker from './BackgroundPicker';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */ /** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
export default function AccountPage() { 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 /> <ThemePicker />
<BackgroundPicker />
</section> </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 ( return (
<div> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-gray-900"></h3> <h3 className="text-sm font-medium text-gray-900"></h3>
{pref === 'system' && ( {pref === 'system' && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{resolved === 'dark' ? '深色' : '浅色'} {resolved === 'dark' ? '深色' : '浅色'}
</span> </span>
)} )}
</div> </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 => { {OPTIONS.map(o => {
const active = pref === o.value; const active = pref === o.value;
return ( return (
<button <button
key={o.value} key={o.value}
role="radio"
aria-checked={active}
onClick={() => setPref(o.value)} onClick={() => setPref(o.value)}
title={o.hint} 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 active
? 'border-blue-500 bg-blue-50 text-blue-700' ? 'bg-white text-gray-900 shadow-sm border border-gray-200'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50' : '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} {o.label}
</button> </button>
); );

View File

@ -197,6 +197,51 @@
--s-yellow-700: 161 98 7; --s-yellow-700: 161 98 7;
--s-yellow-800: 133 77 14; --s-yellow-800: 133 77 14;
--s-yellow-900: 113 63 18; --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; color-scheme: light;
} }
@ -226,8 +271,15 @@
.dark { .dark {
--c-white: 24 27 33; --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-50: 17 19 24;
--c-gray-100: 32 36 44; --c-gray-100: 32 36 44;
@ -254,6 +306,23 @@
--c-chrome-800: 30 35 44; --c-chrome-800: 30 35 44;
--c-chrome-900: 12 14 18; --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));
}
/*
* ─── 预设渐变 ───
*
* 全部用现有调色板的**表面段**50300拼出来因此
* - 无需新增任何写死的十六进制值
* - 自动随主题变化 —— 那一段在深色下本来就是暗的(见 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;
}
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { initTheme } from './stores/themeStore'; import { initTheme } from './stores/themeStore';
import { initBackground } from './stores/backgroundStore';
import './index.css'; import './index.css';
// 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。 // 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。
@ -9,6 +10,11 @@ import './index.css';
// 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。 // 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。
initTheme(); initTheme();
// 自定义背景同样要在 render 之前套用(读 localStorage + 写 CSS 变量)。
// 它只是装饰层,晚一帧不会像主题那样闪出刺眼白底,但提前套用能避免
// 「先看到纯色底、再变成背景」的抽动。
initBackground();
/** /**
* 用 Visual Viewport 驱动应用高度。 * 用 Visual Viewport 驱动应用高度。
* *
@ -39,6 +45,15 @@ document.addEventListener('focusout', () => requestAnimationFrame(syncEditingSta
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
{/*
背景层挂在 App **之外**。
挂在 App 里面的话,它只会存在于主界面的那几个分支上 —— 登录页、加载页、
初始化向导都各自 return 自己的外层 div。背景是全局装饰用户不该
「一进登录页背景就没了」。它 position:fixed + z-index:-1与 App 的
布局无关,放这里最稳。
*/}
<div className="app-backdrop" aria-hidden="true" />
<App /> <App />
</React.StrictMode> </React.StrictMode>
); );

View File

@ -0,0 +1,259 @@
import { create } from 'zustand';
/**
* 自定义背景。
*
* 三件事分开表达,因为它们可以组合:
* - `kind` 背景来源(不设 / 预设渐变 / 自定义图片)
* - `dim` 压暗强度 —— 背景越花,正文越需要一层遮罩才读得动
* - `blur` 模糊强度 —— 图片作背景时通常要虚化,否则细节会跟正文抢注意力
*
* # 为什么背景不放进主题 store
*
* 主题light/dark/system是**必须全局一致**的语义:同一个界面里不能一半深色
* 一半浅色。背景是**纯装饰偏好**,可以随时关掉而不影响任何功能,而且它的取值
* 空间(预设 id / 图片数据 / 两个数值)与主题毫无关系。塞在一起会让主题 store
* 承担两种生命周期的状态,也会让「跟随系统」的实现被背景字段淹没。
*
* # 为什么图片要压缩后再存
*
* 存 localStorage。一张手机直出照片 48MB而 localStorage 配额通常只有 5MB
* 写失败会抛异常,用户看到的是「选了图片但没反应」。所以在**存入之前**先等比
* 缩到 MAX_EDGE 并转 JPEG超限则明确拒绝并告知而不是静默失败。
* (不使用 IndexedDB它的异步/事务模型会把这个纯展示功能复杂化,而压缩后
* 的尺寸已经足够小。)
*/
export type BackgroundKind = 'none' | 'preset' | 'image';
export interface BackgroundState {
kind: BackgroundKind;
/** 预设 id如 'aurora')。仅 kind === 'preset' 时有效。 */
presetId: string;
/** 压缩后的 data URL。仅 kind === 'image' 时有效。 */
imageDataUrl: string;
/** 压暗强度 080百分比。 */
dim: number;
/** 模糊强度 024px。 */
blur: number;
}
export const STORAGE_KEY = 'agentmail.background';
/** 预设清单。**渐变的实际色值定义在 index.css**,这里只有 id 与显示名。 */
export const PRESETS: { id: string; label: string }[] = [
{ id: 'aurora', label: '极光' },
{ id: 'dusk', label: '暮色' },
{ id: 'mint', label: '薄荷' },
{ id: 'sand', label: '沙丘' },
{ id: 'ink', label: '墨色' },
{ id: 'mesh', label: '网格' }
];
export const DEFAULT_BACKGROUND: BackgroundState = {
kind: 'none',
presetId: 'aurora',
imageDataUrl: '',
dim: 24,
blur: 8
};
/** 图片最长边。超过就等比缩小 —— 背景是满屏铺开的,再大也看不出来。 */
export const MAX_EDGE = 2560;
/** 压缩后 data URL 的长度上限(约 2.4MB 文本),留足 localStorage 余量。 */
export const MAX_DATA_URL_BYTES = 2_400_000;
export function clampDim(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.dim;
return Math.min(80, Math.max(0, Math.round(v)));
}
export function clampBlur(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.blur;
return Math.min(24, Math.max(0, Math.round(v)));
}
/** 归一化磁盘上可能存在的脏数据(旧版本、手改 localStorage、字段缺失。 */
export function normalizeBackground(raw: unknown): BackgroundState {
const o = (typeof raw === 'object' && raw !== null ? raw : {}) as Partial<BackgroundState>;
const kind: BackgroundKind =
o.kind === 'preset' || o.kind === 'image' || o.kind === 'none' ? o.kind : 'none';
const presetId = PRESETS.some(p => p.id === o.presetId) ? String(o.presetId) : DEFAULT_BACKGROUND.presetId;
const imageDataUrl = typeof o.imageDataUrl === 'string' && o.imageDataUrl.startsWith('data:image/')
? o.imageDataUrl
: '';
return {
// 选了 image 却没有可用图片(被清理/写坏)→ 退回不设,而不是留一个空壳状态
kind: kind === 'image' && !imageDataUrl ? 'none' : kind,
presetId,
imageDataUrl,
dim: clampDim(o.dim ?? DEFAULT_BACKGROUND.dim),
blur: clampBlur(o.blur ?? DEFAULT_BACKGROUND.blur)
};
}
export function readStored(): BackgroundState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_BACKGROUND;
return normalizeBackground(JSON.parse(raw));
} catch {
// 隐私模式或脏 JSON退回默认背景页面照常可用
return DEFAULT_BACKGROUND;
}
}
/**
* 把背景写进 DOM。
*
* 用 CSS 变量 + 一个 `data-bg` 标记,而不是给每个组件加 class
* 全站有 27 个组件,逐个改不现实,漏一处就是「一块不透明卡片浮在背景上」。
* 变量定义在 index.css玻璃化处理也集中在那里。
*/
export function applyBackground(state: BackgroundState = readStored()): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const active = state.kind !== 'none' && (state.kind === 'preset' || !!state.imageDataUrl);
root.dataset.bg = active ? 'on' : 'off';
root.style.setProperty('--bg-dim', `${clampDim(state.dim)}%`);
root.style.setProperty('--bg-blur', `${clampBlur(state.blur)}px`);
if (!active) {
root.style.removeProperty('--bg-image');
root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`));
return;
}
root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`));
if (state.kind === 'image') {
root.style.setProperty('--bg-image', `url("${state.imageDataUrl}")`);
} else {
// 预设的渐变由 class 提供,避免把色值写进 JS写死十六进制就绕过了主题变量
// 深色模式下会原样落下浅色渐变 —— 与组件里写死颜色是同一类错误)。
root.style.removeProperty('--bg-image');
root.classList.add(`bg-preset-${state.presetId}`);
}
}
interface BackgroundStore extends BackgroundState {
setKind: (kind: BackgroundKind) => void;
setPreset: (presetId: string) => void;
setImage: (dataUrl: string) => void;
setDim: (v: number) => void;
setBlur: (v: number) => void;
reset: () => void;
}
function persist(state: BackgroundState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// 配额满:本次会话仍生效,只是下次打开会退回默认值。
// 不抛给调用方 —— 背景是装饰,不该让「换背景失败」打断任何操作。
}
}
export const useBackgroundStore = create<BackgroundStore>((set, get) => {
/** 统一的提交口:先落 DOM再持久化最后更新 state。 */
const commit = (patch: Partial<BackgroundState>) => {
const next: BackgroundState = normalizeBackground({ ...get(), ...patch });
applyBackground(next);
persist(next);
set(next);
};
return {
...readStored(),
setKind: kind => commit({ kind }),
setPreset: presetId => commit({ presetId, kind: 'preset' }),
setImage: imageDataUrl => commit({ imageDataUrl, kind: 'image' }),
setDim: dim => commit({ dim: clampDim(dim) }),
setBlur: blur => commit({ blur: clampBlur(blur) }),
reset: () => commit({ ...DEFAULT_BACKGROUND })
};
});
/**
* 把 disk 上的背景在首屏套用一次。
*
* 不做「内联脚本防闪屏」:背景是装饰层,晚一帧出现只是不够顺滑,
* 不会像主题那样闪出刺眼的白底(主题已有 index.html 的内联脚本)。
*/
export function initBackground(): void {
applyBackground(readStored());
}
/**
* 读取用户选择的图片并压缩成可存储的 data URL。
*
* 失败一律返回带原因的 `{ ok: false }` 而不是抛异常 —— 调用方需要在界面上
* 说明「为什么没换成」,静默失败会让人以为按钮坏了。
*/
export async function prepareImage(
file: File
): Promise<{ ok: true; dataUrl: string } | { ok: false; reason: string }> {
if (!file.type.startsWith('image/')) {
return { ok: false, reason: '请选择图片文件' };
}
// 已压缩过的上限:单张原始文件超过 20MB 就不必读了,解码本身会卡住主线程
if (file.size > 20 * 1024 * 1024) {
return { ok: false, reason: '图片过大(超过 20MB请先裁剪' };
}
if (typeof document === 'undefined') {
return { ok: false, reason: '当前环境不支持图片处理' };
}
try {
const bitmap = await loadImage(file);
const { canvas, width, height } = drawScaled(bitmap, MAX_EDGE);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
if (dataUrl.length > MAX_DATA_URL_BYTES) {
// 缩小一档再试一次。直接拒绝会让「一张 4K 照片」这种完全正常的需求不可用。
const smaller = drawScaled(bitmap, Math.round(MAX_EDGE / 2));
const retry = smaller.canvas.toDataURL('image/jpeg', 0.78);
if (retry.length > MAX_DATA_URL_BYTES) {
return { ok: false, reason: '图片压缩后仍过大,请换一张更小的图片' };
}
return { ok: true, dataUrl: retry };
}
void width;
void height;
return { ok: true, dataUrl };
} catch {
return { ok: false, reason: '图片读取失败,请换一张试试' };
}
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('decode failed'));
};
img.src = url;
});
}
/** 等比缩放到最长边不超过 maxEdge并画出到 canvas。 */
function drawScaled(img: HTMLImageElement, maxEdge: number) {
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const scale = Math.min(1, maxEdge / Math.max(w, h || 1));
const width = Math.max(1, Math.round(w * scale));
const height = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
}
return { canvas, width, height };
}

View File

@ -94,6 +94,34 @@ export default {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { 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。 * textColor 单独覆盖 white。
* *

View 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) 图片必须压缩后再存,且有明确上限。
// 手机直出照片 48MB直接塞 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);

View File

@ -0,0 +1,301 @@
/**
* 自定义背景 + 外观现代化的手工验收。
*
* 需要共享 ChromiumCDP 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 而不是 NumbergetComputedStyle 给的是 `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);

View 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);
});
});

View File

@ -30,6 +30,20 @@ const css = read('../src/index.css');
const cfg = read('../tailwind.config.js'); const cfg = read('../tailwind.config.js');
const html = read('../index.html'); const html = read('../index.html');
/**
* 剥掉注释后的 CSS。
*
* 下面几处按 `indexOf(':root')` / `indexOf('.dark')` 切颜色块,而**注释里出现
* 选择器字面量会把块提前截断**:在 :root 的注释里写一句「深色主题里换值」
* (原文用了带点的选择器写法)就足以让浅色块在 color-scheme 之前被切开,
* 于是第 8 条真假失败。更危险的是反向情形:块被截短后变量集合变小,
* 「覆盖齐全」这类断言可能**真空通过**。
*
* 所以所有块切分都基于剥注释后的文本。字符串字面量里的 /* 在本文件里
* 不存在,直接用非贪婪匹配去掉块注释即可。
*/
const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
// 1) tailwind 必须走 class 策略。 // 1) tailwind 必须走 class 策略。
// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。 // media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg)); check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
@ -54,8 +68,11 @@ check(
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。 // 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。 // 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark')); const lightBlock = cssNoComments.slice(
const darkBlock = css.slice(css.indexOf('.dark {')); cssNoComments.indexOf(':root'),
cssNoComments.indexOf('.dark')
);
const darkBlock = cssNoComments.slice(cssNoComments.indexOf('.dark {'));
const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || [])); const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || [])); const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
const missing = [...lightVars].filter(v => !darkVars.has(v)); const missing = [...lightVars].filter(v => !darkVars.has(v));
@ -302,8 +319,16 @@ check(
!/--s-[a-z]+-\d+:/.test(darkBlock) !/--s-[a-z]+-\d+:/.test(darkBlock)
); );
// 23) 白字落在实心按钮底上必须达到 4.5:1(两种模式同一组值,只需算一次) // 23) 白字落在实心按钮底上必须达到 4.5:1。
const solidBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark')); //
// **两种模式都要算**。之前只拿浅色的 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 sRgb = name => {
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`)); 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; return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
@ -314,18 +339,23 @@ const solidPairs = [
['red-600'], ['red-700'], ['red-600'], ['red-700'],
['green-700'], ['orange-700'] ['green-700'], ['orange-700']
]; ];
const onAccentLight = rgbOf(lightBlock, 'on-accent') || const onAccentValues = [
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number); ['浅色', rgbOf(lightBlock, 'on-accent')],
['深色', rgbOf(darkBlock, 'on-accent')]
];
const weakButtons = []; const weakButtons = [];
for (const [name] of solidPairs) { for (const [mode, onAccent] of onAccentValues) {
const bg = sRgb(name); if (!onAccent) { weakButtons.push(`${mode}:on-accent 缺失`); continue; }
if (!bg) { weakButtons.push(`${name}:缺失`); continue; } for (const [name] of solidPairs) {
// 这些按钮文字只有 914px按普通文字执行 WCAG AA 4.5:1。 const bg = sRgb(name);
const r = contrast(onAccentLight, bg); if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`); // 这些按钮文字只有 914px按普通文字执行 WCAG AA 4.5:1。
const r = contrast(onAccent, bg);
if (r < 4.5) weakButtons.push(`${mode}/${name}:${r.toFixed(2)}`);
}
} }
check( check(
'白字在实心按钮底上达到 4.5:1', '白字在实心按钮底上达到 4.5:1(两种模式)',
weakButtons.length === 0, weakButtons.length === 0,
weakButtons.join(' ') weakButtons.join(' ')
); );

View File

@ -7,9 +7,11 @@ import react from '@vitejs/plugin-react';
* 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在 * 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在
* 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。 * 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。
* *
* 只跑 test/components/ 下的用例。test/ 顶层那几个markdown-xss、 * 只跑 test/components/ 与 test/stores/ 下的用例。test/ 顶层那几个
* narrow-layout是 node:test / 手写断言脚本,由 `npm test` 直接用 node 跑 —— * markdown-xss、narrow-layout、theme、background是手写断言的 node 脚本,
* 它们不需要 DOM套一层 vitest 只是变慢。 * 由 `npm test` 直接用 node 跑 —— 它们只读源码或纯逻辑,不需要 DOM 运行环境,
* 套一层 vitest 只是变慢。存到 stores 里的状态机则需要 jsdom要断言 DOM 写入),
* 所以走 vitest。
*/ */
export default defineConfig({ export default defineConfig({
// 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。 // 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。
@ -18,7 +20,7 @@ export default defineConfig({
}, },
plugins: [react()], plugins: [react()],
test: { test: {
include: ['test/components/**/*.test.tsx'], include: ['test/components/**/*.test.tsx', 'test/stores/**/*.test.ts'],
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['test/components/setup.ts'], setupFiles: ['test/components/setup.ts'],