/** * 外观「现代化」的手工验收。 * * 与 theme.test.mjs / background.test.mjs 的分工:那两个守源码形态(令牌有没有 * 定义、颜色有没有走变量),这里量的是**真实渲染结果**。现代化最容易被自欺的 * 地方正是「改了一堆类名,但界面上什么都没变」—— 只有量出来才知道。 * * 四条判据都对着一个具体的、曾经真实存在的问题: * * 1. **字号下限**。改造前全站 172 处 text-xs(12px) 当正文、另有 * 84+68+19 处 text-[10px]/[11px]/[9px] 的魔法数字,最小只有 9px。 * 这里不是查类名,而是**遍历真实 DOM 算最小字号** —— 类名写对了但没生效 * (比如配置没被 Tailwind 读到)就该失败。 * **不只要求下限**:邮件正文(阅读区 `.markdown`)必须达到 14px —— * 一个阅读类应用把正文做成 12px 才是真正的“不现代”。 * 注意**不要拿收件箱列表去要求正文占比**:那一屏本质上是元信息密集区, * 发件人/时间/会话别名本来就该小。(第一版判据就是这么写错的, * 量出 39.7% < 40% 而“失败”—— 判据量错了对象。) * 2. **层次(elevation)真的存在**。改造前 --shadow-1/2/3 三个令牌定义了但 * 零处使用,全站只有 10 处系统默认阴影 → 界面是平的。这里断言列表面板 * 与浮动层**真的有 box-shadow 且不等于 none**。 * 3. **分隔线被软化**。全站 67 处 border-gray-200。断言令牌值确实是软化后的, * 而不是「以为改了」。 * 4. **正文对比度仍然达标**。字号变大、线变淡之后,最容易顺手弄坏的就是对比度。 * 这里对真实渲染的文字做 WCAG AA 抽查。 * * 用法: * AGENTMAIL_DIST=$PWD/client/electron/dist \ * AGENTMAIL_URL=http://127.0.0.1:8180 \ * ADMIN_USER=gui-lab ADMIN_PW=... \ * node test/manual/modernization-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 : ''}`); } }; 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 parseRgba = s => { const m = String(s).match( /rgba?\(\s*(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)(?:[,\s/]+([\d.]+))?/ ); if (!m) return null; return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])]; }; await mkdir(OUT, { recursive: true }); const { browser, page } = await openApp(WIDE); // 可指定主题。openApp 复用共享浏览器,localStorage 会残留上一次测试的值, // 所以要用同一套持久化键(agentmail.theme)写进去再 reload, // 而不是只靠 documentElement.classList 假装切换(那样 store 下次渲染就把你改回来了)。 if (process.env.VERIFY_THEME) { await page.evaluate(t => localStorage.setItem('agentmail.theme', t), process.env.VERIFY_THEME); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(900); } /** 量真实 DOM 里的字号下限与分布。 */ const measureType = () => page.evaluate(() => { const seen = new Map(); let min = Infinity; let minText = ''; for (const el of document.querySelectorAll('*')) { // 只看真的在渲染文字的叶子元素,父容器的 font-size 不算 const hasOwnText = [...el.childNodes].some( n => n.nodeType === 3 && n.textContent.trim().length > 0 ); if (!hasOwnText) continue; const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; const px = parseFloat(getComputedStyle(el).fontSize); if (!Number.isFinite(px)) continue; seen.set(px, (seen.get(px) || 0) + 1); if (px < min) { min = px; minText = (el.textContent || '').trim().slice(0, 24); } } return { min, minText, histogram: [...seen.entries()].sort((a, b) => a[0] - b[0]) }; }); const measureElevation = () => page.evaluate(() => { const shadowOf = sel => { const el = document.querySelector(sel); if (!el) return null; return getComputedStyle(el).boxShadow; }; // 列表面板:只有并排显示(lg)时才该有面板阴影 const panel = document.querySelector('.lg\\:shadow-panel') || document.querySelector('[class*="shadow-panel"]'); return { panel: panel ? getComputedStyle(panel).boxShadow : null, panelFound: !!panel }; }); const themeTokens = () => page.evaluate(() => { const cs = getComputedStyle(document.documentElement); return { gray200: cs.getPropertyValue('--c-gray-200').trim(), shadow1: cs.getPropertyValue('--shadow-1').trim(), panel: cs.getPropertyValue('--shadow-panel').trim() }; }); /** 对真实渲染的正文做对比度抽查(取列表里的主题行)。 */ const sampleContrast = () => page.evaluate(() => { const parseRgba = s => { const m = String(s).match( /rgba?\(\s*(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)(?:[,\s/]+([\d.]+))?/ ); if (!m) return null; return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])]; }; const dark = document.documentElement.classList.contains('dark'); // 先画的是最底层:从根往下才是正确顺序 const composite = (el, base) => { const chain = []; for (let p = el; p; p = p.parentElement) { const c = parseRgba(getComputedStyle(p).backgroundColor); if (c && c[3] > 0) chain.push(c); } let out = base; for (const [r, g, b, a] of chain.reverse()) { out = [r * a + out[0] * (1 - a), g * a + out[1] * (1 - a), b * a + out[2] * (1 - a)]; } return out; }; const BODY = dark ? [24, 27, 33] : [255, 255, 255]; const nodes = [...document.querySelectorAll('button.w-full.text-left span, .markdown p')] .filter(n => (n.textContent || '').trim().length > 4) .slice(0, 40); const out = []; for (const n of nodes) { const cs = getComputedStyle(n); const bg = composite(n, BODY); out.push({ text: n.textContent.trim().slice(0, 20), fg: cs.color, bg: `rgb(${bg.map(v => Math.round(v)).join(',')})`, size: parseFloat(cs.fontSize) }); } return out; }); /** 从真实 DOM 推断当前生效的主题,而不是假设。上一版写死了 light, * 而 openApp 复用的浏览器里 localStorage 可能残留上一次测试的 dark, * 于是拿深色的令牌值去比浅色的期望值 → 假失败。 */ const detectTheme = () => page.evaluate(() => (document.documentElement.classList.contains('dark') ? 'dark' : 'light')); /** 量邮件正文(阅读区)字号;不在阅读界面时返回 null。 */ const measureBodyFont = () => page.evaluate(() => { const el = document.querySelector('.markdown'); if (!el) return null; return parseFloat(getComputedStyle(el).fontSize); }); console.log('=== 现代化验收 ===\n'); const theme = await detectTheme(); console.log(` 实际生效主题: ${theme}\n`); // ── 1. 字号 ── const type = await measureType(); console.log(' 字号分布(真实 DOM,px → 元素数):'); for (const [px, n] of type.histogram) console.log(` ${String(px).padStart(5)}px ${n}`); // 改造前最小 9px,且 10px 档有 103 处。判据:不小于 10px,且主体正文不小于 13px。 check('最小字号 ≥ 11px(改造前为 9px)', type.min >= 11, `实测 ${type.min}px,出现在「${type.minText}」`); // 邮件正文必须舒适 —— 这才是“阅读类应用是否现代”的真正判据。 // 先切到一封有正文的邮件再量(否则量的是空状态提示)。 const firstMail = page.locator('button.w-full.text-left').nth(1); if (await firstMail.count()) { await firstMail.click(); await page.waitForTimeout(900); } const bodyFont = await measureBodyFont(); if (bodyFont === null) { check('邮件正文字号 ≥ 14px', false, '页面上没有找到 .markdown 正文节点(选一封邮件后重试)'); } else { check('邮件正文字号 ≥ 14px', bodyFont >= 14, `实测 ${bodyFont}px`); } // ── 2. 层次 ── const elev = await measureElevation(); check('列表面板存在(lg:shadow-panel)', elev.panelFound, JSON.stringify(elev)); check( '列表面板真的有阴影(不再是纯边框分组)', !!elev.panel && elev.panel !== 'none' && elev.panel.length > 5, String(elev.panel) ); // ── 3. 令牌 ── const tk = await themeTokens(); // 灰度分隔线的期望值按**实际生效的主题**取,不写死浅色。 const expectGray200 = theme === 'dark' ? '39 43 52' : '234 236 241'; const originalGray200 = theme === 'dark' ? '44 49 59' : '229 231 235'; check('--shadow-1 已定义', tk.shadow1.length > 5, tk.shadow1); check('--shadow-panel 已定义', tk.panel.length > 5, tk.panel); check( `--c-gray-200 是软化后的值(${theme})`, tk.gray200 === expectGray200, `实测 ${tk.gray200},期望 ${expectGray200}(原值 ${originalGray200})` ); // ── 4. 对比度 ── const samples = await sampleContrast(); const bad = []; for (const s of samples) { const fg = parseRgba(s.fg); const bg = parseRgba(s.bg); if (!fg || !bg) continue; const ratio = contrast(fg.slice(0, 3), bg.slice(0, 3)); // 大字号(≥18.66px 粗体 / ≥24px)门槛 3:1,其余 4.5:1 const need = s.size >= 24 ? 3 : 4.5; if (ratio < need) bad.push(`${s.text} → ${ratio.toFixed(2)}:1 (<${need}) size=${s.size}`); } check( `正文对比度全部达标(抽查 ${samples.length} 处)`, bad.length === 0, bad.slice(0, 6).join(' | ') ); await writeFile(`${OUT}/50-modern-${theme}-contrast.json`, JSON.stringify(samples, null, 2)); await page.screenshot({ path: `${OUT}/50-modern-${theme}.png` }); await writeFile( `${OUT}/50-modern-${theme}.json`, JSON.stringify({ theme, type, bodyFont, elev, tk, samples: samples.length, bad }, null, 2) ); console.log(`\n现代化:${pass} 通过,${fail} 失败`); await page.close(); await browser.close(); process.exit(fail === 0 ? 0 : 1);