Files
MailUI4Agents/client/electron/test/manual/modernization-verify.mjs
JianFeeeee c19eea5e3c feat(webui): 真正动可见层的现代化 —— 字号、层次、间距、分隔线
# 起因:上一轮的「现代化」基本不算现代化

用户指出「我说的是 webui 现代化」。回看上一轮,我交付的其实是**底层改进**:
圆角加大一档、自定义滚动条、焦点环、过渡、reduce-motion、令牌与可访问性。
这些都对,但**可见变化几乎只有圆角** —— 界面看起来还是老样子。

实测数据确认了「老」在哪:

  text-xs(12px)  172 处   ← 被当正文用
  text-[10px]     84 处
  text-[11px]     68 处
  text-[9px]      19 处   ← 现代显示器上基本读不了
  text-sm(14px)   69 处
  text-base(16px)  4 处

  57 处 border-b + 21 处 border-r,其中 67 条是 border-gray-200 的硬灰线

  阴影:全站共 10 处,且全是 Tailwind 系统默认档;
        index.css 里 --shadow-1/2/3 三个语义令牌**定义了但零处使用**

所以真正的病因是三条:**字太小、层次为零、硬线切分**。

# 改动

## 1. 字号体系抬一档(tailwind.config.js)

不用 Tailwind 默认档,重定为:

  3xs 11px(角标下限,取代 9/10px 魔法数字)
  2xs 12px(元信息,取代 11px)
  xs  13px(次要正文,原 12px —— 拿它当正文的地方自动变舒适)
  sm  14px(正文)
  base 15px

并把 171 处裸 px 类名(text-[9px]/[10px]/[11px])统一换成令牌 ——
顺带消除魔法数字。行高一起给:小档位 1.35/1.45,正文 1.55,
只放大字号不放行高会把密排列表顶得很难看。

## 2. 把层次接出来(原本是死代码)

tailwind.config.js 新增 boxShadow 映射 `--shadow-1/2/3` + 新增
`--shadow-panel`(横向偏移 + 大扩散,竖向几乎不偏移,否则全高面板像浮在半空)。

用于:列表面板(lg:shadow-panel,**同时去掉 border-r 硬线**)、
登录/初始化卡片(shadow-sm → shadow-2 + 去硬边框)、
地址自动补全下拉(shadow-lg → shadow-2)、窄屏滑入详情面板
(shadow-2xl → shadow-3 + 去 border-l)、主题分段控件的选中滑块。

深色下层次比浅色更难感知,所以 --shadow-panel 在深色里更实一些;
深色里靠边框分组几乎看不见,层次是**唯一**有效的分组手段。

## 3. 分隔线软化(改令牌而不是改 67 处类名)

`--c-gray-200` 浅色 229 231 235 → 234 236 241,深色 44 49 59 → 39 43 52。
改在令牌上,67 条边框 + 8 处底色一次性生效且不会漏。

**刻意没有一起调 gray-300**:它同时是滚动条滑块色,调淡会让滑块更难看见。

## 4. 配比放宽(列表行的呼吸感)

MailList:行内距 px-3 py-2.5 → px-3.5 py-3,列表 gap space-y-0.5 → space-y-1,
表头 py-3 → py-3.5。未读主题字重 medium → semibold,已读 gray-500 → gray-600。

## 5. 量出来的两个真实对比度缺陷(不是估算)

新增 `test/manual/modernization-verify.mjs`,用真实渲染做四条判据。
它量出浅色下两个 WCAG AA 不达标(阈值 4.5:1):

  - 会话别名 `text-blue-500` 白底 3.68:1(别名在 mail list / thread / mailview
    共 4 处,都是 11px 小字)→ 改 blue-600/700,达 5.17:1
  - 时间戳 `text-gray-400` 压在选中行淡蓝底 `bg-blue-50` 上 4.44:1

第二个的**根因是调色板缺一档**:浅色下 `--c-gray-400` 与 `--c-gray-500`
完全相同(都是 107 114 128),于是「比次要文字再深一档的中间色」根本不存在,
时间戳无处可退。拉开 gray-500 → 90 98 112(5.65:1),并把 5 个列表组件的
行内元信息(19 处)从 gray-400 提到 gray-500。

# 验证

- typecheck 干净
- 前端全量 `npm test` EXIT=0(markdown-xss / narrow-layout / theme 30 /
  background 15 / vitest 216)
- **真实渲染** `modernization-verify.mjs`:浅色 8/8、深色 8/8,判据含
  最小字号 ≥ 11px(改造前 9px)、邮件正文 ≥ 14px、列表面板真有 box-shadow、
  gray-200 是软化值、40 处正文对比度全部达标

# 我自己的三处错(都被这次的度量拦下)

1. **判据量错对象**:第一版拿「收件箱列表」要求 40% 元素 ≥13px,量出 39.7%
   判失败 —— 而收件箱本质是元信息密集区,发件人/时间/别名本来就该小。
   改成量真正该达标的**邮件正文**(≥14px)。
2. **探针忽略 alpha**:`parseRgb` 把 `rgba(239,246,255,0.4)` 的 alpha 丢掉当实色,
   于是把淡蓝底当纯蓝算出 4.44:1 的假缺陷。改为按画家算法合成整条背景链。
3. **config 注释换算写错**:3xs 注释写 10px,0.6875rem 其实是 11px。
2026-09-12 09:54:31 +08:00

267 lines
11 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 / 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(' 字号分布(真实 DOMpx → 元素数):');
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);