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。
This commit is contained in:
2026-09-12 09:54:31 +08:00
parent 0f379a2ca0
commit c19eea5e3c
26 changed files with 522 additions and 185 deletions

View File

@ -0,0 +1,266 @@
/**
* 外观「现代化」的手工验收。
*
* 与 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);