`components/AGENTS.md` is the reference: what to use for a given need,
where a new component goes, and the style / i18n / a11y / test rules.
`desktop/AGENTS.md` now routes here — the line it replaces ("reuse the
existing desktop design system") named nothing to look up and so was not
an executable instruction for a person or a model.
`docs/component-library-plan.md` keeps the audit evidence and a record of
what actually shipped, including where the plan was wrong.
Two rules earned their own sections because the library broke them
itself:
- Overriding a component's utility with `className` does not reliably
win. Tailwind sorts same-utility arbitrary values by value and takes
the last, regardless of the order they were passed. `hoverTone="danger"`
was a no-op with exactly the two tones it was built for, and its test
passed because it only asserted the red class was *present*, never that
the neutral one was gone. Assert that a class prefix appears exactly
once, and prove it by reverting the fix.
- The library must not compose user-visible English. `SearchField` built
`Clear ${label}`, which made adopting it an i18n regression for every
caller that had already translated its clear button. The first repair —
falling back to `label` — was worse: the input and its clear button
then shared an accessible name and `getByLabelText` matched both.
Reuse went from 22% to 75% (524 component uses against 179 remaining
native buttons). The remainder are elements that should not be
components: `role="tab"`, `menuitem`, `option`, `treeitem`, `gridcell`,
whole-row and whole-card click targets, drag handles, and the OS
titlebar.
42 KiB
桌面端组件库:抽取与迁移计划
本文是 desktop/src/components/AGENTS.md 的配套执行文档,记录审计证据、待抽组件的 API 草稿和分批落地顺序。
审计范围:desktop/src/**(492 个 ts/tsx,其中 tsx 235 个)。方法:5 个并行只读 agent 分域扫描(设计 token / 交互原语 / 浮层 / 数据展示 / 共享层与工程约束),结论经主控 agent 抽样复核。
执行状态(2026-07-26):阶段 0–8 已全部落地,
shared/与common/已删除。 落地过程中对本文的偏离与新发现记录在 附录二。改动清单以那一节为准,本文正文保留为当时的审计快照。
一、现状:问题不是"开发者偷懒"
1.1 量化
| 指标 | 数值 |
|---|---|
components/ 下组件 / 测试文件 |
117 / 67 |
原生 <button> / <Button> 实例 |
362(非测试) / 102 → 复用率 22% |
完全不知道 shared/Button 存在的文件 |
80 |
| 同一文件里两套写法混用 | 13 |
| 原生 input / textarea / select | 56 / 13 / 7 |
| 手撸浮层(弹窗/抽屉/下拉/右键菜单/popover/tooltip) | 31 |
| 手写 outside-click 逻辑 | 21 处 |
| 手写空态 | 22 处(24 个 border-dashed 壳,横跨 15 文件) |
| 手写骨架屏 | 6 个块 / 15 处 |
| pill 徽章 / 状态点 | 45 / 41 |
| 卡片容器组合(前 7 名合计) | 115 处 |
[var(--x)] 桥接 vs @theme 裸 utility |
4874 / 8 |
| 按钮 focus 写法 / disabled 写法 | 32 种 / 19 种 |
| 按钮高度 / 圆角 / 字号档位 | 18 / 21 / 21 种 |
| z-index 取值 / z token | 17 种 / 0 |
1.2 根因:没有索引,不是没有能力
三个铁证,都是同一份代码被复制而作者显然不知道已有实现:
-
shared/Button.tsx:56-63自己私有实现了一个Spinner,SVG path 与相邻的shared/Spinner.tsx逐字符相同(M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z)。而shared/Spinner.tsx被 0 个文件导入,是纯死代码 —— 连 shared 目录内部、相邻两个文件之间都在复制粘贴。 -
chat/ChatInput.tsx:408-466与pages/EmptySession.tsx:140-198是 59 行逐字节相同的复制(diff输出为空),四个 outside-click effect 一模一样。 -
chat/SessionTaskBar.tsx:65-76与teams/TeamStatusBar.tsx:62-71的进度条连{/* Progress bar */}注释都一样,只有三元里的变量名不同。
再加上目录学证据:shared/ 和 controls/ 是同一个 commit(993b96cd, 2026-04-06)建的,隐含规则从未写下;common/(2026-05-29)的 commit message 一行,没解释为何新开目录 —— 而 shared/common 本就是同义词。
结论:
desktop/AGENTS.md里那句 "Reuse the existing desktop design system" 对人和 AI 都是不可执行指令 —— 它没说去哪查、查到什么算数。这就是本次要补的东西。
1.3 现有共享层复用率
| 组件 | 生产调用方 | 行数 | 耦合 | 判定 |
|---|---|---|---|---|
shared/Button |
23 | 63 | 纯 UI | 真·公共 |
shared/ConfirmDialog |
13 | 49 | 纯 UI | 真·公共 |
shared/Modal |
10 | 119 | 纯 UI | 真·公共 |
shared/CopyButton |
8 | 61 | 纯 UI* | 真·公共 |
shared/Input |
7 | 38 | 纯 UI | 真·公共 |
shared/MobileBottomSheet |
5 | 94 | 纯 UI | 真·公共 |
shared/DirectoryPicker |
5 | 363 | api×2 | 业务件 |
shared/ActionDialog |
4 | 66 | 纯 UI | 真·公共 |
shared/Dropdown |
4 | 103 | 纯 UI | 真·公共 |
controls/ModelSelector |
3 | 658 | store×7 | 业务件 |
controls/PermissionModeSelector |
3 | 418 | store×5 | 业务件 |
shared/RepositoryLaunchControls |
2 | 664 | api | 伪公共 |
controls/ReasoningEffortPopover |
1 | 214 | 纯 UI | 伪公共(唯一调用方是同目录 ModelSelector) |
controls/AutoModeOptInDialog |
1 | 47 | — | 伪公共 |
shared/ConfirmPopover |
1 | 33 | 纯 UI | 伪公共 |
shared/ProjectContextChip |
1 | 71 | — | 伪公共 |
shared/Toast |
1 | 54 | store | 应用单例 |
shared/UpdateChecker |
1 | 74 | store | 应用单例 |
shared/Spinner |
0 | 30 | 纯 UI | 死代码 |
23 个"公共"组件里,真·公共只有 8 个;单调用方的伪公共占 35%。
* CopyButton 反向 import 了 ../chat/clipboard,是 4 处层级倒挂之一。
二、可以立刻修的缺陷
审计顺带发现的、与抽取解耦的问题。建议先修这批:改动小、风险低,且能验证提交流程。
P0 —— 影响用户可见行为
| # | 问题 | 位置 | 证据 |
|---|---|---|---|
| 1 | dark: 变体绑到了操作系统而非应用主题。globals.css 无 @custom-variant dark、无 prefers-color-scheme,13 处 dark: 全是坏的。系统浅色 + 应用 dark 时,流式重试提示条会炸出近白色块 |
chat/StreamingIndicator.tsx:68,70,72,75,78,84、features/pets/PetApp.tsx:600 |
已验证:@custom-variant 0 处、prefers-color-scheme 0 处、dark: 13 处 |
| 2 | 6 个 token 被引用但从未定义,导致边框回落 currentColor、暗色下焦点环外圈变白、面板背景透明 |
--color-bg-secondary(3处) --color-bg-primary --color-bg --color-surface-secondary --color-border-strong --color-on-primary-container |
已验证:定义 0 处、tsx 引用 1–3 处 |
| 3 | Toast 被 BottomSheet 完全遮住(z 100 vs 10000)→ H5 上在 sheet 里操作,结果反馈用户看不到 | shared/Toast.tsx:48 vs shared/MobileBottomSheet.tsx:49 |
z 值直读 |
| 4 | portal 下拉盖过 Modal 遮罩(内联 9999 vs z-50)。DirectoryPicker 被 AgentManager 在 Modal 内使用 |
shared/DirectoryPicker.tsx:178、RepositoryLaunchControls.tsx:306,314,325,333 |
z 值直读 |
| 5 | 浮层进场动画全部失效。animate-in slide-in-from-right fade-in 是 tailwindcss-animate 的类,该依赖已在 shadcn 回滚时删除,globals.css 也没定义 |
shared/Toast.tsx:25、shared/Dropdown.tsx:69,72 |
已验证:package.json 无该依赖、globals.css 无定义 |
| 6 | MCP 状态徽章配色两份副本已漂移,toneForStatus 缺 checking 分支 → 同一个 server 在设置页有徽章、在 slash 面板里渲染成裸文字 |
pages/McpSettings.tsx:71-77 vs chat/LocalSlashCommandPanel.tsx:32-44 |
— |
| 7 | 内嵌终端硬锁暗色。20 个 hex 写死,而三主题的终端 token 齐备却被完全绕过 → white 主题下嵌一块纯黑终端 | pages/TerminalSettings.tsx:229-249(token 在 globals.css:577-584/786-793/988-995) |
— |
P1 —— 一致性与死代码
| # | 问题 | 位置 |
|---|---|---|
| 8 | pages/NewTaskModal.tsx 整文件零引用死代码(真正在用的是 components/tasks/NewTaskModal.tsx),且是废弃 token --color-primary 的最大残留方(9/29 处)。抽取前应删除,否则会把废弃 token 带进新组件 |
已验证零 import |
| 9 | 状态点误用 animate-pulse(2s) 而非 animate-pulse-dot(1.5s),与其余 13 处不同频呼吸 |
layout/TabBar.tsx:610、chat/SessionTaskBar.tsx:143 |
| 10 | 不确定态进度条用 animate-pulse 假装,而 globals.css:1487-1506 已有正确实现没人用 |
pages/Settings.tsx:4241 |
| 11 | content-visibility 写成内联 style,绕过了针对触屏 H5 的关闭覆盖(iOS 长按选词会乱跳);且 '212px' 少了 auto 关键字 |
market/SkillCard.tsx:35 |
| 12 | 硬编码 DOM id 在两处渲染 → 重复 id,aria-controls 指向不确定 |
controls/PermissionModeSelector.tsx:129 渲染于 :176 和 :291 |
| 13 | 4 个死 class,其中 .pet-status-pulse 引用了根本不存在的 keyframe |
globals.css:1073, 1121, 2056, 2074 |
| 14 | 4 处层级倒挂,最严重是复用率第 4 的基础件反向依赖功能目录 | shared/CopyButton.tsx:2 → ../chat/clipboard(修法:chat/clipboard.ts 移到 lib/clipboard.ts) |
| 15 | 硬编码文案:Modal.tsx:98 aria-label="Close dialog" 无覆盖口、ReasoningEffortPopover.tsx:35 默认 '推理强度'、AttachmentGallery.tsx:193 '修改内容' |
— |
| 16 | 10 个 icon-only 按钮对屏幕阅读器完全无名,含全局 chrome | layout/TitleBar.tsx:62(设置入口)、layout/TabBar.tsx:404,483(标签滚动)、tasks/TaskRow.tsx:135、tasks/TaskRunsPanel.tsx:232、chat/LocalSlashCommandPanel.tsx:84、pages/TerminalSettings.tsx:848、pages/AgentTeams.tsx:156,175 |
待验证(未下结论)
- Tailwind 冲突类的实际胜负:84 处
<Button className=>中已出现size="sm"(px-2) 与px-0/px-3直接冲突。确定的是 class 属性顺序不决定层叠;具体谁胜出需bun run build后比对生成 CSS 中两条规则的偏移量。 color-mix的 WebKit 下限:globals.test.ts:118固化了"启动关键路径不得含 color-mix"(Safari 15 WebView),但 Tailwind v4 把bg-[var(--x)]/40编译成color-mix,全库有 422 处这类暴露面。抽库前需确认 Tauri 壳的实际最低 WebKit 版本(color-mix需 Safari 16.2+)。若 Safari 15 仍在矩阵内,组件库内部不能用/Nalpha 修饰符。
三、抽取清单
排序依据:重复次数 × 单处代码量 × 视觉不一致度 × 行为风险。
第 1 批:纯样式,零行为风险
1. Spinner(复活现有死代码)
覆盖 17 处手撸 border 转圈 + Button.tsx:56 私有副本。最小改动、最高确定性,适合用来验证整个流程。
export type SpinnerProps = {
size?: number // 默认 16
tone?: 'brand' | 'current' // 覆盖现存两种方言
respectReducedMotion?: boolean // 默认 true
className?: string
}
现存两种方言:brand + border-2(11 处)与 currentColor(6 处)。chat/ContextUsageIndicator.tsx:276 是唯一尊重 motion-safe: 的,抽取时把它变成默认。
2. IconButton(新建)
数量最大的一类:76 个严格 icon-only 按钮,跨 40 文件,15 种圆角 × 12 种尺寸,focus ring 仅 41% 覆盖,10 个无障碍名称缺失。
export type IconButtonProps =
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'aria-label'> & {
icon: ReactNode | string // lucide 组件、material 图标名,或任意 ReactNode
label: string // 必填:icon-only 无可见文本,无障碍名不可省
showTooltip?: boolean // 默认 true,同时写入 title
size?: 'xs' | 'sm' | 'md' | 'lg' // h-6/h-7/h-8/h-9,各配等宽
tone?: 'default' | 'muted' | 'danger' | 'brand'
shape?: 'square' | 'circle'
loading?: boolean
className?: string
}
内部固定:type="button"、aria-label={label}、统一 focus ring、统一 disabled。
同为 h-7 方形图标按钮,现在有三种写法:
Sidebar.tsx:1005(rounded-lg + border-focus ring)、WorkbenchPanel.tsx:119(rounded-[7px] + info/30 ring)、ChatInput.tsx:1193(rounded-[6px] + 无 ring)。
3. 强化 Button(就地扩 API,不新建)
78% 的按钮绕开它是有原因的 —— 缺 focus 环、缺 icon-only 模式、三个尺寸档都没有 h-*(这是全库 18 种高度泛滥的直接诱因)、type 未默认为 button、缺 tonal/link 变体。
export type ButtonVariant =
| 'primary' // 收敛到 gradient-btn-primary(现存 5 套配方的胜出者)
| 'secondary'
| 'tonal' // 新增:现存 27 处无处安放
| 'ghost'
| 'danger' // 实心
| 'danger-outline' // 新增:现存 17 个 danger 里 11 个是描边派
| 'link' // 新增:纯文字 + hover:underline
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant
size?: 'xs' | 'sm' | 'md' | 'lg' // 每档绑定固定 h-*,终结 18 种高度
loading?: boolean
icon?: ReactNode
iconPosition?: 'start' | 'end' // 新增:下拉箭头场景
block?: boolean // 新增:替代散落的 w-full
className?: string
}
配套修 4 条:基类补 focus ring;type 默认 'button';loading 时补 aria-busy;删掉内部私有 Spinner 改用 shared/Spinner。
最刺眼的证据:
pages/Settings.tsx:2623(主题选择器)与:2643(语言选择器)是相邻两个控件,外层 className 逐字符相同,只有 active 填色不同 —— 一个用渐变+阴影,一个用扁平 brand 填色。用户能直接看出来。
4. Badge + StatusDot
覆盖 45 处 pill 徽章 + 41 处状态点。纯展示无状态,可逐文件分批合入。
export type BadgeTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'info'
export type BadgeProps = {
tone?: BadgeTone
variant?: 'soft' | 'outline' | 'solid' // soft = 有底无边(默认)
size?: 'xs' | 'sm' // xs = text-[10px] px-2 py-0.5(覆盖 27/45)
pill?: boolean // false → radius-sm,用于计数徽章
icon?: ReactNode
mono?: boolean // 版本号/路径
className?: string
children: ReactNode
}
export type StatusDotProps = {
tone: BadgeTone
size?: 'sm' | 'md' | 'lg' // sm = 1.5(覆盖 23/41)
pulse?: boolean // 统一走 animate-pulse-dot
className?: string
}
market/InstallStateBadge.tsx:27-39与market/SecurityBadge.tsx:25-37的 shell 逐字符相同,只有映射表不同 —— 抽完这两个文件各自只剩一张映射表。 顺带修 P0#6(MCP tone 漂移)与 P1#9(两处 pulse 误用)。
5. Checkbox / Switch
Checkbox 20 实例 5 套写法(3 种尺寸、2 种 accent token、外加一套 globals.css:162 的隐藏 input + peer 自绘机制),连类名书写顺序都不统一(w-4 h-4 vs h-4 w-4)。
Switch 只有 3 个实例,但不一致率 100%(3 套实现,尺寸差 27%:56×32 vs 44×24),且新功能会继续复制。
export type SwitchProps = {
checked: boolean
onChange: (checked: boolean) => void
label: string // 必填,无可见文案时作 aria-label
labelHidden?: boolean
description?: ReactNode
disabled?: boolean
size?: 'sm' | 'md' // 采用 PetSettings 比例,弃用 h-8 w-14
as?: 'switch' | 'checkbox' // 语义按钮 vs 原生 input
}
统一走已存在的 --color-switch-checked-bg / --color-switch-thumb token。
第 2 批:含行为,逐个走查
6. EmptyState / ErrorState / LoadingState
覆盖 22 + 12 + 19 处,同时删掉 5 个同名局部定义。
现状最严重的一处:项目里有 3 个叫 EmptyState、2 个叫 LoadingState 的局部组件,跨文件导入 0 次,且两个 EmptyState 的 props 互不兼容({icon, text} vs {title, body})。
export type StateSize = 'sm' | 'md' | 'lg'
export type EmptyStateProps = {
icon?: ReactNode
title?: string // 渲染为 <h3>,可用 headingLevel 覆盖
description?: string
action?: { label: string; onClick: () => void; variant?: 'primary' | 'secondary' }
size?: StateSize
variant?: 'dashed' | 'plain' | 'inline'
headingLevel?: 2 | 3 | 4
className?: string
}
export type ErrorStateProps = {
title: string
detail?: string
onRetry?: () => void
retryLabel?: string
size?: StateSize
tone?: 'soft' | 'strong' // 把 20 种错误底色收敛到 2 档
className?: string
}
不一致现状:图标系统三并存(lucide / material-symbols / 内联 svg)、图标尺寸 9 种、标题标签 4 种(只有 2 处有真标题标签,其余对屏幕阅读器就是普通段落)、圆角 5 种、padding 8 种、24 个空态只有 1 个带行动按钮。
错误态的 alpha 完全失控:背景 5 档 + 边框 7 档 + container 8 档 = 20 种错误底色。
7. SearchField / TextField / TextArea / SelectField
输入类控件是全库无障碍最差的一块:76 个 input/textarea/select 中 focus-visible: 使用 0 次、disabled: 样式 0 处、aria-invalid 仅 1 处、13 个 textarea 全部无 id、7 个 select 全部无 aria-label。3 处焦点完全不可见(trace/TraceTree.tsx:86、shared/RepositoryLaunchControls.tsx:437,506、search/GlobalSearchModal.tsx:229)。
搜索框有 11 个独立实现、2 种结构、7 种 focus 反馈。
export type SearchFieldProps =
Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type' | 'value' | 'onChange'> & {
value: string
onChange: (value: string) => void
label: string // 必填,搜索框通常无可见 label
showLabel?: boolean
size?: 'sm' | 'md' | 'lg'
clearable?: boolean
onClear?: () => void
icon?: ReactNode
containerClassName?: string
onNavigate?: (direction: 'up' | 'down') => void // 上下键导航到结果
controlsId?: string
activeDescendantId?: string // combobox
}
TextField/TextArea 必须补齐现有 Input.tsx 全缺的:aria-invalid={!!error}、aria-describedby 指向 hint/error(用 useId() 生成,顺带修掉现在按 label 文本派生 id 的中文碰撞风险)、error 节点 role="alert"、disabled: 样式。
8. SegmentedControl / Tabs
10 处实现、6 种激活态视觉(渐变填充 / 扁平填充 / 边框+浅底 / 白底浮起 / 下边框 / 纯色差),单 pages/Settings.tsx 内部就有 5 种。4 处 role="tab" 全都没有 role="tablist" 父容器,也没有左右方向键。
export type SegmentedControlProps<T extends string> = {
items: Array<{ value: T; label: ReactNode; description?: ReactNode; icon?: ReactNode; disabled?: boolean }>
value: T
onChange: (value: T) => void
label: string // 必填,挂在容器上
appearance?: 'solid' | 'raised' | 'underline'
size?: 'sm' | 'md'
layout?: 'fill' | 'auto' | number
as?: 'radiogroup' | 'tablist' // 单选组 vs 视图切换
}
9. Skeleton 族
覆盖 6 个手撸骨架块 / 15 处。需先定 pulse 打容器还是叶子(建议容器级,但注意容器级会让 border 一起呼吸,两种现状视觉不同)。
export function Skeleton(props: {
shape?: 'line' | 'block' | 'circle' | 'avatar'
width?: string; height?: string
radius?: 'sm' | 'md' | 'lg' | 'full'
tone?: 'base' | 'strong' // 把现存 4 种占位底色收敛到 2 种
className?: string
}): JSX.Element
/** 唯一持有 animate-pulse 的地方,并统一挂 role/aria-busy */
export function SkeletonGroup(props: { label: string; className?: string; children: ReactNode }): JSX.Element
export function SkeletonRows(props: { count: number; rowHeight?: string; lines?: number; divided?: boolean }): JSX.Element
export function SkeletonCards(props: { count: number; minHeight?: string; withAvatar?: boolean }): JSX.Element
10. Card / Panel
覆盖 115 处。建议只在新代码和顺手重构时用,不做全局批量替换。
抽 Card 的真正价值不是省代码,而是给圆角 token 收敛提供渐进路径:现在硬编码 Tailwind 刻度(433 次)是设计 token(184 次)的 2.35 倍,且 rounded-lg 与 rounded-[var(--radius-lg)] 同名不同值(8px vs 12px)。调用方改成 <Card radius="md"> 后,433 处硬编码就有了收敛出口,不需要危险的全局替换。
export type CardProps = {
radius?: 'sm' | 'md' | 'lg' | 'xl'
surface?: 'base' | 'low' | 'lowest' | 'high' | 'inspector'
border?: 'solid' | 'dashed' | 'none'
padding?: 'none' | 'sm' | 'md' | 'lg'
interactive?: boolean // hover 边框高亮 + focus ring
as?: 'div' | 'section' | 'article'
className?: string
children: ReactNode
}
11. Progress
export type ProgressProps = {
value?: number
indeterminate?: boolean // 走 globals.css:1487 已有实现
size?: 'xs' | 'sm' | 'md'
tone?: BadgeTone | 'auto' // 'auto' = 满 100% 转 success
label: string // 必填,挂 role="progressbar" + aria-valuenow
className?: string
}
现状只有 browser/BrowserAddressBar.tsx:48 有 role="progressbar"。抽取后 chat/SessionTaskBar.tsx:65-76 与 teams/TeamStatusBar.tsx:62-71 那份复制粘贴一次消掉。
第 3 批:风险最高,单独 PR + 真机走查
12. 两个 hook 先行
// hooks/useDismissable.ts —— 消灭 21 处手写 outside-click
export type DismissReason = 'outside' | 'escape' | 'scroll' | 'resize' | 'blur'
export function useDismissable(options: {
open: boolean
refs: ReadonlyArray<{ current: HTMLElement | null }>
triggerRef?: { current: HTMLElement | null } // 点它时不派发 outside,避免"关了立刻又开"
onDismiss: (reason: DismissReason) => void
event?: 'pointerdown' | 'mousedown' | 'click' // 默认 pointerdown
capture?: boolean // 默认 true,保证嵌套浮层内层先关
closeOnEscape?: boolean // 默认 true
closeOnScroll?: boolean
isExempt?: (target: EventTarget | null) => boolean
}): void
// hooks/useAnchoredPosition.ts —— 合并 4 份独立的视口计算
export function useAnchoredPosition(o: {
open: boolean
anchorRef: { current: HTMLElement | null }
floatingRef: { current: HTMLElement | null }
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'left-start' | 'right-start'
offset?: number
viewportMargin?: number
flip?: boolean; shift?: boolean; track?: boolean
}): { style: CSSProperties; placement: string; ready: boolean }
实现直接移植
common/OpenWithMenu.tsx:36-75(测量翻转 + 夹取 + 排除 trigger + Esc + scroll 关闭),它是全项目最完整的浮层行为实现。不需要 floating-ui。事件类型很关键:现状
mousedown(14) /click(3) /pointerdown(3)。触屏上mousedown不可靠,这是 H5 上"点外面菜单不关"的根因类别 —— 统一到pointerdown。
13. Menu / Popover / Tooltip / Dialog
Tooltip 优先(5 处实现、5 种写法、4 种 z 值、3 种触发机制,纯展示、风险最低)。
Menu 覆盖 28 处手撸下拉 + 71 个菜单项(29 种内距配方,其中 20 组只出现 1 次)。现状:仅 4 处 a11y 合格、11 处无 Esc、13 处无外部点击关闭、仅 6 处有方向键、仅 1 处关闭后归还焦点、没有任何一处在打开时把焦点移入菜单。
Dialog 不是"合并三个重复实现" —— Modal → ActionDialog → ConfirmDialog 是干净的三层单向包装,没有重复。真正要补的是:
dismissible开关(现在 4 处各自 hackonClose={busy ? () => {} : onClose})- ConfirmDialog 的
confirmLabel/cancelLabel给 i18n 默认值(13 处每处都在写t('common.cancel')) mobileVariant='auto':让 Dialog 按视口自动切 BottomSheet 形态,消灭 4 处useMobileViewport() && !isDesktopRuntime()的三元分叉
14. z-index token
@theme {
--z-base: 0; --z-raised: 10; --z-sticky: 20; --z-nav: 30;
--z-scrim: 40; --z-drawer: 50; --z-dropdown: 60; --z-popover: 70;
--z-dialog: 80; --z-sheet: 80; --z-toast: 90; --z-tooltip: 100;
}
--z-toast 必须高于 --z-sheet,这是修 P0#3 的关键。迁移映射:z-50(20 处) 按语义拆到 dialog/dropdown/popover;内联 9999(5 处) 和 1000 → dropdown;z-[10000](2 处) → sheet/dialog;5 处 tooltip 的 20/30/40/50 → tooltip。
明确不抽
| 组件 | 理由 |
|---|---|
Table |
全库 0 个 <table>,表格状数据全用 CSS Grid。硬造会重演 Spinner.tsx 的死代码教训 |
Divider |
只 9 处,divide-y 已够用,包组件反增 DOM |
Avatar |
market/SkillAvatar.tsx 已抽且仅 2 个调用点;ActivitySettings 的是不同语义(用户头像走上传 API) |
| 虚拟滚动库 | 当前 CV + 手写窗口化经 WebKit 实测(最差帧 279ms→18ms),引库会破坏"虚拟化项禁用 CV"的不变量 |
| 任何 UI 依赖 | 2026-07-25 刚回滚过 shadcn/ui(e9b53f68) |
barrel index.ts |
项目零先例;chunkSizeWarningLimit 已提到 2200,barrel 阻碍 tree-shaking |
四、目录重组
目标结构
components/ui/ 无业务语义原语。禁止 import stores/ 和 api/
components/composite/ 业务复合件。可 import stores/api,但需 ≥2 调用方
components/<feature>/ 功能目录。单调用方组件一律回归此处
迁移批次
批次 1 — 零风险(无调用方变更)
- 删
shared/Spinner.tsx(0 调用方)或按第 1 批复活它 - 删
pages/NewTaskModal.tsx(0 调用方死代码) components/chat/clipboard.ts→lib/clipboard.ts(解开层级倒挂,改 1 处 import)
批次 2 — 私有子组件回归(各改 1 处 import)
| 从 | 到 | 唯一调用方 |
|---|---|---|
controls/ReasoningEffortPopover.tsx |
随 ModelSelector 或标记为内部 | controls/ModelSelector.tsx |
controls/AutoModeOptInDialog.tsx |
同上 | controls/PermissionModeSelector.tsx |
shared/ConfirmPopover.tsx |
components/tasks/ |
components/tasks/TaskRow.tsx |
批次 3 — 建立 ui/(纯移动,影响 74 处 import)
Button ConfirmDialog Modal CopyButton Input MobileBottomSheet ActionDialog Dropdown → components/ui/
与别名迁移合并做 —— 反正都要改这 74 行,一步到位改成
@/components/ui/Button,避免二次触碰。
批次 4 — 业务件出 shared
| 从 | 到 | 调用方 |
|---|---|---|
shared/Toast.tsx |
components/layout/ |
1 |
shared/UpdateChecker.tsx |
components/layout/ |
1 |
shared/ProjectContextChip.tsx |
components/session/ |
1 |
shared/RepositoryLaunchControls.tsx |
components/session/ |
2 |
shared/DirectoryPicker.tsx |
components/composite/ |
5 |
common/OpenWithMenu.tsx + common/TargetIcon.tsx |
components/composite/ |
3 + 3 |
批次 5 — shared/、common/ 清空后删除;controls/ 保留(届时名副其实)。不动 workbench/、hooks/、lib/。
机器化守门(建议)
theme/globals.test.ts 已经开了"用测试守护约定"的先例。照抄加一个 components/ui/layering.test.ts:读 ui/ 下所有源码,断言不含 from '../../stores/ 和 from '../../api/。约 20 行,把"ui/ 不得耦合业务"从文档约定变成 CI 红线 —— 比任何文档措辞都更能保证人和 AI 都遵守。
五、落地节奏
每步独立 PR,带单元测试(props 矩阵 + 被替换位置的行为回归)。
| 阶段 | 内容 | 风险 |
|---|---|---|
| 0 | 修 P0 缺陷(@custom-variant dark 一行、补 6 个幽灵 token、z 层级倒挂、删两个死文件) |
极低,纯 CSS + 删文件 |
| 1 | Spinner 复活 + IconButton + 强化 Button |
低,纯样式 |
| 2 | Badge/StatusDot + Checkbox/Switch |
低 |
| 3 | 三个 State 组件(顺带删 5 个同名局部定义) | 中低 |
| 4 | 目录批次 1–3 + @/ 别名 |
中(影响 74 处 import,但纯机械) |
| 5 | 输入控件族 + SegmentedControl |
中,需逐个走查 |
| 6 | useDismissable + useAnchoredPosition(先替换那 59 行重复验证) |
中,可单测 |
| 7 | Tooltip → Menu → Dialog + z token |
高,需真机走查 |
| 8 | Skeleton / Card / Progress(只用于新代码,不批量替换) |
低 |
阶段 0 和 1 建议先做完并合入,让"有组件库可用"这件事尽快对后续开发生效 —— 否则新功能会继续按旧方式复制粘贴,债务增长快于偿还。
附:审计方法与可信度
5 个只读 agent 分域并行扫描,主控 agent 对关键结论做了独立复核。已复核成立的:@custom-variant 缺失、6 个幽灵 token、animate-in 死类、59 行逐字节重复、pages/NewTaskModal.tsx 零引用、shared/Button 内部私有 Spinner 与 shared/Spinner.tsx 逐字符相同、@/ 别名 0 使用。
已复核不成立并剔除的两条:
browser/BrowserAddressBar.tsx:27,28缺type="button"会触发表单提交 —— 那三个按钮是<form>的兄弟节点而非子节点(form 从:32才开始且只包住 input),不会提交。- "文档命名为
AGENTS.md可以只触发秒级check:policy" —— 实测evaluateChangePolicy(['desktop/src/components/AGENTS.md'])返回checks: desktop, policy。原因是change-policy.ts:313-315的touchesDesktopWeb只判断file.startsWith('desktop/src/'),没有像相邻的server判定那样排除isAgentInstructionPath。所以本文件与components/AGENTS.md的改动都会触发check:desktop,这是把文档放在代码旁边换取"就近命中"的合理代价,文档本身也是低频改动。
未复核、按原样保留的统计数字(各类重复次数、变体分布、配方种数)来自 agent 的脚本化统计,可用作优先级排序依据,但落地时应以当时实际代码为准。
附二:实施记录与对本文的偏离
2026-07-26 执行。基线 225 测试文件 / 2521 用例,完成后 250 / 2893,check:desktop(lint + test + build)全绿。
已落地
| 阶段 | 结果 |
|---|---|
| 0 | @custom-variant dark;6 个幽灵 token 清零;z 层级刻度;animate-in 死类替换为 4 个真实 keyframe;MCP 徽章映射合一;终端配色接入主题;删 pages/NewTaskModal.tsx 与 7 处死 CSS |
| 1 | Spinner / IconButton / Button(4 档固定高度 + focus ring + type="button" + 3 个新 variant);shared/Button 与 shared/Spinner 删除,24 处 import 迁移 |
| 2 | Badge / StatusDot / Checkbox / Switch;替换 MCP 徽章 2 处、animate-pulse 误用 2 处、Switch 实现 2 套 |
| 3 | EmptyState / ErrorState / LoadingState;删除 5 个同名局部定义 |
| 4 | 建 ui/ 与 composite/,shared/ 与 common/ 清空并删除;chat/clipboard.ts → lib/clipboard.ts 解开层级倒挂;两个目录各加契约测试 |
| 5 | Input 强化 + TextArea / SelectField / SearchField / SegmentedControl |
| 6 | useDismissable / useAnchoredPosition;替换 ChatInput 与 EmptySession 那 59 行逐字节重复 |
| 7 | Tooltip;Dropdown 补 listbox 语义、方向键、焦点管理 |
| 8 | Skeleton 族 / Card / Progress |
对本文计划的四处偏离
-
z 层级顺序改了。本文第 14 节把
dropdown: 60排在dialog: 80之下,实际改成了 dropdown(70) > dialog(60)。理由:模态对话框挡住背景,打开的下拉必然属于最上层对话框;按原顺序,AgentManager模态里的DirectoryPicker会被对话框盖住而不可用 —— 那正是它当初硬编码zIndex: 9999的原因。 -
Badge没有solidvariant。清点后 45 个徽章全是 tinted,没有一个实色语义徽章;做solid需要为每个 tone × 每个主题定义可读前景(12 个 token),没有调用方。改为soft/outline两种,另加bordered开关。 -
Menu没有新建。28 处手撸下拉行为各异,一次性统一风险过高。改为:Dropdown补齐 listbox 语义,并交付useDismissable+useAnchoredPosition两个 hook —— 28 处可以逐个用这两个 hook 收敛,不需要一次性替换。 -
Spinner没有respectReducedMotionprop。reduced-motion 改为在 CSS 里全局处理,且是减速(1s → 2.4s)而非停止:spinner 的唯一功能就是表达"进行中",停下来读起来像界面卡死。
真机走查(gallery.html)发现的三件事
单元测试全绿之后开 gallery 才发现的,都不是结构问题:
-
Button/IconButton没转发 ref →Tooltip定位不到锚点、Dropdown关闭后焦点丢失。只有一条 console 警告,不报错。原测试全部用原生<button>做 trigger(DOM 元素原生接受 ref),所以测不出来。已加forwardRef+ 两条用函数组件做 trigger 的测试。 -
light 主题的 warning 徽章对比度 2.66:1(AA 需要 4.5)。根因是拿
--color-warning直接当--color-warning-container上的前景。已补齐--color-on-success/warning/info-container三组共 9 个 token,Badge改用它们;三主题最差对比度从 2.66 升到 5.48。新增theme/contrast.test.ts解析 token 并计算对比度守门(反证过:把值改回去,测试精确复现 2.66:1 后变红)。 -
Dropdown的 Escape 会连带关闭外层Modal。这是我在重写Dropdown时引入的回归 —— 原实现用 capture +stopPropagation,useDismissable最初没有。已加stopEscapePropagation选项。
环境问题(既有,未处理)
desktop/package.json 声明 "react": "^19.2.4",但 bun.lock 锁的是 ^18.3.1,实际装的也是 18.3.1(仓库根目录的 node_modules 反而是 19.2.4)。本次因此使用 forwardRef 而非 React 19 的 ref-as-prop。升级 React 超出本次范围,未改动。
附三:多 agent 并行替换(同日第二轮)
组件库建成后,用 6 个并行 agent 把散落各处的手写 UI 收敛过来。
分区
worktree 是共享的,并发写同一文件会互相覆盖,所以文件区隔离是硬约束而非建议。每个 agent 的 prompt 都写死了「绝不要碰的目录」。
| Agent | 范围 |
|---|---|
| pages | src/pages/** |
| chat | components/chat/** |
| layout | layout/ workspace/ workbench/ |
| market | market/ plugins/ tasks/ search/ skills/ |
| settings | settings/ trace/ browser/ activity/ controls/ composite/ teams/ doctor/ features/pets/ |
| i18n | src/i18n/** + 各处硬编码文案(前五个完成后才启动,避免冲突) |
同时要求:不跑全量测试(并行期间会看到别人改到一半的红,只跑自己范围)、保守优先(拿不准就跳过并写明理由)、性能敏感区点名保护(虚拟滚动、content-visibility、运行时注入 CSS 变量的文件只做最安全的替换)。
结果
组件采用 349 处,剩余原生 <button> 294 处 —— 复用率从审计时的 22% 到 54%。
手写 outside-click 从 21 处降到 2 处(useDismissable 采用 20 处)。
测试 250 文件/2893 用例 → 253/2919,check:desktop 全绿。
i18n 新增 35 个 key × 5 个 locale,locale 文件达成 0 missing / 0 extra。
三件只有并行才暴露的事
-
Dropdown的 trigger 改造是破坏性变更,而它静默失效。 把 trigger 从「渲染在 wrapper 里」改成cloneElement注入 ref/aria/onClick 之后,market/FilterBar的筛选 chip 完全点不动了 ——FilterTrigger是个不转发 props 的函数组件,注入的 onClick 落在空处。没有报错,测试也全绿,是 agent 在替换那个文件时手动点出来的。已加固:排查全部调用方(只此一处,其余是原生
<button>);Dropdown增加开发期断言,trigger 没转发 ref 就console.warn;补两条测试(不转发的 trigger 确实打不开、正确的不告警)。这与Button/IconButton的 forwardRef 缺失是同一个根因的两副面孔 —— 前者是组件自身不转发,后者是调用方自定义的 trigger 不转发。 -
一个 agent 拒绝执行我的错误指令。 我让它给
teams/TeamStatusBar的进度条用tone="auto",它指出auto的语义是「100% 变绿」,而那个进度条是「没有在跑的就变绿」——1 个完成 + 1 个出错 = 50%,按auto就不会变绿。它写了显式条件并在报告里说明了偏离。 -
组件缺口由多个独立来源交叉确认。 三个 agent 分别在自己的范围里撞到「
IconButton缺secondarytone」,各自给出具体文件和行号。同类还有:2xs(20px) 与xl(40px) 尺寸、bordered、hoverTone="danger"(删除图标静止就红会被读成错误状态)、pressed、surface="sidebar"(侧边栏有自己的 hover token,三主题下都与--color-surface-hover不同),以及Button的base(h-8)、Badge的wrap/title/rest props 透传。全部已补齐并配测试。这些都不是「agent 偷懒用 className 硬压」,而是它们按 AGENTS.md §3.6 的判断标准正确识别为「缺 variant」并上报。该条已扩写为 §3.7,把这个流程固化下来。
顺带清掉的死物
layout/TitleBar.tsx(96 行)—— 零引用。审计点名的「设置入口对屏幕阅读器无名」其实根本不渲染。scheduledPage.col*5 个 i18n key × 5 locale —— 表格布局早已被卡片布局取代。RepositoryLaunchControls.test.tsx一处失效的vi.mock(迁移后路径没跟上,隔离静默丢失)。新增__tests__/mockPaths.test.ts全仓守门。SessionTaskBar与TeamStatusBar那对逐字节相同的进度条,现在都是Progress。
新增守门
__tests__/mockPaths.test.ts— 每个vi.mock路径都能解析theme/paletteEscapes.test.ts— 禁止新增绕过 token 的 Tailwind 原始调色板(bg-amber-50这类不跟随data-theme,且tokenUsage.test.ts看不见,因为不是var())。现有 4 个文件白名单允许保留,但不许增长、不许新文件加入。
附四:第二轮并行替换
第一轮的五份报告都记录了「因组件缺 X 而跳过」。把那些缺口补齐后,带着各自的跳过清单再跑一轮。
结果
| 审计时 | 第一轮后 | 第二轮后 | |
|---|---|---|---|
| 组件采用 | 102 | 349 | 524 |
剩余原生 <button> |
362 | 294 | 179 |
| 组件化率 | 22% | 54% | 75% |
测试 253 文件 / 2929 用例,check:desktop 全绿。
第二轮补的组件能力
IconButton 的 2xl(44px)、solid、disabledStyle,Button 的 inverse,Badge/Progress 的 rest props 透传,SearchField 的 clearLabel,以及 --color-on-error token。
一个假绿测试掩盖的无效功能
第二轮的 pages agent 实际编译了 Tailwind v4 来验证类冲突,而不是猜测,结论是:同名工具类的任意值按值的字母序排,最后一个赢,与传入顺序无关。
于是:
hoverTone="danger"配tone="muted"或secondary时完全无效 —— 这两个 tone 的 hover 已经带hover:text-[var(--color-text-primary)],它按字母序排在hover:text-[var(--color-error)]之后,永远赢。而这正是该功能被创造出来要服务的两种搭配。- 同一机制也让调用方的
disabled:opacity-0打不过组件的disabled:opacity-50。
原测试只断言 class 字符串「包含」红色类,从未断言中性类被移除,所以一路绿着。 这恰好是 §3.6 警告别人的陷阱,组件库自己踩了。
修法是让两者互斥而非叠加:hover 背景与 hover 文字色拆成两张表,hoverTone 替换掉 tone 自带的文字色;disabled 透明度改为 disabledStyle 属性。新测试改为断言「hover:text-* 类有且只有一个」,并对全部 5 个 tone 参数化 —— 反证过:恢复旧实现后,正好 secondary 和 muted 两条变红,与编译结论完全吻合。
组件库自己违反自己的约定(两处,都已修)
SearchField拼英文 —— 内部用`Clear ${label}`组装 aria-label,导致任何已翻译清除按钮的调用方采用它反而是 i18n 退化。两个 agent 独立把它列为"无法采用"的原因。第一次修成
clearLabel ?? label是错的:输入框和清除按钮拿到相同的可访问名,getByLabelText直接抛"匹配到多个"。最终改为必填 —— 与IconButton.label同一个设计,强制调用方提供翻译。Badge/Progress不透传 rest props —— 调用方一旦需要data-testid就只能退回手写 span,而那正是重复的源头。
又清掉的死代码
pages/ToolInspection.tsx 与 pages/AgentTeams.tsx —— agent 核实 ContentRouter 和 AppShell 导入的 9 个页面里都没有它们,只有测试引用。连同 pages.test.tsx 里的 5 处引用一并删除。
剩余 179 处,逐类说明
全部是语义上就不该套通用组件的:role="tab" / menuitem" / option" / treeitem" / gridcell" 元素、可点击的整行与整卡、文件树节点、拖拽把手、OS 标题栏按钮、WorkspaceDiffSurface 的 diff 单元格。五份报告逐条写明了理由。
几处例外,附实证:
ChatInput的加号/发送按钮 —ChatInput.test.tsx:1451钉死h-11 w-11;即便加了2xl(44px),发送按钮还有「停止时 error-container / 否则 primary 渐变」的双填充,无变体可表达。Settings.tsx仅存的 2 处调色板 — 在一个硬编码白底的二维码框里(扫码需要对比度),换成主题 token 会在 dark 下变成白底浅字。agent 换了又自己退回来,并留了注释。activity/SessionActivityPanel的转圈 — 该文件有motion-safe:/motion-reduce:的减弱动效策略,且有测试断言行内不得出现裸animate-spin;而Spinner有意发出裸animate-spin(它是减速而非停止)。真冲突。ActivitySettings的热力图 tooltip — 一个 tooltip 在 ~365 个格子间复用,Tooltip克隆单个 trigger,套上去就是 365 个 portal。
仍然遗留
composite/OpenWithMenu未接useAnchoredPosition(anchor是 rect 而非 ref,改动要连着chat/的 3 个调用方一起)。ConversationNavigator的侧向 tooltip ——useAnchoredPosition只有 top/bottom 系 placement,没有 left/right。Card仍只用于新代码,未批量替换容器组合。MessageBlocks的ROLE_STYLES仍用/8alpha 填充,需要新的 soft token。