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.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-26 16:04:04 +08:00
parent 9dd7295cd2
commit f91b62f485
3 changed files with 930 additions and 1 deletions
+2 -1
View File
@@ -2,7 +2,8 @@
These rules apply to `desktop/` changes in addition to the root instructions.
- Reuse the existing desktop design system and component/store/API patterns. Use `lucide-react` for common icons and keep operational UI dense, stable, and readable.
- Before adding or editing anything under `desktop/src/components/`, read `desktop/src/components/AGENTS.md`. It is the authoritative index of reusable components, the placement rules for new ones, and the required style/i18n/a11y/test conventions. Do not add a component that duplicates one listed there, and do not add new files to `components/shared/` or `components/common/`.
- Reuse the existing desktop store/API patterns. Use `lucide-react` for common icons and keep operational UI dense, stable, and readable.
- Add focused Vitest or Testing Library coverage for UI, store, or API behavior. Run it first, then follow `bun run check:impact`; desktop product changes normally select `bun run check:desktop`.
- Chat transport, WebSocket lifecycle, first-turn runtime selection, reconnect, or session changes also require the offline `bun run check:chat-contract` when selected.
- Electron host, sidecar, packaging, or version changes require `bun run check:native` when selected.
+678
View File
@@ -0,0 +1,678 @@
# 桌面端组件库:抽取与迁移计划
本文是 [`desktop/src/components/AGENTS.md`](../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 根因:没有索引,不是没有能力
三个铁证,都是**同一份代码被复制而作者显然不知道已有实现**:
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 引用 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 仍在矩阵内,组件库内部不能用 `/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#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),且新功能会继续复制。
```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 次)是设计 token(184 次)的 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/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 使用。
已复核**不成立**并剔除的两条:
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 个真实 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` |
### 对本文计划的四处偏离
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>` 做 trigger(DOM 元素原生接受 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 个 locale,locale 文件达成 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。
+250
View File
@@ -0,0 +1,250 @@
# 桌面端组件规范
编辑 `desktop/src/components/` 下任何文件前先读本文。它是可复用组件的权威索引、新组件的放置规则,以及样式 / i18n / 无障碍 / 测试的强制约定。
规则优先级:根 `AGENTS.md` < `desktop/AGENTS.md` < 本文件。冲突时以本文件为准。
配套文档:[`desktop/docs/component-library-plan.md`](../../docs/component-library-plan.md) 记录审计证据与后续计划。
---
## 一、我要做 X,用什么
先查这张表,**不要重写一个**。
| 需求 | 用这个 | 位置 |
|---|---|---|
| 带文字的按钮 | `Button` | `ui/Button.tsx` |
| 只有图标的按钮 | `IconButton` | `ui/IconButton.tsx` |
| 转圈加载 | `Spinner` | `ui/Spinner.tsx` |
| 状态徽章 / 计数 | `Badge` | `ui/Badge.tsx` |
| 状态圆点 | `StatusDot` | `ui/Badge.tsx` |
| 复选框 | `Checkbox` | `ui/Checkbox.tsx` |
| 开关 | `Switch` | `ui/Switch.tsx` |
| 单行输入 | `Input` | `ui/Input.tsx` |
| 多行输入 | `TextArea` | `ui/TextArea.tsx` |
| 纯文本选项的下拉 | `SelectField` | `ui/SelectField.tsx` |
| 带图标 / 描述的下拉 | `Dropdown` | `ui/Dropdown.tsx` |
| 搜索框 | `SearchField` | `ui/SearchField.tsx` |
| 分段切换 / 标签页 | `SegmentedControl` | `ui/SegmentedControl.tsx` |
| 空态 | `EmptyState` | `ui/EmptyState.tsx` |
| 错误态 | `ErrorState` | `ui/ErrorState.tsx` |
| 加载态 | `LoadingState` | `ui/LoadingState.tsx` |
| 骨架屏 | `Skeleton` / `SkeletonRows` / `SkeletonCards` | `ui/Skeleton.tsx` |
| 进度条 | `Progress` | `ui/Progress.tsx` |
| 卡片容器 | `Card` | `ui/Card.tsx` |
| 对话框 | `Modal` | `ui/Modal.tsx` |
| 对话框 + 一排按钮 | `ActionDialog` | `ui/ActionDialog.tsx` |
| 确认 / 删除确认 | `ConfirmDialog` | `ui/ConfirmDialog.tsx` |
| 移动端底部弹层 | `MobileBottomSheet` | `ui/MobileBottomSheet.tsx` |
| 悬浮提示 | `Tooltip` | `ui/Tooltip.tsx` |
| 复制按钮 | `CopyButton` | `ui/CopyButton.tsx` |
| 目录选择 | `DirectoryPicker` | `composite/DirectoryPicker.tsx` |
| 「用 X 打开」菜单 | `OpenWithMenu` | `composite/OpenWithMenu.tsx` |
### 两个 hook
| 需求 | 用这个 |
|---|---|
| 点外面 / Esc 关闭浮层 | `hooks/useDismissable.ts` |
| 浮层贴着触发器定位(含翻转、夹取) | `hooks/useAnchoredPosition.ts` |
**不要再手写 `document.addEventListener('mousedown', ...)`。** 触屏上 `mousedown` 不可靠,这是 H5 上「点外面菜单不关」的根因类别。`useDismissable` 默认用 `pointerdown`。
浮层开在 `Modal` 里时传 `stopEscapePropagation: true`,否则一次 Esc 会把下拉和对话框一起关掉。
### 明确不做的
`Table`(全库 0 个 `<table>`,表格数据都用 CSS Grid)、`Divider`(`divide-y` 够用)、`Avatar`(`market/SkillAvatar` 已有)、虚拟滚动库(会破坏「虚拟化项禁用 content-visibility」的不变量)。
---
## 二、新组件放哪
```
components/ui/ 无业务语义的原语。禁止 import stores/ 和 api/
components/composite/ 业务复合件。可 import stores/api,但必须 ≥2 个调用方
components/<feature>/ 功能目录。单调用方的组件一律放这里
```
决策树:
1. **只有一个调用方?** → 放调用方所在的功能目录,不要放 `ui/` 或 `composite/`。
> `composite/contract.test.ts` 对每个文件断言 ≥2 个调用方,单调用方会红。
2. **需要读 store 或调 API?** → `composite/`(且要有 ≥2 个调用方)。
3. **纯 UI、无业务语义?** → `ui/`。
> `ui/contract.test.ts` 断言:不 import stores/api、不 import 其他功能目录、不含硬编码 hex、不用 `rounded-md` 之类的 Tailwind 圆角。
`shared/` 和 `common/` 已删除,**不要重建**。它们的问题是名字没有约束力:两个目录同一个 commit 建的,划分标准从没写下来,一年后各自塞满了原语和业务件的混合物,35% 是单调用方的伪公共件。上面三条规则是唯一的区别,也是 CI 能检查的部分。
**import 一律用 `@/` 别名**:`import { Button } from '@/components/ui/Button'`。
---
## 三、样式
### 3.1 颜色只能来自 token
```tsx
// 对
className="bg-[var(--color-surface)] text-[var(--color-text-primary)]"
// 错:三个主题下长得一模一样
className="bg-[#FAF9F5] text-[#1B1C1A]"
```
三个主题(`white` 默认 / `light` / `dark`)靠 `<html data-theme>` 切换,**不跟随操作系统**。新增 token 必须在三个主题块里都定义,`globals.test.ts` 会检查。
`tokenUsage.test.ts` 扫描全部源码,断言每个 `var(--token)` 都能解析 —— 拼错的 token 不会报错,只会静默渲染成透明背景或 `currentColor` 边框。
### 3.2 状态色成对使用
语义色是成对的:`--color-<tone>-container` 是底色,`--color-on-<tone>-container` 是它上面的文字色。**不要拿 `--color-warning` 直接当 `--color-warning-container` 上的前景** —— light 主题下那样只有 2.66:1,远低于 AA 的 4.5。
`contrast.test.ts` 对三主题 × 六个 tone 计算实际对比度并断言 ≥4.5(dark 的 container 是半透明的,测试会先与 surface 合成再算)。
### 3.3 圆角
用 `rounded-[var(--radius-md)]`,不要用 `rounded-md`。二者同名不同值(Tailwind 的 `rounded-lg` 是 8px,`--radius-lg` 是 12px),混用是全库 21 种圆角的来源。
### 3.4 层级
z-index 只能来自 `globals.css` 的层级刻度:
```tsx
className="z-[var(--z-dropdown)]"
style={{ zIndex: 'var(--z-dialog)' }}
```
顺序:`base 0 → raised → sticky → nav → scrim → drawer → dialog 60 → sheet 65 → dropdown 70 → popover 75 → tooltip 80 → toast 90`
两条刻意的排序:**dropdown 高于 dialog**(模态对话框挡住了背景,所以打开的下拉必然属于最上层对话框 —— 这是 `DirectoryPicker` 当初被迫写 `zIndex: 9999` 的原因),**toast 高于 sheet**(否则在 sheet 里操作,结果反馈用户看不见)。
`tokenUsage.test.ts` 拒绝任何裸 `zIndex: 9999` 或 `z-[10000]`。
### 3.5 `dark:` 变体
可以用,它绑在 `[data-theme="dark"]` 上(见 `globals.css` 顶部的 `@custom-variant dark`)。**但优先用 token** —— `dark:` 只覆盖三个主题中的一个,white 和 light 会共用同一套值。
### 3.6 className 覆盖不保证生效
组件不做 Tailwind 冲突消解(那需要 `tailwind-merge` 依赖,本项目不引 UI 依赖)。`<Button size="sm" className="px-3">` 里 `px-2` 和 `px-3` 谁赢取决于它们在生成样式表里的先后,不是 class 属性的顺序。
**如果你发现自己反复用 className 覆盖同一组工具类,那说明缺一个 variant,去加。**
### 3.7 遇到组件表达不了的样式怎么办
**不要用 className 硬压过去,也不要退回手写 `<button>`。** 正确顺序是:
1. 先确认这不是"这处本就该定制"。列表项、树节点、标签页项、拖拽把手、OS 窗口按钮这些本来就不该塞进通用组件。
2. 如果是通用需求(同样的形态在多处出现),**给组件加一个 variant/tone/size**,连同测试一起。
3. 改不了或拿不准,**保持原样并记下来** —— 一个诚实的"跳过 + 原因"比一个视觉走样的替换有价值得多。
`IconButton` 的 `secondary` tone、`2xs`–`2xl` 六档尺寸、`bordered`、`solid`、`hoverTone="danger"`、`pressed`、`surface="sidebar"`,`Button` 的 `base`(h-8) 与 `inverse` 变体,`Badge` 的 `wrap`/`title`/rest 透传,`SearchField` 的 `clearLabel` —— 全部来自这个流程。多轮独立的替换工作各自撞到同一批缺口,然后一次补齐。
几个存在理由值得记住:
> **`hoverTone="danger"`** — 删除类图标静止态就是红的,会被读成"当前处于错误状态"。静止用 `muted`,悬停才变红。
>
> **`surface="sidebar"`** — 侧边栏有自己的 hover token(`--color-sidebar-item-hover`),三个主题下都与 `--color-surface-hover` 不同。用 className 覆盖会变成两个 `hover:bg-[…]` 打架,谁赢取决于样式表顺序。
>
> **`solid` vs `filled`** — `filled` 只是浅色着色,压在用户上传的图片上会糊掉。`solid` 是满强度填充 + 对比前景(走 `--color-on-error` 这类 token,**不要写 `text-white`** —— dark 主题的 `--color-error` 是浅红,白字读不出来)。
>
> **`2xl`(44px)** — 移动端主要触摸目标的平台下限。`ChatInput` 的输入栏按钮有测试钉死这个尺寸,缩到 40px 是回归。
>
> **`SearchField` 的 `clearLabel`** — 组件内部一度用 `` `Clear ${label}` `` 拼英文,导致任何已经翻译过清除按钮的调用方采用它反而是 i18n 退化。**组件库自己不许拼用户可见的英文。**
---
## 四、桌面端硬约束
- **不引 UI 依赖。** 2026-07-25 刚回滚过 shadcn/ui。
- **不用 `/N` alpha 修饰符做关键配色**(`bg-[var(--x)]/10`)。它会编译成 Safari 15 WebView 无法解析的颜色函数,`vite-config.test.ts` 对 `globals.css` 有整文件断言。需要浅底色就加实色 token(例如 `--color-brand-soft`)。
- **不加 barrel `index.ts`。** 项目零先例,且会阻碍 tree-shaking。
- **`content-visibility` 写进 CSS class,不要写内联 style** —— 内联会绕过针对触屏 H5 的关闭覆盖。
- 同一份 bundle 服务 Electron 壳 / 桌面浏览器 / 手机 H5。触屏专属样式收敛在 `html[data-touch-h5]` 作用域下。
- **会被浮层锚定的组件必须 `forwardRef`。** `Tooltip` 和 `Dropdown` 都会给 trigger 挂 ref;不转发 ref 的组件会让 tooltip 定位不到、下拉关闭后焦点丢失,且**只有一条 console 警告**,不会报错。
---
## 五、i18n
所有用户可见文案走 `useTranslation()`,包括 `aria-label` 和 `title`。
组件库内部不写中文或英文字面量 —— 默认文案(如 `ConfirmDialog` 的按钮文字)由调用方传入。
---
## 六、无障碍基线
新组件必须满足:
- **可聚焦元素有可见 focus ring**:`focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]`
- **图标按钮有名字**:用 `IconButton`,它的 `label` 是必填的
- **输入控件有关联 label**:用 `useId()` 生成 id,**不要从 label 文本派生** —— 中文没有空格,两个中文 label 会撞 id,导致一个 `<label>` 指向另一个字段的输入框
- **错误信息有 `role="alert"`,且被 `aria-describedby` 指向**
- **浮层用正确的 role**:`role="listbox"` 里放 `role="option"`,不要放 `<button>`
- **tooltip 用 `aria-describedby` 而非 `aria-label`** —— 后者会覆盖元素本身的名字,让图标按钮被读成它的提示文字而不是它的动作
- **描述文字不要混进可访问名**:`<label>` 同时包着标题和描述时,用 `aria-labelledby` 精确指向标题那个 span
---
## 七、测试
新组件必须有对应的 `.test.tsx`。三个可复制的模板:
```tsx
// 1. 无障碍契约
it('names the button for screen readers from label', () => {
render(<IconButton icon={<span />} label="Open settings" />)
expect(screen.getByRole('button', { name: 'Open settings' })).toBeInTheDocument()
})
// 2. props 矩阵
it.each(SIZES)('pins an explicit height for size=%s', (size) => {
const { container } = render(<Button size={size}>Label</Button>)
expect(container.firstElementChild?.className).toMatch(/\bh-\d/)
})
// 3. 回归锚点:注释写清楚原来坏在哪
it('defaults to type="button" so it cannot submit a surrounding form', () => {
render(<Button>Run</Button>)
expect(screen.getByRole('button', { name: 'Run' })).toHaveAttribute('type', 'button')
})
```
**jsdom 的三个已知偏差**,别在这上面浪费时间:
- `fireEvent.click` 在 `disabled` 的 `<input>` 上**仍会**触发 change(连裸 input 都如此)。断言 `toBeDisabled()`,不要模拟点击。
- 没有实现 `scrollIntoView`。源码里用可选调用 `?.scrollIntoView?.()`。
- `getBoundingClientRect` 一律返回全 0。要测定位逻辑,用 **ref callback** 打桩 rect —— 它在 DOM 挂载时同步执行,早于测量用的 layout effect;写在渲染函数体里就晚了一帧。
**测试用真实 DOM 元素做 trigger 会漏掉一整类 bug。** 浮层组件的测试里至少要有一例传**函数组件**(`<Button>` 而不是 `<button>`)—— ref 转发失败只在这种情况下暴露。
**单元测试测不到的,去开 gallery**:
```bash
cd desktop && bun run dev
```
打开 `http://localhost:1420/gallery.html`。它渲染全部 `ui/` 组件,右上角切三个主题。颜色是否可读、浮层是否盖对、进场动画是否真的在动 —— 这些只有渲染出来才知道。新增组件请同步加进 `src/dev/ComponentGallery.tsx`。
> gallery 只在 dev 下可达,不进生产构建(vite 的 build input 只有 `index.html`)。
---
## 八、提交前
```bash
cd desktop && bun run lint && bun run test -- --run
```
然后 `bun run check:impact`,桌面改动通常会选中 `bun run check:desktop`(lint + test + build 三步)。
自查清单:
- [ ] 没有新建与第一节表格重复的组件
- [ ] 新组件放在正确目录(第二节决策树)
- [ ] 颜色、圆角、层级都来自 token
- [ ] 有 focus ring;图标按钮有名字;输入控件有 label
- [ ] 有对应测试,且浮层组件测过函数组件 trigger
- [ ] 视觉改动在 gallery 里三个主题都看过