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:
301
client/electron/test/manual/background-verify.mjs
Normal file
301
client/electron/test/manual/background-verify.mjs
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 自定义背景 + 外观现代化的手工验收。
|
||||
*
|
||||
* 需要共享 Chromium(CDP 9222)与一个活着的 Gateway。与 theme.test.mjs /
|
||||
* background.test.mjs 的分工:那两个守「源码写成了什么形态」,这里量的是
|
||||
* **真实渲染结果** —— 背景层有没有真的露出来、玻璃化有没有生效、
|
||||
* 正文在背景之上还能不能读。这些只有真实渲染能回答。
|
||||
*
|
||||
* 用法:
|
||||
* AGENTMAIL_DIST=$PWD/client/electron/dist \
|
||||
* AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* ADMIN_USER=gui-lab ADMIN_PW=... \
|
||||
* node test/manual/background-verify.mjs
|
||||
*
|
||||
* 设 AGENTMAIL_DIST 时,页面壳与静态资源从本地 dist 注入,API/SSE 仍走真实
|
||||
* Gateway —— 因此可以在不部署、不重启的前提下验收本次构建。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
|
||||
const OUT = process.env.SHOT_DIR || '/tmp/appearance-shots';
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** WCAG 相对亮度与对比度。 */
|
||||
const srgb = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const luminance = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = luminance(a);
|
||||
const l2 = luminance(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const parseRgb = s => {
|
||||
const m = String(s).match(/(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
|
||||
const root = () => process.env.SHOT_DIR;
|
||||
void root;
|
||||
|
||||
await mkdir(OUT, { recursive: true });
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
/** 切到某个主题 + 某个背景,并等一帧让过渡稳定。 */
|
||||
async function configure(theme, kind, presetId) {
|
||||
await page.evaluate(
|
||||
([t, k, p]) => {
|
||||
localStorage.setItem('agentmail.theme', t);
|
||||
localStorage.setItem(
|
||||
'agentmail.background',
|
||||
JSON.stringify({
|
||||
kind: k,
|
||||
presetId: p,
|
||||
imageDataUrl: '',
|
||||
dim: 24,
|
||||
blur: 8
|
||||
})
|
||||
);
|
||||
},
|
||||
[theme, kind, presetId]
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
// reload 后要重新登录态?不需要:cookie 仍在。等应用壳渲染出来。
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
|
||||
try {
|
||||
// 确认已登录(helper 内部在需要时会填表;这里兜底判断一次)
|
||||
const loggedIn = await page.evaluate(() => !!document.querySelector('nav, aside, main'));
|
||||
if (!loggedIn) {
|
||||
console.log(' 提示:未进入主界面,可能登录失败');
|
||||
}
|
||||
|
||||
// ── 1. 关闭背景时不应有任何背景层可见 ──
|
||||
console.log('\n── 背景:无 ──');
|
||||
await configure('light', 'none', 'aurora');
|
||||
const noneState = await page.evaluate(() => ({
|
||||
dataBg: document.documentElement.dataset.bg,
|
||||
opacity: getComputedStyle(document.querySelector('.app-backdrop')).opacity,
|
||||
bodyBg: getComputedStyle(document.body).backgroundColor
|
||||
}));
|
||||
check('data-bg 为 off', noneState.dataBg === 'off');
|
||||
check('背景层透明(opacity 0)', Number(noneState.opacity) === 0, noneState.opacity);
|
||||
check('页面底仍是不透明的灰底', parseRgb(noneState.bodyBg) !== null && !/transparent/.test(noneState.bodyBg), noneState.bodyBg);
|
||||
await page.screenshot({ path: `${OUT}/01-none-light.png` });
|
||||
|
||||
// ── 2. 预设背景在浅色下真的露出来 ──
|
||||
console.log('\n── 背景:预设(极光) 浅色 ──');
|
||||
await configure('light', 'preset', 'aurora');
|
||||
const lightPreset = await page.evaluate(() => ({
|
||||
dataBg: document.documentElement.dataset.bg,
|
||||
opacity: getComputedStyle(document.querySelector('.app-backdrop')).opacity,
|
||||
bodyBg: getComputedStyle(document.body).backgroundColor,
|
||||
image: getComputedStyle(document.querySelector('.app-backdrop')).backgroundImage,
|
||||
preset: document.documentElement.className.includes('bg-preset-aurora'),
|
||||
dim: getComputedStyle(document.documentElement).getPropertyValue('--bg-dim').trim()
|
||||
}));
|
||||
check('data-bg 为 on', lightPreset.dataBg === 'on');
|
||||
check('背景层可见', Number(lightPreset.opacity) === 1, lightPreset.opacity);
|
||||
check('预设类已挂到 html', lightPreset.preset);
|
||||
check('渐变已解析成真实背景图', /gradient/.test(lightPreset.image), lightPreset.image.slice(0, 60));
|
||||
check('页面底已让出(透明)', /transparent|rgba\(0, 0, 0, 0\)/.test(lightPreset.bodyBg), lightPreset.bodyBg);
|
||||
check('压暗变量已写入', lightPreset.dim === '24%', lightPreset.dim);
|
||||
await page.screenshot({ path: `${OUT}/02-preset-aurora-light.png` });
|
||||
|
||||
// ── 3. 玻璃化:面板必须半透明且有背景模糊 ──
|
||||
//
|
||||
// 选择器必须排除**完全透明**的元素:背景开启后大量 .bg-gray-50 会变成
|
||||
// rgba(0, 0, 0, 0),它们是「让出背景」的空白容器,不是玻璃面板。
|
||||
// 上一版取「第一个 alpha<1 的元素」正好命中它们,得出「玻璃化没生效」的
|
||||
// 错误结论(实测面板其实是 rgba(255,255,255,0.82) + blur(16px))。
|
||||
const glass = await page.evaluate(() => {
|
||||
const alphaOf = s => {
|
||||
const parts = String(s).split(',');
|
||||
return parts.length > 3 ? parseFloat(parts[3]) : 1;
|
||||
};
|
||||
const candidates = [...document.querySelectorAll('div,aside,nav')].filter(el => {
|
||||
const a = alphaOf(getComputedStyle(el).backgroundColor);
|
||||
return a > 0.05 && a < 1; // 排除全透明容器
|
||||
});
|
||||
if (!candidates.length) return { found: false };
|
||||
// 取面积最大的那块:它才是真正承载内容的玻璃面板
|
||||
const el = candidates.sort((a, b) => {
|
||||
const ra = a.getBoundingClientRect();
|
||||
const rb = b.getBoundingClientRect();
|
||||
return rb.width * rb.height - ra.width * ra.height;
|
||||
})[0];
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
found: true,
|
||||
bg: cs.backgroundColor,
|
||||
filter: cs.backdropFilter || cs.webkitBackdropFilter,
|
||||
cls: el.className.toString().slice(0, 80)
|
||||
};
|
||||
});
|
||||
check('存在半透明面板', glass.found, JSON.stringify(glass));
|
||||
if (glass.found) {
|
||||
// 用 parseFloat 而不是 Number:getComputedStyle 给的是 `rgba(255, 255, 255, 0.82)`,
|
||||
// 最后一段带右括号,Number(' 0.82)') 是 NaN(上一版就踩了这个)。
|
||||
const alpha = parseFloat(String(glass.bg).split(',')[3] ?? '1');
|
||||
check('面板不透明度在 (0,1) 之间', alpha > 0 && alpha < 1, String(alpha));
|
||||
check('面板启用了背景模糊', /blur/.test(glass.filter || ''), String(glass.filter));
|
||||
}
|
||||
|
||||
// ── 4. 深色 + 背景:遮罩必须跟着反转(否则照片会压不住) ──
|
||||
console.log('\n── 背景:预设(极光) 深色 ──');
|
||||
await configure('dark', 'preset', 'aurora');
|
||||
const darkState = await page.evaluate(() => {
|
||||
const backdrop = document.querySelector('.app-backdrop');
|
||||
const scrim = getComputedStyle(backdrop, '::after');
|
||||
return {
|
||||
isDark: document.documentElement.classList.contains('dark'),
|
||||
dataBg: document.documentElement.dataset.bg,
|
||||
scrim: scrim.backgroundColor,
|
||||
colorScheme: getComputedStyle(document.documentElement).colorScheme
|
||||
};
|
||||
});
|
||||
const scrimRgb = parseRgb(darkState.scrim);
|
||||
check('深色类已生效', darkState.isDark);
|
||||
check('深色下遮罩是暗色', scrimRgb !== null && luminance(scrimRgb) < 0.2, darkState.scrim);
|
||||
check('color-scheme 随主题声明', /dark/.test(darkState.colorScheme), darkState.colorScheme);
|
||||
await page.screenshot({ path: `${OUT}/03-preset-aurora-dark.png` });
|
||||
|
||||
// ── 5. 正文在背景之上仍然可读 ──
|
||||
// 逐元素算真实对比度:把最近的不透明祖先底色当作背景(与 theme-verify 同法)。
|
||||
const contrastReport = await page.evaluate(() => {
|
||||
const parse = s => {
|
||||
const m = String(s).match(/(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
const srgb = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const lum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = lum(a);
|
||||
const l2 = lum(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const effBg = el => {
|
||||
let cur = el;
|
||||
while (cur && cur !== document.documentElement) {
|
||||
const cs = getComputedStyle(cur);
|
||||
const bg = parse(cs.backgroundColor);
|
||||
const alpha = cs.backgroundColor.split(',').length > 3 ? parseFloat(cs.backgroundColor.split(',')[3]) : 1;
|
||||
if (bg && alpha > 0.85) return bg;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return parse(getComputedStyle(document.body).backgroundColor) || [255, 255, 255];
|
||||
};
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll('p, span, h1, h2, h3, a, button, li, label')) {
|
||||
const text = (el.textContent || '').trim();
|
||||
if (!text || text.length < 2) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 6) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.5) continue;
|
||||
const fg = parse(cs.color);
|
||||
if (!fg) continue;
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const bold = Number(cs.fontWeight) >= 700;
|
||||
const need = size >= 24 || (size >= 18.66 && bold) ? 3 : 4.5;
|
||||
const ratio = contrast(fg, effBg(el));
|
||||
if (ratio < need) {
|
||||
out.push({ text: text.slice(0, 24), ratio: Number(ratio.toFixed(2)), need, size });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
check(
|
||||
'深色 + 背景:无低对比正文(WCAG AA)',
|
||||
contrastReport.length === 0,
|
||||
contrastReport.slice(0, 4).map(x => `${x.text}:${x.ratio}<${x.need}`).join(' | ')
|
||||
);
|
||||
for (const miss of contrastReport.slice(0, 6)) {
|
||||
console.log(` · "${miss.text}" ${miss.ratio} < ${miss.need}(${miss.size}px)`);
|
||||
}
|
||||
|
||||
// ── 6. 自动模式:跟随系统深浅色 ──
|
||||
console.log('\n── 主题:跟随系统 ──');
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'system'));
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const autoDark = await page.evaluate(() => ({
|
||||
dark: document.documentElement.classList.contains('dark'),
|
||||
colorScheme: document.documentElement.style.colorScheme,
|
||||
pref: localStorage.getItem('agentmail.theme')
|
||||
}));
|
||||
check('系统深色 → 应用深色', autoDark.dark === true, JSON.stringify(autoDark));
|
||||
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
// 真实用户改系统设置时不会刷新页面 —— 这里也必须**不刷新**地跟随
|
||||
await page.waitForTimeout(600);
|
||||
const autoLight = await page.evaluate(() => ({
|
||||
dark: document.documentElement.classList.contains('dark'),
|
||||
colorScheme: document.documentElement.style.colorScheme
|
||||
}));
|
||||
check('系统切浅色 → 应用实时跟随(无需刷新)', autoLight.dark === false, JSON.stringify(autoLight));
|
||||
await page.screenshot({ path: `${OUT}/04-auto-light.png` });
|
||||
|
||||
// ── 7. 显式选择时不得被系统覆盖 ──
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'light'));
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const pinned = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
check('显式选浅色时不被系统深色覆盖', pinned === false);
|
||||
|
||||
console.log('\n── 现代化打磨 ──');
|
||||
const polish = await page.evaluate(() => {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const btn = document.querySelector('button');
|
||||
return {
|
||||
focusRuleExists: true,
|
||||
radiusCard: cs.getPropertyValue('--radius-card').trim(),
|
||||
radiusControl: cs.getPropertyValue('--radius-control').trim(),
|
||||
buttonRadius: btn ? getComputedStyle(btn).borderRadius : '',
|
||||
reducedMotionRule: [...document.styleSheets].some(s => {
|
||||
try {
|
||||
return [...s.cssRules].some(r => r.conditionText?.includes('prefers-reduced-motion'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
check('圆角令牌已定义', !!polish.radiusCard && !!polish.radiusControl, JSON.stringify(polish));
|
||||
check('按钮圆角变大(0.5rem 系而非 0.25rem)', /(8|10|12|16)px/.test(polish.buttonRadius), polish.buttonRadius);
|
||||
check('减弱动效媒体查询已生效', polish.reducedMotionRule);
|
||||
|
||||
if (issues.length) {
|
||||
console.log('\n页面错误:');
|
||||
for (const i of issues.slice(0, 8)) console.log(' ' + i);
|
||||
}
|
||||
check('无页面级错误', issues.length === 0, issues.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await page.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n外观:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
console.log(`截图:${OUT}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user