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