cc-haha/desktop/docs/component-library-plan.md
程序员阿江(Relakkes) f91b62f485 docs(desktop): document the component library and its rules
`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.
2026-07-26 18:31:34 +08:00

679 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 桌面端组件库:抽取与迁移计划
本文是 [`desktop/src/components/AGENTS.md`](../src/components/AGENTS.md) 的配套执行文档,记录审计证据、待抽组件的 API 草稿和分批落地顺序。
审计范围:`desktop/src/**`492 个 ts/tsx其中 tsx 235 个。方法5 个并行只读 agent 分域扫描(设计 token / 交互原语 / 浮层 / 数据展示 / 共享层与工程约束),结论经主控 agent 抽样复核。
> **执行状态2026-07-26**:阶段 08 已全部落地,`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 根因:没有索引,不是没有能力
三个铁证,都是**同一份代码被复制而作者显然不知道已有实现**
1. **`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 目录内部、相邻两个文件之间都在复制粘贴。
2. **`chat/ChatInput.tsx:408-466``pages/EmptySession.tsx:140-198` 是 59 行逐字节相同的复制**`diff` 输出为空),四个 outside-click effect 一模一样。
3. **`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 引用 13 处 |
| 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 仍在矩阵内,组件库内部不能用 `/N` alpha 修饰符。
---
## 三、抽取清单
排序依据:重复次数 × 单处代码量 × 视觉不一致度 × 行为风险。
### 第 1 批:纯样式,零行为风险
#### 1. `Spinner`(复活现有死代码)
覆盖 17 处手撸 border 转圈 + `Button.tsx:56` 私有副本。最小改动、最高确定性,适合用来验证整个流程。
```ts
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 个无障碍名称缺失。
```ts
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` 变体。
```ts
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 处状态点。纯展示无状态,可逐文件分批合入。
```ts
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#6MCP 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且新功能会继续复制。
```ts
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}`)。
```ts
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 反馈**
```ts
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"` 父容器**,也没有左右方向键。
```ts
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 一起呼吸,两种现状视觉不同)。
```ts
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 次)是设计 token184 次)的 2.35 倍,且 `rounded-lg``rounded-[var(--radius-lg)]` 同名不同值8px vs 12px。调用方改成 `<Card radius="md">`433 处硬编码就有了收敛出口,不需要危险的全局替换。
```ts
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`
```ts
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 先行
```ts
// 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 处各自 hack `onClose={busy ? () => {} : onClose}`
- ConfirmDialog 的 `confirmLabel`/`cancelLabel` 给 i18n 默认值13 处每处都在写 `t('common.cancel')`
- **`mobileVariant='auto'`**:让 Dialog 按视口自动切 BottomSheet 形态,消灭 4 处 `useMobileViewport() && !isDesktopRuntime()` 的三元分叉
#### 14. z-index token
```css
@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/dialog5 处 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` 已提到 2200barrel 阻碍 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 | 目录批次 13 + `@/` 别名 | 中(影响 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 使用。
已复核**不成立**并剔除的两条:
1. `browser/BrowserAddressBar.tsx:27,28``type="button"` 会触发表单提交 —— 那三个按钮是 `<form>` 的兄弟节点而非子节点form 从 `:32` 才开始且只包住 input不会提交。
2. "文档命名为 `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 个真实 keyframeMCP 徽章映射合一;终端配色接入主题;删 `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` |
### 对本文计划的四处偏离
1. **z 层级顺序改了**。本文第 14 节把 `dropdown: 60` 排在 `dialog: 80` 之下,实际改成了 dropdown(70) > dialog(60)。理由:模态对话框挡住背景,打开的下拉必然属于最上层对话框;按原顺序,`AgentManager` 模态里的 `DirectoryPicker` 会被对话框盖住而不可用 —— 那正是它当初硬编码 `zIndex: 9999` 的原因。
2. **`Badge` 没有 `solid` variant**。清点后 45 个徽章全是 tinted没有一个实色语义徽章`solid` 需要为每个 tone × 每个主题定义可读前景12 个 token没有调用方。改为 `soft` / `outline` 两种,另加 `bordered` 开关。
3. **`Menu` 没有新建**。28 处手撸下拉行为各异,一次性统一风险过高。改为:`Dropdown` 补齐 listbox 语义,并交付 `useDismissable` + `useAnchoredPosition` 两个 hook —— 28 处可以逐个用这两个 hook 收敛,不需要一次性替换。
4. **`Spinner` 没有 `respectReducedMotion` prop**。reduced-motion 改为在 CSS 里全局处理,且是**减速**1s → 2.4s而非停止spinner 的唯一功能就是表达"进行中",停下来读起来像界面卡死。
### 真机走查(`gallery.html`)发现的三件事
单元测试全绿之后开 gallery 才发现的,都不是结构问题:
1. **`Button` / `IconButton` 没转发 ref** → `Tooltip` 定位不到锚点、`Dropdown` 关闭后焦点丢失。只有一条 console 警告,不报错。原测试全部用原生 `<button>` 做 triggerDOM 元素原生接受 ref所以测不出来。已加 `forwardRef` + 两条用函数组件做 trigger 的测试。
2. **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 后变红)。
3. **`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 个 localelocale 文件达成 0 missing / 0 extra。
### 三件只有并行才暴露的事
1. **`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 不转发。
2. **一个 agent 拒绝执行我的错误指令。** 我让它给 `teams/TeamStatusBar` 的进度条用 `tone="auto"`,它指出 `auto` 的语义是「100% 变绿」而那个进度条是「没有在跑的就变绿」——1 个完成 + 1 个出错 = 50%,按 `auto` 就不会变绿。它写了显式条件并在报告里说明了偏离。
3. **组件缺口由多个独立来源交叉确认。** 三个 agent 分别在自己的范围里撞到「`IconButton``secondary` tone」各自给出具体文件和行号。同类还有`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` 两条变红,与编译结论完全吻合。
### 组件库自己违反自己的约定(两处,都已修)
1. **`SearchField` 拼英文** —— 内部用 `` `Clear ${label}` `` 组装 aria-label导致任何已翻译清除按钮的调用方采用它反而是 i18n 退化。两个 agent 独立把它列为"无法采用"的原因。
> 第一次修成 `clearLabel ?? label` 是错的:输入框和清除按钮拿到**相同**的可访问名,`getByLabelText` 直接抛"匹配到多个"。最终改为**必填** —— 与 `IconButton.label` 同一个设计,强制调用方提供翻译。
2. **`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` 仍用 `/8` alpha 填充,需要新的 soft token。