Files
MailUI4Agents/client/electron/test/background.test.mjs
JianFeeeee ef68c4950c fix(webui): 统一玻璃语言 —— 导航改深色玻璃 + 内层面板自带圆角
用户:「你不觉得页面设计很割裂吗?尤其是通信页,大面积的非圆角元素。
同时还有大面积的非玻璃样式。导航栏不也应该改为玻璃样式吗,为什么还是黑色」。

## ① 导航还是黑的:因为我自己留了一条"必须不透明"的规则

`html[data-bg='on'] .bg-chrome-900 { background-color: rgb(var(--c-chrome-900));
backdrop-filter: none }` —— 这是**更早一轮**用户的要求(当时面板太透,壁纸从导航里
透出来显得脏)。现在整套语言统一到玻璃之后,导航继续做一块实心黑就成了全页唯一的
例外,也就是"割裂"的来源。

改成**深色玻璃**(α=0.72 + blur(18px) + saturate)——用深色而不是白色玻璃:
白字压在深色上才有对比度。没开壁纸时用 0.82(后面是页面底色而不是照片,太透显脏)。

## ② 通信页"大面积非圆角":是我修滚动时带出来的

圆角原本只加在外壳上,靠 `overflow: hidden` 裁掉内层的直角 —— 但那个 overflow
会杀掉滚动(上一封),必须去掉。**于是内层白底面板的直角就从圆角外壳里戳了出来。**
现在把圆角直接给内层面板(`.comm-pane > *`),两边对齐,且不依赖裁剪 ⇒ 与滚动不冲突。
MailView 的两条工具条也不再自带宽底(只留上边框,底色继承面板),否则同样会戳角。

## 判据

`background.test.mjs` 里两条**旧判据**(断言"导航必须不透明、不参与模糊")按现行契约
重写为"深色玻璃(α∈[0.6,0.9])+ 参与模糊",并新增"内层面板自带圆角"。
要求反转后旧判据必须一起改 —— 留着只会让下次改动"要么违规、要么把缺陷写回去"。

**实测**(真浏览器,1280×900,壁纸态):导航 α=0.72 / blur(18px) / radius 14px;
内层 MailList 面板 radius **14px**(不再是直角)。背景套件 31 条全绿。
2026-09-14 11:17:12 +08:00

264 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.

/**
* 自定义背景的结构性检查。
*
* 与 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(' '));
// 16) ★ 玻璃不得层层相乘2026-09-13 用户报:"壁纸底上叠了太多不透明层")。
//
// 判据用**算式**而不是感觉:每层都吃同一个 a两层就是 1-(1-a)²。
// 缺陷时 a=0.82 ⇒ 两层 0.97、三层 0.995(壁纸在数学上被吃掉)。真实渲染实测
// (纯红壁纸读绿通道)同样印证:修复前 p95 82%,修复后 62%。
const varOf = name => {
const mm = css.match(new RegExp(`--${name}:\\s*([0-9.]+);`)); // 第一次出现 = 浅色那组
return mm ? Number(mm[1]) : NaN;
};
const glass = varOf('bg-glass');
const inner = varOf('bg-glass-inner');
const composite = (base, nested, layers) => {
let opaque = base;
for (let i = 1; i < layers; i++) opaque = opaque + nested * (1 - opaque);
return opaque;
};
const ctrl = Number((css.match(/--bg-glass-control:\s*([\d.]+)/) || [])[1]);
const stacked = composite(glass, inner, 2);
check('玻璃有"嵌套层"变量', Number.isFinite(glass) && Number.isFinite(inner), `glass=${glass} inner=${inner}`);
check('有"嵌套面板不再各叠一次"的规则', /\.bg-white \.bg-white \{/.test(css));
/*
* ★ 这三条 2026-09-14 重写(原先断言的是"越透越好",方向是错的)。
*
* 我第一版按"内容更通透"把正文面压到 0.45,用户当场指出:「你把大量需要打底的
* 场景(弹窗正文等)改为了透明。真正该透明的地方(空白区域)加了很浓的模糊」。
* 于是判据改成**两类面分开**
* - 承载文字的面必须够实0.80.95),否则字压在壁纸上读不动;
* - 空白/页面底:透明且不模糊(另有用例)。
* 旧的"≤0.70 / ≤0.85"留着只会把我再拽回那个错误方向,所以连同理由一起改掉。
*/
check('正文面够实0.800.95,读得清优先)', glass >= 0.8 && glass <= 0.95, `实际 ${glass}`);
check('嵌套面比外层透、但仍打底0.70–外层)', inner >= 0.7 && inner <= glass, `glass=${glass} inner=${inner}`);
check('两层嵌套后仍接近实心≥0.9', composite(glass, inner, 2) >= 0.9, `实际 ${stacked.toFixed(3)}`);
check(
'控件档最透,且比嵌套面更透(复选框/地址建议)',
ctrl <= 0.6 && ctrl < inner,
`control=${ctrl} inner=${inner}`
);
check('判据自检:两档一样透是分不出层次的(必须算得出 ≥0.9', composite(0.82, 0.82, 2) >= 0.9);
// 17) ★ 浅色表面类的接管清单必须跟着源码走。
//
// 缺陷的另一半:当时只接管了 white/gray-50/slate-100而源码里在用的
// gray-100(24 处)、blue-50(17)、red-50(13)… 全是实心的,正好把壁纸盖住。
const gen = read('../src/background-takeover.generated.css');
const used = new Set();
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (/\.(tsx?|jsx?)$/.test(e.name)) {
const src = readFileSync(full, 'utf8');
for (const mm of src.matchAll(/bg-[a-z]+-\d{2,3}/g)) {
if (/^bg-[a-z]+-(50|100|200)$/.test(mm[0])) used.add(mm[0]);
}
}
}
};
walk(join(here, '../src'));
const PAGE_BASE = new Set(['bg-gray-50', 'bg-slate-100']); // 页面底必须保持全透明
const missing = [...used].filter(c => !PAGE_BASE.has(c) && !gen.includes(`.${c} {`));
check('源码里用到的浅色表面类全被接管', missing.length === 0, `漏了:${missing.join(', ')}`);
check('页面底没有被写进半透明清单', [...PAGE_BASE].every(b => !gen.includes(`.${b} {`)));
check('清单不是空跑(至少扫到 10 个类)', used.size >= 10, `实际 ${used.size}`);
// 18) ★ 导航栏必须**完全不透明**、内容面板必须更通透、整体圆角玻璃化。
//
// 用户 2026-09-14 原话:「导航栏应当完全不透明……没有正文的位置过于不通透,
// 同时导航栏应当现代化一下,整个界面应当圆角化玻璃化」。
// 注意这是**两个方向**的要求:框架要实、内容要透 —— 写成一条规则就会互相打架。
/*
* ★ 导航栏:从"不透明"改成"深色玻璃"2026-09-14 用户:「导航栏不也应该改为玻璃样式吗,
* 为什么还是黑色」)。
*
* 这两条判据原本断言的是**相反**的东西(不透明 + 不模糊),是更早一轮的要求
* (当时面板太透、壁纸从导航透出来显得脏)。要求反转后旧判据必须一起改 ——
* 留着它只会让下一次改动"要么违规、要么把缺陷写回去"。
*
* 现在的契约:**深色玻璃**(白字要对比度,所以不用白色玻璃),
* α 落在 [0.6, 0.9]:太透读不清,太实就退回那块黑 slab。
*/
const navAlpha = Number(
(css.match(/html\[data-bg='on'\] \.bg-chrome-900,[\s\S]{0,200}?rgb\(var\(--c-chrome-900\) \/ ([\d.]+)\)/) || [])[1]
);
const navGlass = /html\[data-bg='on'\] \.bg-chrome-900,[\s\S]{0,240}backdrop-filter: blur\(/.test(css);
check('导航栏是深色玻璃(α 在 0.60.9,读得清又不挡壁纸)',
Number.isFinite(navAlpha) && navAlpha >= 0.6 && navAlpha <= 0.9, `α=${navAlpha}`);
check('导航栏参与模糊(玻璃的必要条件)', navGlass);
check('内层面板自带圆角(不靠裁剪,否则与滚动冲突)',
/\.comm-pane > \*:not\(\[data-testid='comm-tabs'\]\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
/*
* ★ 浮动面板几何必须**无条件**生效2026-09-14 用户:「通信页面大面积缺失圆角与
* 玻璃效果,所有有内容与无内容区域都是硬截断」)。
*
* 根因:这些规则原先全写在 `html[data-bg='on']` 里 ⇒ 没开壁纸的账号看到硬边面板。
* 所以判据不能再带 data-bg 前缀 —— 带前缀等于把缺陷写进判据。
*/
check('外壳留缝与圆角是无条件的(不只壁纸模式)',
/(?<!data-bg='on'\] )\.app-shell \{[\s\S]{0,80}padding: var\(--pane-gap\)/.test(css) &&
/(?<!data-bg='on'\] )\.app-shell > \* \{[\s\S]{0,160}border-radius: var\(--radius-card\)/.test(css));
check('窄屏内容面板也浮起来(圆角)',
/\.narrow-shell > \*:not\(\.narrow-nav\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
check('窄屏底部导航是悬浮玻璃(留缝 + 模糊)',
/\.narrow-nav \{[\s\S]{0,220}backdrop-filter: blur\(/.test(css) &&
/\.narrow-nav \{[\s\S]{0,120}margin: 0 10px/.test(css));
console.log(`\n背景:${pass} 通过${fail ? `${fail} 失败` : ''}`);
process.exit(fail ? 1 : 0);