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:
266
client/electron/test/manual/modernization-verify.mjs
Normal file
266
client/electron/test/manual/modernization-verify.mjs
Normal 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(' 字号分布(真实 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);
|
||||
Reference in New Issue
Block a user