Files
MailUI4Agents/client/electron/test/manual/background-verify.mjs
JianFeeeee 84c1d749cd 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,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
2026-09-12 08:02:30 +08:00

302 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 自定义背景 + 外观现代化的手工验收。
*
* 需要共享 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);