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

@ -145,7 +145,7 @@ export default function AccountPage() {
</h3> </h3>
<form onSubmit={changePw} className="space-y-3"> <form onSubmit={changePw} className="space-y-3">
<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>
<input <input
type="password" type="password"
value={oldPw} value={oldPw}
@ -155,7 +155,7 @@ export default function AccountPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"> 8 </label> <label className="block text-2xs font-medium text-gray-500 mb-1"> 8 </label>
<input <input
type="password" type="password"
value={newPw} value={newPw}
@ -165,7 +165,7 @@ export default function AccountPage() {
/> />
</div> </div>
<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>
<input <input
type="password" type="password"
value={confirmPw} value={confirmPw}
@ -175,7 +175,7 @@ export default function AccountPage() {
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400' mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
}`} }`}
/> />
{mismatch && <p className="mt-1 text-[10px] text-red-500"></p>} {mismatch && <p className="mt-1 text-3xs text-red-500"></p>}
</div> </div>
{error && ( {error && (

View File

@ -181,12 +181,12 @@ export default function AddressInput({
{open && items.length > 0 && ( {open && items.length > 0 && (
<div <div
className={`absolute z-20 w-full overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg ${ className={`absolute z-20 w-full overflow-y-auto bg-white border border-gray-200 rounded-lg shadow-2 ${
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1' menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
}`} }`}
style={{ maxHeight: menuLayout.maxHeight }} style={{ maxHeight: menuLayout.maxHeight }}
> >
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100"> <div className="px-2.5 py-1 text-3xs text-gray-400 border-b border-gray-100">
{hint} {hint}
</div> </div>
{items.map((s, i) => { {items.map((s, i) => {
@ -216,23 +216,23 @@ export default function AddressInput({
一条已经在跑的会话,而不是继续一条已有的邮件往来 */} 一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
{c?.source === 'platform' && ( {c?.source === 'platform' && (
<span <span
className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]" className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs"
title="平台侧已有的会话,本站还没有对应的邮件往来" title="平台侧已有的会话,本站还没有对应的邮件往来"
> >
</span> </span>
)} )}
{c?.source === 'new' && ( {c?.source === 'new' && (
<span className="shrink-0 text-[10px] text-gray-400 font-sans"></span> <span className="shrink-0 text-3xs text-gray-400 font-sans"></span>
)} )}
{(c?.unread ?? 0) > 0 && ( {(c?.unread ?? 0) > 0 && (
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-[9px]"> <span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-3xs">
{c!.unread} {c!.unread}
</span> </span>
)} )}
</div> </div>
{c?.title && c.source !== 'new' && ( {c?.title && c.source !== 'new' && (
<p className="text-[10px] text-gray-400 truncate mt-0.5">{c.title}</p> <p className="text-3xs text-gray-400 truncate mt-0.5">{c.title}</p>
)} )}
</button> </button>
); );

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' : ''}`} /> <ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button> </button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span> <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="tap text-2xs 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={`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' ? '管理员' : '用户'} {user.role === 'admin' ? '管理员' : '用户'}
</span> </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' ? '启用' : '禁用'} {user.status === 'active' ? '启用' : '禁用'}
</span> </span>
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && ( {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" /> <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> </div>
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />} {expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
</div> </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="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 className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<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>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} <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" /> 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>
<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')} <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"> 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> <option value="user"></option>
@ -271,7 +271,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
</select> </select>
</div> </div>
<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' ? ( {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> <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' && ( {user.role !== 'admin' && (
<> <>
<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">
Agent {agents.length > 0 && <span className="text-gray-400">{agents.length} </span>} Agent {agents.length > 0 && <span className="text-gray-400">{agents.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span> <span className="ml-2 font-normal text-gray-400"> = </span>
</label> </label>
@ -297,7 +297,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
</div> </div>
</div> </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>} 访 {paths.length > 0 && <span className="text-gray-400">{paths.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span> <span className="ml-2 font-normal text-gray-400"> = </span>
</label> </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 位)" <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" /> 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} <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" /> <CheckIcon className="w-3 h-3" />
</button> </button>
</div> </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'; const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
return ( return (
<div> <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} <span className="font-normal text-gray-400"> = </span>
</label> </label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -421,8 +421,8 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
return ( return (
<div> <div>
<div className="flex items-baseline gap-1.5 mb-1"> <div className="flex items-baseline gap-1.5 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label> <label className="text-2xs font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>} {hint && <span className="text-3xs text-gray-400">{hint}</span>}
</div> </div>
{children} {children}
</div> </div>

View File

@ -11,7 +11,7 @@ export function AttachmentList({ items }: { items: Attachment[] }) {
<div className="mt-4 border-t border-gray-100 pt-3"> <div className="mt-4 border-t border-gray-100 pt-3">
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" /> <PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[11px] font-medium text-gray-500"> <span className="text-2xs font-medium text-gray-500">
{items.length} {items.length}
</span> </span>
</div> </div>
@ -26,7 +26,7 @@ export function AttachmentList({ items }: { items: Attachment[] }) {
> >
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" /> <FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span> <span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
<span className="text-[10px] text-gray-400 shrink-0"> <span className="text-3xs text-gray-400 shrink-0">
{api.formatSize(a.size_bytes)} {api.formatSize(a.size_bytes)}
</span> </span>
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" /> <DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
@ -126,7 +126,7 @@ export function AttachmentPicker({
</button> </button>
{uploading && ( {uploading && (
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-[11px] text-gray-500"> <span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-2xs text-gray-500">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" /> <SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
<span className="truncate">{uploading.name}</span> <span className="truncate">{uploading.name}</span>
<span className="shrink-0">{uploading.pct}%</span> <span className="shrink-0">{uploading.pct}%</span>
@ -134,14 +134,14 @@ export function AttachmentPicker({
)} )}
{items.length > 0 && !uploading && ( {items.length > 0 && !uploading && (
<span className="text-[11px] text-gray-500 min-w-0 break-words"> <span className="text-2xs text-gray-500 min-w-0 break-words">
{items.length} ·{' '} {items.length} ·{' '}
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))} {api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
</span> </span>
)} )}
</div> </div>
{error && <div className="text-[11px] text-red-600 break-words">{error}</div>} {error && <div className="text-2xs text-red-600 break-words">{error}</div>}
{items.length > 0 && ( {items.length > 0 && (
<ul className="space-y-1"> <ul className="space-y-1">
@ -152,7 +152,7 @@ export function AttachmentPicker({
> >
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" /> <FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span> <span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span> <span className="text-3xs text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
<button <button
type="button" type="button"
onClick={() => remove(a)} onClick={() => remove(a)}

View File

@ -118,7 +118,7 @@ export default function BackgroundPicker() {
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }} style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
aria-hidden="true" aria-hidden="true"
/> />
<span className="absolute bottom-0 inset-x-0 text-[10px] py-0.5 bg-black/45 text-white"> <span className="absolute bottom-0 inset-x-0 text-3xs py-0.5 bg-black/45 text-white">
{p.label} {p.label}
</span> </span>
</button> </button>
@ -152,11 +152,11 @@ export default function BackgroundPicker() {
<img src={imageDataUrl} alt="背景预览" className="w-full h-full object-cover" /> <img src={imageDataUrl} alt="背景预览" className="w-full h-full object-cover" />
</div> </div>
)} )}
<p className="text-[11px] text-gray-500"> <p className="text-2xs text-gray-500">
2560px 2.4MB 2560px 2.4MB
</p> </p>
{error && ( {error && (
<p role="alert" className="text-[11px] text-red-600"> <p role="alert" className="text-2xs text-red-600">
{error} {error}
</p> </p>
)} )}
@ -186,7 +186,7 @@ export default function BackgroundPicker() {
onChange={setBlur} onChange={setBlur}
/> />
{/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */} {/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */}
<p className="text-[11px] text-gray-500"> <p className="text-2xs text-gray-500">
{Math.round((imageDataUrl.length || 1) / 1024)}KB{' '} {Math.round((imageDataUrl.length || 1) / 1024)}KB{' '}
{Math.round(MAX_DATA_URL_BYTES / 1024)}KB {Math.round(MAX_DATA_URL_BYTES / 1024)}KB
</p> </p>
@ -232,7 +232,7 @@ function Slider({
className="w-full mt-1 accent-blue-600" className="w-full mt-1 accent-blue-600"
aria-label={label} aria-label={label}
/> />
<span className="block text-[11px] text-gray-500">{hint}</span> <span className="block text-2xs text-gray-500">{hint}</span>
</label> </label>
); );
} }

View File

@ -465,7 +465,7 @@ function MonthGrid({
{/* 农历日必须显示:农历重复规则的公历日期每次都在变, {/* 农历日必须显示:农历重复规则的公历日期每次都在变,
不显示农历人无法确认「每月十五」到底落在哪一格 */} 不显示农历人无法确认「每月十五」到底落在哪一格 */}
<span <span
className={`text-[10px] leading-none truncate ${ className={`text-3xs leading-none truncate ${
outside ? 'text-gray-300' : 'text-gray-400' outside ? 'text-gray-300' : 'text-gray-400'
}`} }`}
> >
@ -535,7 +535,7 @@ function WeekGrid({
> >
{d.getMonth() + 1}.{d.getDate()} {d.getMonth() + 1}.{d.getDate()}
</div> </div>
<div className="text-[10px] text-gray-400 leading-none">{cellLunarLabel(d)}</div> <div className="text-3xs text-gray-400 leading-none">{cellLunarLabel(d)}</div>
</div> </div>
<div className="flex-1 p-1 flex flex-col gap-1"> <div className="flex-1 p-1 flex flex-col gap-1">
{list.length === 0 ? ( {list.length === 0 ? (

View File

@ -189,7 +189,7 @@ export default function ComposePage() {
placeholder="refactor-auth" placeholder="refactor-auth"
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" 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"
/> />
{aliasError && <span className="text-[10px] text-red-600">{aliasError}</span>} {aliasError && <span className="text-3xs text-red-600">{aliasError}</span>}
</Field> </Field>
)} )}
@ -227,11 +227,11 @@ export default function ComposePage() {
placeholder={defaultRoundsHint} placeholder={defaultRoundsHint}
className="w-24 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" className="w-24 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"
/> />
<span className="text-[11px] text-gray-400"> <span className="text-2xs text-gray-400">
Agent Agent
</span> </span>
</div> </div>
{roundsError && <span className="text-[10px] text-red-600">{roundsError}</span>} {roundsError && <span className="text-3xs text-red-600">{roundsError}</span>}
</Field> </Field>
)} )}
@ -251,7 +251,7 @@ export default function ComposePage() {
<div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col"> <div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
<div className="shrink-0 flex items-center gap-2 mb-1.5"> <div className="shrink-0 flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-gray-500">Markdown</span> <span className="text-2xs font-medium text-gray-500">Markdown</span>
<div className="flex-1" /> <div className="flex-1" />
<Toggle active={!preview} onClick={() => setPreview(false)}> <Toggle active={!preview} onClick={() => setPreview(false)}>
@ -319,8 +319,8 @@ function Field({
return ( return (
<div> <div>
<div className="flex items-baseline gap-2 mb-1"> <div className="flex items-baseline gap-2 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label> <label className="text-2xs font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>} {hint && <span className="text-3xs text-gray-400">{hint}</span>}
</div> </div>
{children} {children}
</div> </div>
@ -339,7 +339,7 @@ function Toggle({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`tap text-[11px] px-2 py-0.5 rounded ${ className={`tap text-2xs px-2 py-0.5 rounded ${
active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800' active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
}`} }`}
> >

View File

@ -81,7 +81,7 @@ export default function ContactPanel() {
</button> </button>
<button <button
onClick={toggleArchivedView} onClick={toggleArchivedView}
className={`tap text-[11px] px-1.5 py-0.5 rounded ${ className={`tap text-2xs px-1.5 py-0.5 rounded ${
showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800' showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
}`} }`}
> >
@ -137,7 +137,7 @@ export default function ContactPanel() {
{showArchived && ( {showArchived && (
<div className="pt-3 mt-2 border-t border-gray-200"> <div className="pt-3 mt-2 border-t border-gray-200">
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400"> <p className="px-2 pb-1 text-2xs font-medium text-gray-400">
{archivedContacts.length} {archivedContacts.length}
</p> </p>
{archivedContacts.map(c => ( {archivedContacts.map(c => (
@ -146,7 +146,7 @@ export default function ContactPanel() {
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50" className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
> >
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p> <p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
<p className="text-[10px] text-gray-400 mt-0.5"> <p className="text-3xs text-gray-500 mt-0.5">
{c.mail_count} · {c.mail_count} ·
</p> </p>
</div> </div>
@ -181,20 +181,20 @@ function ArchiveConfirm({
<p className="text-xs text-gray-800"> <p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span> <span className="font-mono">{contact.address}</span>
</p> </p>
<p className="text-[10px] text-gray-500 mt-0.5"> <p className="text-3xs text-gray-500 mt-0.5">
Agent session Agent session
</p> </p>
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<button <button
onClick={onConfirm} onClick={onConfirm}
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700" className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-2xs font-medium hover:bg-red-700"
> >
<CheckIcon className="w-3 h-3" /> <CheckIcon className="w-3 h-3" />
</button> </button>
<button <button
onClick={onCancel} onClick={onCancel}
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white" className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-2xs hover:bg-white"
> >
<CloseIcon className="w-3 h-3" /> <CloseIcon className="w-3 h-3" />
@ -235,20 +235,20 @@ function ContactRow({
<span className="text-xs font-semibold text-gray-900 truncate"> <span className="text-xs font-semibold text-gray-900 truncate">
{contact.agent_name} {contact.agent_name}
</span> </span>
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span> <span className="text-3xs text-gray-500 font-mono truncate">{contact.path}</span>
{contact.unread_count > 0 && ( {contact.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center"> <span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-3xs font-bold flex items-center justify-center">
{contact.unread_count} {contact.unread_count}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-1 mt-0.5"> <div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" /> <ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate"> <span className="text-2xs text-blue-600 font-mono truncate">
{contact.session_alias || '(未命名会话)'} {contact.session_alias || '(未命名会话)'}
</span> </span>
</div> </div>
<p className="text-[10px] text-gray-400 mt-0.5"> <p className="text-3xs text-gray-500 mt-0.5">
{contact.mail_count} · {time} {contact.mail_count} · {time}
</p> </p>
</button> </button>
@ -257,7 +257,7 @@ function ContactRow({
<button <button
onClick={onCompose} onClick={onCompose}
title="写信给该地址" title="写信给该地址"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white" className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-3xs text-gray-600 hover:bg-white"
> >
<ComposeIcon className="w-3 h-3" /> <ComposeIcon className="w-3 h-3" />
@ -265,7 +265,7 @@ function ContactRow({
<button <button
onClick={onRequestArchive} onClick={onRequestArchive}
title="归档该 name@path.session" title="归档该 name@path.session"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300" className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-3xs text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
> >
<ArchiveIcon className="w-3 h-3" /> <ArchiveIcon className="w-3 h-3" />

View File

@ -48,7 +48,7 @@ function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => vo
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all"> <code className="flex-1 text-2xs font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
{token} {token}
</code> </code>
<button <button
@ -121,7 +121,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
}`} }`}
> >
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div> <div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div> <div className="text-3xs text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
</button> </button>
))} ))}
</div> </div>
@ -142,7 +142,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))} onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100" className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/> />
<span className="text-[11px] text-gray-500"></span> <span className="text-2xs text-gray-500"></span>
</div> </div>
)} )}
</div> </div>
@ -220,7 +220,7 @@ export default function KeyPanel({
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} /> <CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
</div> </div>
<p className="text-[11px] text-gray-500"> <p className="text-2xs text-gray-500">
{variant === 'agent' {variant === 'agent'
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。' ? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
: '第三方客户端用该密钥访问自己的邮箱Authorization: Bearer。它不能用于注册 Agent。'} : '第三方客户端用该密钥访问自己的邮箱Authorization: Bearer。它不能用于注册 Agent。'}
@ -238,21 +238,21 @@ export default function KeyPanel({
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null; const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return ( return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap"> <div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0"> <code className="text-2xs font-mono text-gray-700 w-24 shrink-0">
{k.token_hint} {k.token_hint}
</code> </code>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-xs text-gray-900 truncate"> <div className="text-xs text-gray-900 truncate">
{k.label || <span className="text-gray-400"></span>} {k.label || <span className="text-gray-400"></span>}
</div> </div>
<div className="text-[10px] text-gray-500 mt-0.5"> <div className="text-3xs text-gray-500 mt-0.5">
{KEY_TYPE_LABEL[k.key_type]} {KEY_TYPE_LABEL[k.key_type]}
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`} {k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
{agentKey && {agentKey &&
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')} (agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
</div> </div>
</div> </div>
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span> <span className={`text-3xs shrink-0 ${st.cls}`}>{st.text}</span>
{agentKey && onBind && bindingID === k.key_id ? ( {agentKey && onBind && bindingID === k.key_id ? (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
@ -260,7 +260,7 @@ export default function KeyPanel({
value={bindName} value={bindName}
onChange={e => setBindName(e.target.value)} onChange={e => setBindName(e.target.value)}
placeholder="Agent 名" placeholder="Agent 名"
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1" className="w-28 text-2xs border border-gray-300 rounded px-1.5 py-1"
/> />
<button <button
onClick={() => { onClick={() => {
@ -268,13 +268,13 @@ export default function KeyPanel({
setBindingID(null); setBindingID(null);
setBindName(''); setBindName('');
}} }}
className="text-[11px] text-blue-600 hover:underline" className="text-2xs text-blue-600 hover:underline"
> >
</button> </button>
<button <button
onClick={() => setBindingID(null)} onClick={() => setBindingID(null)}
className="text-[11px] text-gray-500 hover:underline" className="text-2xs text-gray-500 hover:underline"
> >
</button> </button>
@ -287,7 +287,7 @@ export default function KeyPanel({
setBindingID(k.key_id); setBindingID(k.key_id);
setBindName(agentKey.agent_name ?? ''); setBindName(agentKey.agent_name ?? '');
}} }}
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0" className="text-2xs text-gray-500 hover:text-gray-900 shrink-0"
> >
</button> </button>

View File

@ -53,7 +53,7 @@ export default function LoginPage() {
// auto margin 在空间不足时自动退化为 0于是矮屏变成正常的顶对齐可滚布局。 // auto margin 在空间不足时自动退化为 0于是矮屏变成正常的顶对齐可滚布局。
return ( return (
<div className="h-full overflow-y-auto flex justify-center bg-slate-100"> <div className="h-full overflow-y-auto flex justify-center bg-slate-100">
<div className="w-[380px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8"> <div className="w-[380px] max-w-[92vw] shrink-0 my-auto bg-white rounded-2xl shadow-2 p-6 sm:p-8">
<div className="flex flex-col items-center mb-6"> <div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<BrandMarkIcon className="w-6 h-6" /> <BrandMarkIcon className="w-6 h-6" />
@ -64,7 +64,7 @@ export default function LoginPage() {
<form onSubmit={submit} className="space-y-3"> <form onSubmit={submit} className="space-y-3">
<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>
<input <input
ref={userRef} ref={userRef}
value={username} value={username}
@ -79,7 +79,7 @@ export default function LoginPage() {
</div> </div>
<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>
<input <input
type="password" type="password"
value={password} value={password}

View File

@ -63,8 +63,8 @@ export default function MailList() {
}; };
return ( return (
<div className="w-full lg:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0"> <div className="w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1"> <div className="px-4 py-3.5 border-b border-gray-200 flex items-center gap-1">
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2> <h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是 {/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
「有几件事」,后者只是流量 */} 「有几件事」,后者只是流量 */}
@ -75,7 +75,7 @@ export default function MailList() {
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto p-2 space-y-0.5"> <div className="flex-1 overflow-y-auto p-2.5 space-y-1">
{groups.map(g => {groups.map(g =>
isFlatGroup(g) ? ( isFlatGroup(g) ? (
<MailItem <MailItem
@ -157,7 +157,7 @@ function SessionGroup({
> >
<button <button
onClick={onToggle} onClick={onToggle}
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-gray-50" className="w-full text-left px-3.5 py-3 rounded-lg hover:bg-gray-50"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ChevronRightIcon <ChevronRightIcon
@ -173,18 +173,18 @@ function SessionGroup({
{showTo ? '→ ' : ''} {showTo ? '→ ' : ''}
{peer} {peer}
</span> </span>
<span className="text-[10px] text-gray-400 shrink-0">{time}</span> <span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5 pl-5"> <div className="flex items-center gap-1.5 mt-0.5 pl-5">
{g.unreadCount > 0 && ( {g.unreadCount > 0 && (
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center"> <span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-3xs font-bold flex items-center justify-center">
{g.unreadCount} {g.unreadCount}
</span> </span>
)} )}
<span <span
className={`text-xs truncate ${ className={`text-xs truncate ${
g.unreadCount > 0 ? 'font-medium text-gray-900' : 'text-gray-500' g.unreadCount > 0 ? 'font-semibold text-gray-900' : 'text-gray-600'
}`} }`}
> >
{g.subject} {g.subject}
@ -192,10 +192,10 @@ function SessionGroup({
</div> </div>
<div className="flex items-center gap-2 mt-0.5 pl-5"> <div className="flex items-center gap-2 mt-0.5 pl-5">
<span className="text-[10px] text-blue-500 font-mono truncate"> <span className="text-3xs text-blue-600 font-mono truncate">
{g.alias ? `.${g.alias}` : '(未命名会话)'} {g.alias ? `.${g.alias}` : '(未命名会话)'}
</span> </span>
<span className="text-[10px] text-gray-400 shrink-0">{g.mails.length} </span> <span className="text-3xs text-gray-500 shrink-0">{g.mails.length} </span>
</div> </div>
</button> </button>
@ -276,14 +276,14 @@ function MailItem({
</> </>
)} )}
</span> </span>
<span className="text-[10px] text-gray-400 shrink-0">{time}</span> <span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div> </div>
{!compact && ( {!compact && (
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-0.5">
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />} {isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
{isPermission && ( {isPermission && (
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium"> <span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs font-medium">
<ShieldIcon className="w-2.5 h-2.5" /> <ShieldIcon className="w-2.5 h-2.5" />
</span> </span>
@ -303,7 +303,7 @@ function MailItem({
发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */} 发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */}
{compact && isPermission && ( {compact && isPermission && (
<span <span
className={`inline-flex items-center gap-0.5 text-[10px] ${ className={`inline-flex items-center gap-0.5 text-3xs ${
mail.permission_result ? 'text-gray-400' : 'text-orange-600 font-medium' mail.permission_result ? 'text-gray-400' : 'text-orange-600 font-medium'
}`} }`}
> >
@ -312,11 +312,11 @@ function MailItem({
</span> </span>
)} )}
{!compact && mail.session_alias && ( {!compact && mail.session_alias && (
<span className="min-w-0 flex-1 truncate text-[10px] text-blue-600 font-mono">.{mail.session_alias}</span> <span className="min-w-0 flex-1 truncate text-3xs text-blue-600 font-mono">.{mail.session_alias}</span>
)} )}
{ccCount > 0 && <span className="text-[10px] text-gray-400"> {ccCount}</span>} {ccCount > 0 && <span className="text-3xs text-gray-500"> {ccCount}</span>}
{attachCount > 0 && ( {attachCount > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400"> <span className="inline-flex items-center gap-0.5 text-3xs text-gray-500">
<PaperclipIcon className="w-2.5 h-2.5" /> <PaperclipIcon className="w-2.5 h-2.5" />
{attachCount} {attachCount}
</span> </span>

View File

@ -175,7 +175,7 @@ function PermissionEditor() {
setEditing(false); setEditing(false);
}} }}
title={o.desc} title={o.desc}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${ className={`px-1.5 py-0.5 rounded text-3xs font-medium border transition-colors disabled:opacity-40 ${
mode === o.value mode === o.value
? 'border-blue-500 bg-blue-50 text-blue-700' ? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
@ -184,7 +184,7 @@ function PermissionEditor() {
{o.label} {o.label}
</button> </button>
))} ))}
<button onClick={() => setEditing(false)} className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5"> <button onClick={() => setEditing(false)} className="text-3xs text-gray-400 hover:text-gray-700 ml-0.5">
× ×
</button> </button>
</div> </div>
@ -226,7 +226,7 @@ function BudgetEditor() {
<button <button
onClick={open} onClick={open}
title="本任务的往返预算Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)" title="本任务的往返预算Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${ className={`inline-flex items-center gap-1 text-2xs px-2 py-0.5 rounded border transition-colors ${
exhausted exhausted
? 'border-red-200 bg-red-50 text-red-700' ? 'border-red-200 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600' : 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
@ -244,7 +244,7 @@ function BudgetEditor() {
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500"></span> <span className="text-2xs text-gray-500"></span>
<input <input
value={draft} value={draft}
onChange={e => setDraft(e.target.value)} onChange={e => setDraft(e.target.value)}
@ -258,7 +258,7 @@ function BudgetEditor() {
<button <button
disabled={busy || invalid} disabled={busy || invalid}
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })} onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40" className="text-2xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
> >
</button> </button>
@ -266,13 +266,13 @@ function BudgetEditor() {
disabled={busy || budget.used_rounds === 0} disabled={busy || budget.used_rounds === 0}
onClick={() => commit({ reset: true })} onClick={() => commit({ reset: true })}
title="已用次数归零,上限不变" title="已用次数归零,上限不变"
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30" className="text-2xs text-gray-500 hover:text-gray-900 disabled:opacity-30"
> >
</button> </button>
<button <button
onClick={() => setEditing(false)} onClick={() => setEditing(false)}
className="text-[11px] text-gray-400 hover:text-gray-700" className="text-2xs text-gray-400 hover:text-gray-700"
> >
</button> </button>
@ -308,9 +308,9 @@ function RenameProposalBar() {
<span className="font-mono font-semibold">.{proposal.alias}</span> <span className="font-mono font-semibold">.{proposal.alias}</span>
</p> </p>
{proposal.reason && ( {proposal.reason && (
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p> <p className="text-2xs text-blue-700 mt-0.5">{proposal.reason}</p>
)} )}
<p className="text-[10px] text-blue-500 mt-0.5"> <p className="text-3xs text-blue-700 mt-0.5">
name@path.{proposal.alias} name@path.{proposal.alias}
Agent Agent
</p> </p>
@ -376,13 +376,13 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2"> <div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" /> <ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600"> <span className="text-2xs font-medium text-gray-600">
{mail.subject} {mail.subject}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={() => setCcOpen(o => !o)} onClick={() => setCcOpen(o => !o)}
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`} className={`tap text-3xs ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
> >
{ccOpen ? '收起抄送' : '抄送'} {ccOpen ? '收起抄送' : '抄送'}
</button> </button>
@ -468,19 +468,19 @@ function Header({
<BackButton /> <BackButton />
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2> <h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
{mail.status === 'unread' && ( {mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium"> <span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs font-medium">
</span> </span>
)} )}
{mail.mail_type === 'permission_request' && ( {mail.mail_type === 'permission_request' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium"> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs font-medium">
<ShieldIcon className="w-3 h-3" /> <ShieldIcon className="w-3 h-3" />
</span> </span>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{mail.status === 'unread' && ( {mail.status === 'unread' && (
<button onClick={onRead} className="tap text-xs text-blue-500 hover:underline"> <button onClick={onRead} className="tap text-xs text-blue-600 hover:underline">
</button> </button>
)} )}
@ -553,20 +553,20 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
都渲染成 human 就分不清谁说的话 */} 都渲染成 human 就分不清谁说的话 */}
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span> <span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
{isPermission && ( {isPermission && (
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium"> <span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-3xs font-medium">
</span> </span>
)} )}
{mail.cc_list?.length > 0 && ( {mail.cc_list?.length > 0 && (
<span <span
className="text-[10px] text-gray-400" className="text-3xs text-gray-400"
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')} title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
> >
{mail.cc_list.length} {mail.cc_list.length}
</span> </span>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<span className="text-[10px] text-gray-400">{time}</span> <span className="text-3xs text-gray-400">{time}</span>
{onForward && ( {onForward && (
<button <button
onClick={onForward} onClick={onForward}
@ -628,7 +628,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
*/ */
const staleBanner = const staleBanner =
staleWarning || expiredBeforeDecide ? ( staleWarning || expiredBeforeDecide ? (
<p className="mt-2 text-[11px] leading-relaxed text-amber-800 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-1.5"> <p className="mt-2 text-2xs leading-relaxed text-amber-800 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-1.5">
{staleWarning || {staleWarning ||
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'} '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'}
</p> </p>
@ -678,7 +678,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return ( return (
<div className="mt-3 pt-3 border-t border-orange-200"> <div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner} {staleBanner}
<div className="text-[11px] text-gray-500 mb-2"> <div className="text-2xs text-gray-500 mb-2">
{options.length === 0 {options.length === 0
? '这题没有预设选项,请直接填写回答:' ? '这题没有预设选项,请直接填写回答:'
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'} : multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
@ -725,7 +725,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
</button> </button>
{blank && ( {blank && (
<span className="text-[11px] text-gray-400"></span> <span className="text-2xs text-gray-400"></span>
)} )}
</div> </div>
</div> </div>
@ -852,19 +852,19 @@ function ReplyBar({
return ( return (
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3"> <div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate"> {target}</p> <p className="text-3xs text-gray-400 font-mono min-w-0 truncate"> {target}</p>
<div className="flex-1" /> <div className="flex-1" />
{(replyTo.cc_list?.length ?? 0) > 0 && ( {(replyTo.cc_list?.length ?? 0) > 0 && (
<button <button
onClick={replyAll} onClick={replyAll}
className="tap text-[10px] text-gray-500 hover:text-blue-600" className="tap text-3xs text-gray-500 hover:text-blue-600"
> >
</button> </button>
)} )}
<button <button
onClick={() => setCcOpen(o => !o)} onClick={() => setCcOpen(o => !o)}
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`} className={`tap text-3xs ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
> >
{ccOpen ? '收起抄送' : '抄送'} {ccOpen ? '收起抄送' : '抄送'}
</button> </button>
@ -898,7 +898,7 @@ function ReplyBar({
setBudgetEditing(true); setBudgetEditing(true);
}} }}
title="本任务往返预算Agent 主动发信上限(自动转发与权限询问不占)" title="本任务往返预算Agent 主动发信上限(自动转发与权限询问不占)"
className={`tap inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded border ${ className={`tap inline-flex items-center gap-1 text-3xs px-2 py-1 rounded border ${
budget.unlimited budget.unlimited
? 'border-gray-200 text-gray-500 hover:border-blue-300' ? 'border-gray-200 text-gray-500 hover:border-blue-300'
: budget.remaining === 0 : budget.remaining === 0
@ -914,7 +914,7 @@ function ReplyBar({
)} )}
{budget && budgetEditing && ( {budget && budgetEditing && (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<span className="text-[10px] text-gray-500"></span> <span className="text-3xs text-gray-500"></span>
<input <input
value={maxRoundsDraft} value={maxRoundsDraft}
onChange={e => setMaxRoundsDraft(e.target.value)} onChange={e => setMaxRoundsDraft(e.target.value)}
@ -931,13 +931,13 @@ function ReplyBar({
setBudgetBusy(false); setBudgetBusy(false);
setBudgetEditing(false); setBudgetEditing(false);
}} }}
className="tap px-2 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40" className="tap px-2 py-0.5 text-3xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
> >
</button> </button>
<button <button
onClick={() => setBudgetEditing(false)} onClick={() => setBudgetEditing(false)}
className="tap text-[10px] text-gray-400 hover:text-gray-700" className="tap text-3xs text-gray-400 hover:text-gray-700"
> >
× ×
</button> </button>
@ -972,5 +972,5 @@ function StatusBadge({ status }: { status: string }) {
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' } archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
}; };
const b = map[status] || map.active; const b = map[status] || map.active;
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>; return <span className={`text-3xs px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
} }

View File

@ -50,7 +50,7 @@ function PanelHeader({ count }: { count: number }) {
<h3 className="text-sm font-semibold text-gray-900">Agent </h3> <h3 className="text-sm font-semibold text-gray-900">Agent </h3>
{count > 0 && <span className="text-xs text-gray-400">{count}</span>} {count > 0 && <span className="text-xs text-gray-400">{count}</span>}
</div> </div>
<p className="text-[11px] text-gray-500"> <p className="text-2xs text-gray-500">
<br /> <br />
@ -152,12 +152,12 @@ function AgentModelRow({
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
/> />
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">{agentName}</span> <span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">{agentName}</span>
<span className="min-w-0 flex-1 text-[11px] text-gray-500"> <span className="min-w-0 flex-1 text-2xs text-gray-500">
{saved.length === 0 ? '不限定(用平台默认模型)' : `${saved.length} 个模型,按序尝试`} {saved.length === 0 ? '不限定(用平台默认模型)' : `${saved.length} 个模型,按序尝试`}
</span> </span>
{staleKeys.length > 0 && ( {staleKeys.length > 0 && (
<span <span
className="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[9px]" className="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-3xs"
title="已选但平台当前没有上报这些模型" title="已选但平台当前没有上报这些模型"
> >
{staleKeys.length} {staleKeys.length}
@ -176,7 +176,7 @@ function AgentModelRow({
{err && <p className="text-xs text-red-600 pt-3">{err}</p>} {err && <p className="text-xs text-red-600 pt-3">{err}</p>}
{!loading && catalog.length === 0 && staleKeys.length === 0 && ( {!loading && catalog.length === 0 && staleKeys.length === 0 && (
<p className="text-[11px] text-gray-500 pt-3"> <p className="text-2xs text-gray-500 pt-3">
30 30
provider provider
</p> </p>
@ -184,7 +184,7 @@ function AgentModelRow({
{picks.length > 0 && ( {picks.length > 0 && (
<div className="pt-3"> <div className="pt-3">
<p className="text-[10px] font-medium text-gray-500 mb-1.5"> <p className="text-3xs font-medium text-gray-500 mb-1.5">
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
@ -198,11 +198,11 @@ function AgentModelRow({
isStale ? 'border-amber-200' : 'border-gray-200' isStale ? 'border-amber-200' : 'border-gray-200'
}`} }`}
> >
<span className="w-4 text-[10px] text-gray-400 shrink-0">{i + 1}</span> <span className="w-4 text-3xs text-gray-400 shrink-0">{i + 1}</span>
<span className="text-xs font-mono text-gray-800 truncate">{key}</span> <span className="text-xs font-mono text-gray-800 truncate">{key}</span>
{isStale && ( {isStale && (
<span <span
className="shrink-0 text-[9px] text-amber-700" className="shrink-0 text-3xs text-amber-700"
title="平台当前没有上报这个模型,可能已下线" title="平台当前没有上报这个模型,可能已下线"
> >
@ -241,7 +241,7 @@ function AgentModelRow({
{catalog.length > 0 && ( {catalog.length > 0 && (
<div> <div>
<p className="text-[10px] font-medium text-gray-500 mb-1.5"> <p className="text-3xs font-medium text-gray-500 mb-1.5">
{catalog.length} {catalog.length}
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -253,7 +253,7 @@ function AgentModelRow({
key={key} key={key}
onClick={() => toggle(key)} onClick={() => toggle(key)}
title={m.display_name || key} title={m.display_name || key}
className={`tap px-2 py-1 text-[11px] font-mono rounded border transition-colors ${ className={`tap px-2 py-1 text-2xs font-mono rounded border transition-colors ${
on on
? 'bg-blue-50 border-blue-300 text-blue-700' ? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
@ -270,7 +270,7 @@ function AgentModelRow({
{(catalog.length > 0 || picks.length > 0) && ( {(catalog.length > 0 || picks.length > 0) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{picks.length === 0 && ( {picks.length === 0 && (
<span className="text-[10px] text-gray-400"> <span className="text-3xs text-gray-400">
= =
</span> </span>
)} )}
@ -278,7 +278,7 @@ function AgentModelRow({
{dirty && ( {dirty && (
<button <button
onClick={() => setPicks(saved)} onClick={() => setPicks(saved)}
className="tap text-[11px] text-gray-500 hover:text-gray-900" className="tap text-2xs text-gray-500 hover:text-gray-900"
> >
</button> </button>
@ -286,7 +286,7 @@ function AgentModelRow({
<button <button
onClick={save} onClick={save}
disabled={!dirty || saving} disabled={!dirty || saving}
className="tap inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40" className="tap inline-flex items-center gap-1 px-3 py-1 text-2xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
> >
{saving ? ( {saving ? (
<SpinnerIcon className="w-3 h-3 animate-spin" /> <SpinnerIcon className="w-3 h-3 animate-spin" />

View File

@ -85,10 +85,10 @@ export default function NarrowNav() {
}`} }`}
> >
<Icon /> <Icon />
<span className="text-[10px] leading-none">{short}</span> <span className="text-3xs leading-none">{short}</span>
{badge > 0 && ( {badge > 0 && (
<span <span
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${ className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${
mode === 'inbox' mode === 'inbox'
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: mode === 'permissions' : mode === 'permissions'
@ -111,7 +111,7 @@ export default function NarrowNav() {
}`} }`}
> >
<ComposeIcon /> <ComposeIcon />
<span className="text-[10px] leading-none"></span> <span className="text-3xs leading-none"></span>
</button> </button>
<button <button
@ -128,7 +128,7 @@ export default function NarrowNav() {
<ConnectionIndicator /> <ConnectionIndicator />
</span> </span>
</div> </div>
<span className="text-[10px] leading-none"></span> <span className="text-3xs leading-none"></span>
</button> </button>
</nav> </nav>
); );

View File

@ -74,7 +74,7 @@ export default function NarrowStack({
{mounted && ( {mounted && (
<div <div
className={`absolute inset-0 z-10 flex bg-white border-l border-gray-200 shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${ className={`absolute inset-0 z-10 flex bg-white shadow-3 transition-transform duration-200 ease-out motion-reduce:transition-none ${
entered ? 'translate-x-0' : 'translate-x-full' entered ? 'translate-x-0' : 'translate-x-full'
}`} }`}
> >

View File

@ -87,7 +87,7 @@ export default function PermissionChip({ mode, enforcement, compact }: Permissio
<span <span
title={`${hint}(强制力:${ENFORCEMENT_LABEL[e]}`} title={`${hint}(强制力:${ENFORCEMENT_LABEL[e]}`}
className={`inline-flex items-center gap-1 rounded border font-medium ${color} ${ className={`inline-flex items-center gap-1 rounded border font-medium ${color} ${
compact ? 'px-1 text-[10px]' : 'px-1.5 text-xs' compact ? 'px-1 text-3xs' : 'px-1.5 text-xs'
}`} }`}
> >
{label} {label}

View File

@ -58,7 +58,7 @@ export default function PermissionList() {
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1"> <div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
<h2 className="text-sm font-semibold text-gray-800"></h2> <h2 className="text-sm font-semibold text-gray-800"></h2>
{pendingTotal > 0 ? ( {pendingTotal > 0 ? (
<span className="ml-2 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700 text-[10px] font-semibold"> <span className="ml-2 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700 text-3xs font-semibold">
<ShieldIcon className="w-2.5 h-2.5" /> <ShieldIcon className="w-2.5 h-2.5" />
{pendingTotal} {pendingTotal}
</span> </span>
@ -85,7 +85,7 @@ export default function PermissionList() {
<div className="text-center py-10"> <div className="text-center py-10">
<ShieldIcon className="w-8 h-8 mx-auto text-gray-300" /> <ShieldIcon className="w-8 h-8 mx-auto text-gray-300" />
<p className="text-xs text-gray-400 mt-2"></p> <p className="text-xs text-gray-400 mt-2"></p>
<p className="text-[10px] text-gray-400 mt-1 px-6"> <p className="text-3xs text-gray-500 mt-1 px-6">
Agent bash Agent bash
</p> </p>
</div> </div>
@ -135,28 +135,28 @@ function PermissionSessionGroup({
<span className="text-xs font-mono text-gray-900 truncate"> <span className="text-xs font-mono text-gray-900 truncate">
{g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''} {g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
</span> </span>
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{time}</span> <span className="ml-auto text-3xs text-gray-500 shrink-0">{time}</span>
</div> </div>
<div className="flex items-center gap-1.5 mt-1 pl-5"> <div className="flex items-center gap-1.5 mt-1 pl-5">
{hasPending ? ( {hasPending ? (
<span className="shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-orange-700 text-white text-[9px] font-bold"> <span className="shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-orange-700 text-white text-3xs font-bold">
<ShieldIcon className="w-2.5 h-2.5" /> <ShieldIcon className="w-2.5 h-2.5" />
{g.pending.length} {g.pending.length}
</span> </span>
) : ( ) : (
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"> <span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-3xs">
<CheckIcon className="w-2.5 h-2.5" /> <CheckIcon className="w-2.5 h-2.5" />
</span> </span>
)} )}
{g.settled.length > 0 && ( {g.settled.length > 0 && (
<span className="text-[10px] text-gray-400"> {g.settled.length}</span> <span className="text-3xs text-gray-500"> {g.settled.length}</span>
)} )}
</div> </div>
{!g.alias && ( {!g.alias && (
<p className="text-[10px] text-gray-400 font-mono truncate mt-0.5 pl-5"> <p className="text-3xs text-gray-500 font-mono truncate mt-0.5 pl-5">
() ()
</p> </p>
)} )}
@ -177,7 +177,7 @@ function PermissionSessionGroup({
<> <>
<button <button
onClick={() => setShowSettled(v => !v)} onClick={() => setShowSettled(v => !v)}
className="w-full text-left px-2 py-1 text-[10px] text-gray-400 hover:text-gray-600" className="w-full text-left px-2 py-1 text-3xs text-gray-500 hover:text-gray-600"
> >
{showSettled ? '收起' : '展开'} {g.settled.length} {showSettled ? '收起' : '展开'} {g.settled.length}
</button> </button>
@ -248,12 +248,12 @@ function PermissionRow({
> >
{mail.subject} {mail.subject}
</span> </span>
<span className="text-[10px] text-gray-400 shrink-0">{time}</span> <span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div> </div>
<div className="flex items-center gap-1 mt-0.5"> <div className="flex items-center gap-1 mt-0.5">
{settled ? ( {settled ? (
<span <span
className={`inline-flex items-center gap-0.5 text-[10px] ${ className={`inline-flex items-center gap-0.5 text-3xs ${
approved ? 'text-green-600' : 'text-red-500' approved ? 'text-green-600' : 'text-red-500'
}`} }`}
> >
@ -262,7 +262,7 @@ function PermissionRow({
</span> </span>
) : expired ? ( ) : expired ? (
<span <span
className="inline-flex items-center gap-0.5 text-[10px] text-gray-500" className="inline-flex items-center gap-0.5 text-3xs text-gray-500"
title={ title={
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' + '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' +
'现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。' '现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'
@ -272,7 +272,7 @@ function PermissionRow({
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-0.5 text-[10px] text-orange-600 font-medium"> <span className="inline-flex items-center gap-0.5 text-3xs text-orange-600 font-medium">
<ShieldIcon className="w-2.5 h-2.5" /> <ShieldIcon className="w-2.5 h-2.5" />
</span> </span>

View File

@ -103,7 +103,7 @@ export default function QuotaPanel() {
? 'bg-red-50 text-red-600' ? 'bg-red-50 text-red-600'
: 'bg-gray-50 text-gray-500'; : 'bg-gray-50 text-gray-500';
const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线'; const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线';
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${cls}`}>{label}</span>; return <span className={`text-3xs px-1.5 py-0.5 rounded ${cls}`}>{label}</span>;
}; };
return ( return (
@ -114,7 +114,7 @@ export default function QuotaPanel() {
<span className="text-xs text-gray-400">{stats.length}</span> <span className="text-xs text-gray-400">{stats.length}</span>
</div> </div>
<p className="text-[11px] text-gray-500"> <p className="text-2xs text-gray-500">
<strong className="font-medium text-gray-600"></strong> Agent 0 = <strong className="font-medium text-gray-600"></strong> Agent 0 =
@ -127,8 +127,8 @@ export default function QuotaPanel() {
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
</p> </p>
{error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>} {error && <div className="text-2xs text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
{notice && <div className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>} {notice && <div className="text-2xs text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>}
{stats.length === 0 ? ( {stats.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div> <div className="text-xs text-gray-400 py-3"> Agent</div>
@ -149,7 +149,7 @@ export default function QuotaPanel() {
{statusBadge(s)} {statusBadge(s)}
<div className="min-w-0 flex-1 text-[11px] text-gray-500"> <div className="min-w-0 flex-1 text-2xs text-gray-500">
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`} {s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
<span className="text-gray-400"> <span className="text-gray-400">
{' · '} {s.active_sessions} · {s.sent_total} {' · '} {s.active_sessions} · {s.sent_total}
@ -177,19 +177,19 @@ export default function QuotaPanel() {
{/* 停用/恢复/删除 按钮 */} {/* 停用/恢复/删除 按钮 */}
{isConfirming ? ( {isConfirming ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] text-gray-500">{confirmAction === 'delete' ? '删除' : ''}</span> <span className="text-3xs text-gray-500">{confirmAction === 'delete' ? '删除' : ''}</span>
<button <button
onClick={() => confirmAction === 'delete' onClick={() => confirmAction === 'delete'
? deleteAgent(s.agent_name) ? deleteAgent(s.agent_name)
: toggleStatus(s.agent_name, isDisabled)} : toggleStatus(s.agent_name, isDisabled)}
disabled={busy === s.agent_name} disabled={busy === s.agent_name}
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100" className="tap text-3xs px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
> >
{confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')} {confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')}
</button> </button>
<button <button
onClick={() => { setConfirming(null); setConfirmAction(null); }} onClick={() => { setConfirming(null); setConfirmAction(null); }}
className="tap text-[10px] text-gray-400 hover:text-gray-600" className="tap text-3xs text-gray-400 hover:text-gray-600"
> >
</button> </button>
@ -202,7 +202,7 @@ export default function QuotaPanel() {
title={isDisabled title={isDisabled
? '恢复此 Agent恢复为离线停用时撤销的密钥不会自动回来必须重新签发' ? '恢复此 Agent恢复为离线停用时撤销的密钥不会自动回来必须重新签发'
: '停用此 Agent撤销全部密钥并从补全里隐藏邮件与会话保留可恢复'} : '停用此 Agent撤销全部密钥并从补全里隐藏邮件与会话保留可恢复'}
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${ className={`tap shrink-0 inline-flex items-center gap-1 text-2xs px-1.5 py-0.5 rounded border ${
isDisabled isDisabled
? 'border-green-200 text-green-700 hover:bg-green-50' ? 'border-green-200 text-green-700 hover:bg-green-50'
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200' : 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
@ -215,7 +215,7 @@ export default function QuotaPanel() {
onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }} onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }}
disabled={busy === s.agent_name} disabled={busy === s.agent_name}
title="彻底删除此 Agent清除密钥与运行态邮件保留但此名今后不可再用" title="彻底删除此 Agent清除密钥与运行态邮件保留但此名今后不可再用"
className="tap shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30" className="tap shrink-0 text-3xs px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30"
> >
</button> </button>

View File

@ -53,7 +53,7 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
// auto margin 在空间不足时自动退化为 0于是矮屏变成正常的顶对齐可滚布局。 // auto margin 在空间不足时自动退化为 0于是矮屏变成正常的顶对齐可滚布局。
return ( return (
<div className="h-full overflow-y-auto flex justify-center bg-slate-100"> <div className="h-full overflow-y-auto flex justify-center bg-slate-100">
<div className="w-[420px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8"> <div className="w-[420px] max-w-[92vw] shrink-0 my-auto bg-white rounded-2xl shadow-2 p-6 sm:p-8">
<div className="flex flex-col items-center mb-6"> <div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<BrandMarkIcon className="w-6 h-6" /> <BrandMarkIcon className="w-6 h-6" />
@ -66,7 +66,7 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
<form onSubmit={submit} className="space-y-3"> <form onSubmit={submit} className="space-y-3">
<div> <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">
<span className="text-red-400"> name </span> <span className="text-red-400"> name </span>
</label> </label>
<input <input
@ -77,13 +77,13 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
spellCheck={false} spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/> />
<p className="mt-1 text-[10px] text-gray-400"> <p className="mt-1 text-3xs text-gray-400">
. _ -2-64 `admin@.new` . _ -2-64 `admin@.new`
</p> </p>
</div> </div>
<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>
<input <input
value={displayName} value={displayName}
onChange={e => setDisplayName(e.target.value)} onChange={e => setDisplayName(e.target.value)}
@ -93,7 +93,7 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
</div> </div>
<div> <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">
<span className="text-red-400"> 8 </span> <span className="text-red-400"> 8 </span>
</label> </label>
<input <input
@ -106,7 +106,7 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
</div> </div>
<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>
<input <input
type="password" type="password"
value={confirm} value={confirm}
@ -116,7 +116,7 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400' mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
}`} }`}
/> />
{mismatch && <p className="mt-1 text-[10px] text-red-500"></p>} {mismatch && <p className="mt-1 text-3xs text-red-500"></p>}
</div> </div>
{error && ( {error && (

View File

@ -80,10 +80,10 @@ export default function Sidebar() {
}`} }`}
> >
<Icon /> <Icon />
<span className="text-[9px] leading-none">{short}</span> <span className="text-3xs leading-none">{short}</span>
{badge > 0 && ( {badge > 0 && (
<span <span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${ className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${
mode === 'inbox' mode === 'inbox'
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: mode === 'permissions' : mode === 'permissions'
@ -110,14 +110,14 @@ export default function Sidebar() {
}`} }`}
> >
<ComposeIcon /> <ComposeIcon />
<span className="text-[9px] leading-none"></span> <span className="text-3xs leading-none"></span>
</button> </button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700"> <div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700">
<button <button
onClick={() => setViewMode('account')} onClick={() => setViewMode('account')}
title={`${user?.display_name || user?.username}(点击管理账号)`} title={`${user?.display_name || user?.username}(点击管理账号)`}
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${ className={`relative w-9 h-9 rounded-full flex items-center justify-center text-2xs font-semibold transition-colors ${
viewMode === 'account' && !composing viewMode === 'account' && !composing
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600' : 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'

View File

@ -84,7 +84,7 @@ export default function ThemePicker() {
title={o.hint} title={o.hint}
className={`px-3 py-1.5 rounded-md text-xs inline-flex items-center gap-1.5 transition-colors ${ className={`px-3 py-1.5 rounded-md text-xs inline-flex items-center gap-1.5 transition-colors ${
active active
? 'bg-white text-gray-900 shadow-sm border border-gray-200' ? 'bg-white text-gray-900 shadow-1'
: 'text-gray-600 hover:text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`} }`}
> >

View File

@ -249,49 +249,49 @@ function Node({
node.from_human ? '' : node.session_workspace || '' node.from_human ? '' : node.session_workspace || ''
)} )}
</span> </span>
<span className="text-[10px] text-gray-400"></span> <span className="text-3xs text-gray-500"></span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span> <span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" /> <div className="flex-1" />
{isForward && ( {isForward && (
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]"> <span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs">
</span> </span>
)} )}
{node.parent_hidden && ( {node.parent_hidden && (
<span <span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]" className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-3xs"
title="上一封不在你的可见范围内" title="上一封不在你的可见范围内"
> >
</span> </span>
)} )}
{isPermission && ( {isPermission && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]"> <span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs">
<ShieldIcon className="w-2.5 h-2.5" /> <ShieldIcon className="w-2.5 h-2.5" />
</span> </span>
)} )}
{node.attachment_count > 0 && ( {node.attachment_count > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400"> <span className="inline-flex items-center gap-0.5 text-3xs text-gray-500">
<PaperclipIcon className="w-2.5 h-2.5" /> <PaperclipIcon className="w-2.5 h-2.5" />
{node.attachment_count} {node.attachment_count}
</span> </span>
)} )}
<span className="text-[10px] text-gray-400 shrink-0">{time}</span> <span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div> </div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p> <p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。 {/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */} 不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
{ccCount > 0 && ( {ccCount > 0 && (
<p className="text-[10px] text-gray-400 mt-0.5 truncate"> <p className="text-3xs text-gray-500 mt-0.5 truncate">
{node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')} {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
</p> </p>
)} )}
{node.body_preview && ( {node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p> <p className="text-2xs text-gray-500 mt-0.5 line-clamp-2">{node.body_preview}</p>
)} )}
{node.session_alias && ( {node.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span> <span className="text-3xs text-blue-600 font-mono">.{node.session_alias}</span>
)} )}
</button> </button>
</div> </div>

View File

@ -53,9 +53,9 @@ export function WorkCard({
<button onClick={onOpen} className="flex-1 text-left p-3 min-w-0"> <button onClick={onOpen} className="flex-1 text-left p-3 min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span> <span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span> <span className="text-3xs text-gray-500 font-mono truncate">{c.path}</span>
{c.unread_count > 0 && ( {c.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center"> <span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-3xs font-bold flex items-center justify-center">
{c.unread_count} {c.unread_count}
</span> </span>
)} )}
@ -63,7 +63,7 @@ export function WorkCard({
<div className="flex items-center gap-1 mt-0.5"> <div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" /> <ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate"> <span className="text-2xs text-blue-600 font-mono truncate">
{c.session_alias || '(未命名会话)'} {c.session_alias || '(未命名会话)'}
</span> </span>
</div> </div>
@ -80,14 +80,14 @@ export function WorkCard({
) : ( ) : (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" /> <BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
)} )}
<p className="text-[11px] text-gray-500 line-clamp-2 leading-snug"> <p className="text-2xs text-gray-500 line-clamp-2 leading-snug">
{c.last_preview} {c.last_preview}
</p> </p>
</div> </div>
)} )}
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className="text-[10px] text-gray-400"> <span className="text-3xs text-gray-500">
{c.mail_count} · {time} {c.mail_count} · {time}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
@ -100,7 +100,7 @@ export function WorkCard({
<button <button
onClick={onCompose} onClick={onCompose}
title="写信给该地址" title="写信给该地址"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50" className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-3xs text-gray-600 hover:bg-gray-50"
> >
<ComposeIcon className="w-3 h-3" /> <ComposeIcon className="w-3 h-3" />
@ -108,7 +108,7 @@ export function WorkCard({
<button <button
onClick={onArchive} onClick={onArchive}
title="归档该 name@path.session" title="归档该 name@path.session"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300" className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-3xs text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
> >
<ArchiveIcon className="w-3 h-3" /> <ArchiveIcon className="w-3 h-3" />
@ -140,7 +140,7 @@ export function BudgetChip({ max, used }: { max: number; used: number }) {
return ( return (
<span <span
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium shrink-0 ${tone}`} className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-3xs font-medium shrink-0 ${tone}`}
title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`} title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`}
> >
<GaugeIcon className="w-2.5 h-2.5" /> <GaugeIcon className="w-2.5 h-2.5" />

View File

@ -38,11 +38,27 @@
--c-gray-50: 249 250 251; --c-gray-50: 249 250 251;
--c-gray-100: 243 244 246; --c-gray-100: 243 244 246;
--c-gray-200: 229 231 235; /*
* 分隔线刻意比「正牌 gray-200」再淡一档。
*
* 全站 67 处 `border-*-gray-200` 是「盒子感」的主要来源:硬线把界面切成
* 一块块,看着像 2015 年的后台。现代做法是让**留白与表面色差**承担分组,
* 线条只留一丝提示。改在令牌上而不是逐个类名67 处(含 8 处作为底色的
* 进度条/骨架)一次性生效,且不会漏。
*/
--c-gray-200: 234 236 241;
--c-gray-300: 209 213 219; --c-gray-300: 209 213 219;
/* 最小字号与 placeholder 也会使用这一档;在白底上保持至少 4.5:1。 */ /* 最小字号与 placeholder 也会使用这一档;在白底上保持至少 4.5:1。 */
--c-gray-400: 107 114 128; --c-gray-400: 107 114 128;
--c-gray-500: 107 114 128; /*
* gray-500 必须比 gray-400 **深一档**。
*
* 原来两者都是 107 114 128完全相同—— 调色板里因而不存在「比次要文字
* 再深一点的中间色」,于是时间戳这类要压在高亮行淡蓝底上的文字没得选,
* 只能停在 gray-400实测在 bg-blue-50 上只有 4.44:1低于 AA 4.5
* (真实渲染量得)。拉开一档后为 5.65:1。
*/
--c-gray-500: 90 98 112;
--c-gray-600: 75 85 99; --c-gray-600: 75 85 99;
--c-gray-700: 55 65 81; --c-gray-700: 55 65 81;
--c-gray-800: 31 41 55; --c-gray-800: 31 41 55;
@ -241,6 +257,11 @@
--shadow-1: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.1); --shadow-1: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.1);
--shadow-2: 0 2px 4px rgb(15 23 42 / 0.05), 0 4px 12px rgb(15 23 42 / 0.1); --shadow-2: 0 2px 4px rgb(15 23 42 / 0.05), 0 4px 12px rgb(15 23 42 / 0.1);
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12), 0 2px 6px rgb(15 23 42 / 0.08); --shadow-3: 0 8px 24px rgb(15 23 42 / 0.12), 0 2px 6px rgb(15 23 42 / 0.08);
/*
* 侧边面板专用:横向偏移 + 大扩散,模拟「两块面板叠在一起」而不是「被线分开」。
* 竖向几乎不偏移,否则整列会看起来浮在半空(面板是全高的)。
*/
--shadow-panel: 1px 0 2px rgb(15 23 42 / 0.04), 4px 0 16px -4px rgb(15 23 42 / 0.08);
--hairline: 0 0 0; --hairline: 0 0 0;
color-scheme: light; color-scheme: light;
} }
@ -283,7 +304,7 @@
--c-gray-50: 17 19 24; --c-gray-50: 17 19 24;
--c-gray-100: 32 36 44; --c-gray-100: 32 36 44;
--c-gray-200: 44 49 59; --c-gray-200: 39 43 52;
--c-gray-300: 61 68 81; --c-gray-300: 61 68 81;
--c-gray-400: 138 146 161; --c-gray-400: 138 146 161;
--c-gray-500: 165 173 186; --c-gray-500: 165 173 186;
@ -321,6 +342,11 @@
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.4); --shadow-1: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-2: 0 2px 6px rgb(0 0 0 / 0.45); --shadow-2: 0 2px 6px rgb(0 0 0 / 0.45);
--shadow-3: 0 10px 30px rgb(0 0 0 / 0.55); --shadow-3: 0 10px 30px rgb(0 0 0 / 0.55);
/*
* 深色下层次比浅色更难感知,所以面板阴影要更实一些;
* 但仍以「方向性」为主(横偏移),避免整列看起来悬空。
*/
--shadow-panel: 1px 0 2px rgb(0 0 0 / 0.35), 4px 0 16px -4px rgb(0 0 0 / 0.5);
--hairline: 0 0 0 1px rgb(255 255 255 / 0.06); --hairline: 0 0 0 1px rgb(255 255 255 / 0.06);
/* /*

View File

@ -94,6 +94,51 @@ export default {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {
/**
* 层次elevation
*
* index.css 里一直定义着 `--shadow-1/2/3` 三个语义令牌,但**没有任何地方用过**
* —— 全站只有 10 处阴影,而且都是 Tailwind 的系统默认档shadow / shadow-sm /
* shadow-2xl。同时界面用 67 条 `border-*-gray-200` 硬线来分组。
*
* 结果就是整个应用看起来是平的:弹窗、下拉、抽屉飘不起来。
* 这里把已有的令牌接出来,让层次成为可用且统一的表达手段(
* 也是深色模式下唯一有效的分组方式 —— 深色里靠边框分组几乎看不见)。
*/
boxShadow: {
1: 'var(--shadow-1)',
2: 'var(--shadow-2)',
3: 'var(--shadow-3)',
panel: 'var(--shadow-panel)'
},
/**
* 字号体系。
*
* # 为什么要重定而不是用默认值
*
* 默认档位xs=12px在这套界面上被当**正文**用了 172 处,
* 另有一批 text-[9px]/[10px]/[11px] 的魔法数字(共 171 处)——
* 最小只有 9px在现在的显示器上基本读不动。整个界面看上去老旧
* 首先就是因为**字太小**。
*
* 这里的做法是把比例尺整体抬一档,并给两个小档位起名字:
* - `2xs` / `3xs` 取代 11px / 9-10px 的魔法数字(不再出现裸 px
* - `xs` 12px → 13px原本拿它当正文的地方自动变舒适
* - `sm` 14px 保持不变(现在它承担小标题与主要正文)
*
* 行高一起给:字号变大而不放行高会把密排的列表顶得很难看。
* 小档位用 1.4(紧凑但不能挤),正文档 1.55。
*/
fontSize: {
'3xs': ['0.6875rem', { lineHeight: '1.2' }], // 11px —— 角标与元信息下限
'2xs': ['0.75rem', { lineHeight: '1.35' }], // 12px —— 元信息
xs: ['0.8125rem', { lineHeight: '1.45' }], // 13px —— 次要正文
sm: ['0.875rem', { lineHeight: '1.55' }], // 14px —— 正文
base: ['0.9375rem', { lineHeight: '1.55' }], // 15px
lg: ['1.0625rem', { lineHeight: '1.5' }],
xl: ['1.25rem', { lineHeight: '1.4' }],
'2xl': ['1.5rem', { lineHeight: '1.35' }]
},
/** /**
* 圆角整体调大一档。 * 圆角整体调大一档。
* *

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);