/** * 壁纸到底有没有透出来 —— 量化判据(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.png、21-before.png)`); await browser.close(); process.exit(pass === 5 ? 0 : 1);