Files
MailUI4Agents/client/electron/test/manual/wallpaper-layers-verify.mjs
JianFeeeee ababe4ae56 fix(webui): 壁纸被不透明层盖住 —— 玻璃不再层层相乘 + 浅色表面类全部接管
用户报的:「webui 目前在壁纸底上叠了太多不透明层,导致壁纸效果很差,几乎看不出来」,
追问后补充:「不只是玻璃,而是很多界面是不透明的」。两句都成立,是两个叠加的原因。

## ① 玻璃层层相乘

每层面板都吃同一个不透明度 a,两层就是 1-(1-a)²。a=0.82 时两层 0.97、三层 0.995
—— 壁纸在数学上被吃掉,而且每层各做一次 16px 模糊,图案被糊成灰块。
改法:玻璃只出现一次(外层 0.62 + 18px 模糊;嵌套层只留 0.3 色调且不再模糊;
第三层透明)。同时把遮罩 24%→12%、照片自身模糊 8→4px(那张被洗白的锅之一)。

## ② 14 个浅色表面类根本没被接管("很多界面是不透明的"就是这条)

旧规则只接管 white / gray-50 / slate-100,而源码里在用的是
gray-100(24 处)、blue-50(17)、red-50(13)、blue-100(8)、gray-200(8)、green-50(8)…
—— 全是实心的,正好盖住壁纸。

清单改为**从源码用法生成**(`scripts/gen-background-takeover.mjs` → 生成的 CSS,
构建前自动跑),因为手写清单必然烂;判据保证"源码里出现的浅色表面类必须都被接管",
并明确排除页面底(gray-50/slate-100 必须保持**全透明**,不能被改成半透明)。
按钮/徽标(*-600/700、chrome-600/700)**故意保持不透明**:小控件可读性优先
(index.css 里原有注释记着实测 4.46:1 的教训)。

## 判据

- `test/background.test.mjs` 23 条(新增 8 条):玻璃算式(两层 ≤0.85)、外层 ≤0.70、
  嵌套规则存在、**判据自检**(旧值 0.82 必须算得出 >0.95)、表面类覆盖、页面底不得进清单、
  清单非空跑。犯过一次错:第一版把判据追加在 `process.exit()` **之后** ⇒ 根本没执行,
  从"测试数没变"才发现。
- `test/manual/wallpaper-layers-verify.mjs`(真实渲染,自带无头 Chromium):
  用**纯红壁纸读绿通道**测有效不透明度(纯色下 backdrop-filter 不影响读数)。
  面板 p95:**修复前 82% → 修复后 62%**;反向对照(改回旧值)能分辨 ≥10 个百分点;
  深色照片面上面板仍是浅底;关掉壁纸的同坐标对照更实。5/5。
  过程中作废了两个指标:只看绿通道会把深色元素误判成"盖死";"面积占比"类阈值
  (Δ≥8/≥40)在 0.97 时仍会蹭过门槛,饱和而无分辨力 —— 同坐标比值才可信。

## 交付

已部署(网关内嵌 WebUI 重建):线上 CSS 已含 `--bg-glass-inner`、嵌套规则与 14 个接管类。
2026-09-14 08:09:22 +08:00

172 lines
9.7 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.

/**
* 壁纸到底有没有透出来 —— 量化判据2026-09-13 用户报的缺陷)。
*
* 用户原话「webui 目前在壁纸底上叠了太多不透明层,导致壁纸效果很差,几乎看不出来」
*
* # 怎么把它变成数
*
* 用**纯红壁纸255,0,0**,只看**绿通道**:面板是白的(绿 255、壁纸是红的绿 0
* 于是某点合成后的绿色值 G = a·G_off ⇒ **a = G(开着) / G(关着)** 就是该处的
* 有效不透明度。同一个坐标取两次(背景开/关)就是天然的反向对照。
*
* 嵌套是相乘的,所以这个数直接暴露"叠了几层"
* 一层 0.82 → 82%;两层 → 97%;三层 → 99.5%(缺陷现场就是 97% 以上)
* 用纯色壁纸是关键backdrop-filter 的模糊对纯色不起作用,读数不受 blur 干扰。
*
* # 为什么自带 Chromium
*
* 原先走共享浏览器CDP 9222+ dist 注入,实测两条路都不通:
* ① dist 注入后应用卡在 `phase='checking'`bodyText 恒为「加载中」、整页 17 个元素)
* ——量到的会是加载页而不是界面(这条已写成**前置断言**,不成立就不出结论);
* ② 共享实例标签页太多时 connectOverCDP 会挂住。
* 自带无头实例既避开这两条,也顺便做到"测自己这套 CSS、不打扰别人的标签页"。
*
* # 验的是哪份 CSS
*
* 从 `src/index.css` **原文**取 `html[data-bg='on']` 那几段注入到真实线上页面 ——
* 类结构没变(本次只改 CSS所以注入等价于发布后的效果也避免手抄走样。
*
* 用法node test/manual/wallpaper-layers-verify.mjs
*/
import { readFile, mkdir } from 'node:fs/promises';
// 走与 narrow-probe-helper 同一个入口(本机 playwright 装在 /usr/lib/node_modules
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
const { chromium } = await import(PLAYWRIGHT);
const URL_ = process.env.APP || 'https://mail.jianfgit.xyz';
const OUT = '/tmp/wallpaper-layers';
await mkdir(OUT, { recursive: true });
const RED = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR4nGP8z8Dwn4GBgYGJAQ0AAB6nAgHh0m0iAAAAAElFTkSuQmCC';
const css = await readFile(new URL('../../src/index.css', import.meta.url), 'utf8');
const struct = [...css.matchAll(/html\[data-bg='on'\][^{]*\{[^}]*\}/g)].map((m) => m[0]).join('\n');
// 变量也要注入:结构规则只是"用" var(--bg-glass),不注入它就会继续读页面自带的旧值
// ——实测漏了这一步时,"修复前/修复后"两组数字完全一样(都是页面里那个 0.82)。
const first = new Map();
for (const m of css.matchAll(/--bg-(glass-inner|glass|blur-panel|dim|blur):\s*[^;]+;/g)) {
if (!first.has(m[1])) first.set(m[1], m[0]); // 第一次出现 = 浅色那组
}
const vars = `:root {\n ${[...first.values()].join('\n ')}\n}`;
// 还要把**生成物**(浅色表面类的半透明接管清单)一起注入:那是这次修复的另一半
// gray-100 / blue-50 / red-50 … 这些原本是实心的)。
const takeover = await readFile(new URL('../../src/background-takeover.generated.css', import.meta.url), 'utf8');
const rules = `${vars}\n${struct}\n${takeover}`;
console.log(` 注入:变量 ${first.size} 个 + 结构 ${struct.length} 字符 + 接管清单 ${takeover.length} 字符;含嵌套条 ${/\.bg-white \.bg-white \{/.test(struct)}`);
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(URL_ + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
if (await page.locator('input[autocomplete=username]').count()) {
await page.fill('input[autocomplete=username]', 'gui-lab');
await page.fill('input[type=password]', 'gui123456');
await page.click('button:has-text("登录")');
await page.waitForTimeout(3500);
}
const pre = await page.evaluate(() => ({ t: (document.body.innerText||'').slice(0,40).replace(/\n/g,'|'), els: document.querySelectorAll('*').length }));
console.log(` 前置元素 ${pre.els} 正文${pre.t}`);
if (pre.els < 60 || pre.t.includes('加载中')) { console.log(' ✗ 前置不成立,终止(不给出结论)'); await browser.close(); process.exit(2); }
await page.addStyleTag({ content: rules });
await page.evaluate(() => {
const r = document.documentElement;
r.classList.remove('dark');
r.setAttribute('data-bg', 'on');
r.style.setProperty('--bg-dim', '0%');
r.style.setProperty('--bg-blur', '0px');
});
async function sample(bgImage) {
await page.evaluate((img) => {
const r = document.documentElement;
if (img) r.style.setProperty('--bg-image', `url("${img}")`), r.setAttribute('data-bg', 'on');
else { r.style.removeProperty('--bg-image'); r.setAttribute('data-bg', 'off'); }
}, bgImage);
await page.waitForTimeout(500);
const cdp = await page.context().newCDPSession(page);
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' });
await cdp.detach();
return page.evaluate(async (b64) => {
const img = new Image(); img.src = 'data:image/png;base64,' + b64; await img.decode();
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0);
const dpr = img.width / innerWidth, pts = [];
for (let y = 60; y < innerHeight - 60; y += 40)
for (let x = 60; x < innerWidth - 60; x += 40) {
const d = ctx.getImageData(Math.round(x*dpr), Math.round(y*dpr), 1, 1).data;
pts.push({ x, y, g: d[1] });
}
return pts;
}, data);
}
const off = await sample('');
/** 面板点的有效不透明度分布(浅底的才算面板;暗色小控件故意不透明,不计入)。 */
async function measure(label, rules) {
await page.evaluate((css) => {
document.querySelectorAll('style[data-probe]').forEach((n) => n.remove());
const st = document.createElement('style');
st.setAttribute('data-probe', '1');
st.textContent = css;
document.head.appendChild(st);
const r = document.documentElement;
r.classList.remove('dark');
r.setAttribute('data-bg', 'on');
r.style.setProperty('--bg-dim', '0%');
r.style.setProperty('--bg-blur', '0px');
}, rules);
const on = await sample(RED);
const panels = on
.map((p, i) => ({ x: p.x, y: p.y, gOn: p.g, gOff: off[i].g }))
.filter((p) => p.gOff >= 200);
const a = panels.map((p) => p.gOn / p.gOff).sort((x, y) => x - y);
const q = (r) => a[Math.min(a.length - 1, Math.floor(a.length * r))];
const stack = a.filter((v) => v >= 0.95).length / a.length; // 被吃光的比例
const sameCoord = panels.filter((p) => p.gOff >= p.gOn + 15).length / panels.length;
console.log(` [${label}] 面板 ${a.length} 中位 ${(q(0.5) * 100).toFixed(1)}% p90 ${(q(0.9) * 100).toFixed(1)}% p95 ${(q(0.95) * 100).toFixed(1)}% 被吃光(95%) ${(stack * 100).toFixed(1)}%`);
return { a, stack, p95: q(0.95), p50: q(0.5), sameCoord };
}
const fixed = await measure('修复后', rules);
// 对比度底线:换成近乎全黑的照片面,面板仍须是浅底(否则正文读不动)
const DARK_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR4nGNgYGD4z4AGGNEFAB0MAgHh0m0iAAAAAElFTkSuQmCC';
const darkOn = await sample(DARK_PNG);
const darkPanelG = darkOn.filter((p, i) => off[i].g >= 200).map((p) => p.g).sort((a, b) => a - b);
// 取中位数而不是 p5最暗的那 5% 混着徽标/边框/文字这些**故意**深色的元素,
// 拿它们当"面板底"会得到一个假失败。中位数才是"典型面板"。
const darkMidG = darkPanelG[Math.floor(darkPanelG.length / 2)];
console.log(` [深色照片面] 面板绿通道 中位 = ${darkMidG}p5 ${darkPanelG[Math.floor(darkPanelG.length*0.05)]}`);
// 对照把玻璃值改回缺陷时的样子0.82,且没有"嵌套不再叠加"那几条)
const before = rules
.replace(/--bg-glass:\s*0\.62;/g, '--bg-glass: 0.82;')
.replace(/html\[data-bg='on'\] \.bg-white \.bg-white( \.bg-white)? \{[^}]*\}/g, '');
const regressed = await measure('修复前(0.82 且层层叠加)', before);
const ok1 = fixed.p95 <= 0.85;
const ok2 = fixed.stack <= 0.05;
const ok3 = darkMidG >= 150; // 深色照片面上,面板仍是浅底
const ok4 = fixed.sameCoord >= 0.6;
// 对照必须真的量出差别 —— 否则这条判据对这次改动毫无分辨力
const ok5 = regressed.p95 - fixed.p95 >= 0.1; // 旧值明显更实0.82 vs 0.62
console.log('');
console.log(` ${ok1 ? '通过' : '失败'} 修复后面板 p95 0.85壁纸普遍透得出来`);
console.log(` ${ok2 ? '通过' : '失败'} 修复后"被吃光"(95%) 的面板点 5%`);
console.log(` ${ok3 ? '通过' : '失败'} 深色照片面上面板仍是浅底绿中位 150正文对比度`);
console.log(` ${ok4 ? '通过' : '失败'} 对照关掉壁纸后同坐标更实 60%`);
console.log(` ${ok5 ? '通过' : '失败'} 反向对照改回旧值后面板明显更实p95 10 个百分点`);
await page.screenshot({ path: `${OUT}/20-fixed.png` });
await page.evaluate((css) => {
document.querySelectorAll('style[data-probe]').forEach((n) => n.remove());
const st = document.createElement('style');
st.setAttribute('data-probe', '2');
st.textContent = css;
document.head.appendChild(st);
}, before);
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/21-before.png` });
const pass = [ok1, ok2, ok3, ok4, ok5].filter(Boolean).length;
console.log(`\n壁纸层次${pass} 通过${5 - pass} 失败截图 ${OUT}/20-fixed.png21-before.png`);
await browser.close();
process.exit(pass === 5 ? 0 : 1);