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

@ -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);

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

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 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(' ')
);