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

@ -194,18 +194,18 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
<span className="tap text-2xs text-gray-500">{user.display_name}</span>
<span className={`text-3xs px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
<span className={`text-3xs px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
{user.status === 'active' ? '启用' : '禁用'}
</span>
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
<span className="text-[10px] text-gray-400">受限</span>
<span className="text-3xs text-gray-400">受限</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{user.last_login || '从未登录'}</span>
<span className="text-3xs text-gray-400">{user.last_login || '从未登录'}</span>
</div>
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
</div>
@ -258,12 +258,12 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
<label className="block text-2xs font-medium text-gray-500 mb-1">显示名</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">角色</label>
<label className="block text-2xs font-medium text-gray-500 mb-1">角色</label>
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user">普通用户</option>
@ -271,7 +271,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
</select>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">状态</label>
<label className="block text-2xs font-medium text-gray-500 mb-1">状态</label>
{user.status === 'active' ? (
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full">禁用</button>
) : (
@ -283,7 +283,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
{user.role !== 'admin' && (
<>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
<label className="block text-2xs font-medium text-gray-500 mb-1.5">
可调用 Agent {agents.length > 0 && <span className="text-gray-400">({agents.length} 项)</span>}
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限</span>
</label>
@ -297,7 +297,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
<label className="block text-2xs font-medium text-gray-500 mb-1.5">
可访问目录 {paths.length > 0 && <span className="text-gray-400">({paths.length} 项)</span>}
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限;按目录前缀匹配</span>
</label>
@ -322,7 +322,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-chrome-700 text-white hover:bg-chrome-800 disabled:opacity-40">
className="tap inline-flex items-center gap-1 px-2 py-1 text-2xs rounded bg-chrome-700 text-white hover:bg-chrome-800 disabled:opacity-40">
<CheckIcon className="w-3 h-3" /> 重置
</button>
</div>
@ -402,7 +402,7 @@ function ScopePick({ label, items, selected, onToggle, color }: {
const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
return (
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
<label className="block text-2xs font-medium text-gray-500 mb-1">
{label} <span className="font-normal text-gray-400">未勾选 = 不限</span>
</label>
<div className="flex flex-wrap gap-1.5">
@ -421,8 +421,8 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
return (
<div>
<div className="flex items-baseline gap-1.5 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
<label className="text-2xs font-medium text-gray-500">{label}</label>
{hint && <span className="text-3xs text-gray-400">{hint}</span>}
</div>
{children}
</div>