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

42 KiB
Raw Blame History

桌面端组件库:抽取与迁移计划

本文是 desktop/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 自己私有实现了一个 SpinnerSVG path 与相邻的 shared/Spinner.tsx 逐字符相同M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z)。而 shared/Spinner.tsx0 个文件导入,是纯死代码 —— 连 shared 目录内部、相邻两个文件之间都在复制粘贴。

  2. chat/ChatInput.tsx:408-466pages/EmptySession.tsx:140-198 是 59 行逐字节相同的复制diff 输出为空),四个 outside-click effect 一模一样。

  3. chat/SessionTaskBar.tsx:65-76teams/TeamStatusBar.tsx:62-71 的进度条连 {/* Progress bar */} 注释都一样,只有三元里的变量名不同。

再加上目录学证据:shared/controls/同一个 commit993b96cd, 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-scheme13 处 dark: 全是坏的。系统浅色 + 应用 dark 时,流式重试提示条会炸出近白色块 chat/StreamingIndicator.tsx:68,70,72,75,78,84features/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-50DirectoryPickerAgentManager 在 Modal 内使用 shared/DirectoryPicker.tsx:178RepositoryLaunchControls.tsx:306,314,325,333 z 值直读
5 浮层进场动画全部失效animate-in slide-in-from-right fade-intailwindcss-animate 的类,该依赖已在 shadcn 回滚时删除,globals.css 也没定义 shared/Toast.tsx:25shared/Dropdown.tsx:69,72 已验证package.json 无该依赖、globals.css 无定义
6 MCP 状态徽章配色两份副本已漂移toneForStatuschecking 分支 → 同一个 server 在设置页有徽章、在 slash 面板里渲染成裸文字 pages/McpSettings.tsx:71-77 vs chat/LocalSlashCommandPanel.tsx:32-44
7 内嵌终端硬锁暗色。20 个 hex 写死,而三主题的终端 token 齐备却被完全绕过 → white 主题下嵌一块纯黑终端 pages/TerminalSettings.tsx:229-249token 在 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:610chat/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 在两处渲染 → 重复 idaria-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:135tasks/TaskRunsPanel.tsx:232chat/LocalSlashCommandPanel.tsx:84pages/TerminalSettings.tsx:848pages/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 私有副本。最小改动、最高确定性,适合用来验证整个流程。

export type SpinnerProps = {
  size?: number                  // 默认 16
  tone?: 'brand' | 'current'     // 覆盖现存两种方言
  respectReducedMotion?: boolean // 默认 true
  className?: string
}

现存两种方言brand + border-211 处)与 currentColor6 处)。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 ringtype 默认 '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-39market/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且新功能会继续复制。

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-label3 处焦点完全不可见trace/TraceTree.tsx:86shared/RepositoryLaunchControls.tsx:437,506search/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/erroruseId() 生成,顺带修掉现在按 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 次)是设计 token184 次)的 2.35 倍,且 rounded-lgrounded-[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:48role="progressbar"。抽取后 chat/SessionTaskBar.tsx:65-76teams/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 处各自 hack onClose={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 → dropdownz-[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/uie9b53f68
barrel index.ts 项目零先例;chunkSizeWarningLimit 已提到 2200barrel 阻碍 tree-shaking

四、目录重组

目标结构

components/ui/         无业务语义原语。禁止 import stores/ 和 api/
components/composite/  业务复合件。可 import stores/api但需 ≥2 调用方
components/<feature>/  功能目录。单调用方组件一律回归此处

迁移批次

批次 1 — 零风险(无调用方变更)

  • shared/Spinner.tsx0 调用方)或按第 1 批复活它
  • pages/NewTaskModal.tsx0 调用方死代码)
  • components/chat/clipboard.tslib/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 Dropdowncomponents/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

批次 5shared/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 TooltipMenuDialog + 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,28type="button" 会触发表单提交 —— 那三个按钮是 <form> 的兄弟节点而非子节点form 从 :32 才开始且只包住 input不会提交。
  2. "文档命名为 AGENTS.md 可以只触发秒级 check:policy" —— 实测 evaluateChangePolicy(['desktop/src/components/AGENTS.md']) 返回 checks: desktop, policy。原因是 change-policy.ts:313-315touchesDesktopWeb 只判断 file.startsWith('desktop/src/')没有像相邻的 server 判定那样排除 isAgentInstructionPath。所以本文件与 components/AGENTS.md 的改动都会触发 check:desktop,这是把文档放在代码旁边换取"就近命中"的合理代价,文档本身也是低频改动。

未复核、按原样保留的统计数字(各类重复次数、变体分布、配方种数)来自 agent 的脚本化统计,可用作优先级排序依据,但落地时应以当时实际代码为准。


附二:实施记录与对本文的偏离

2026-07-26 执行。基线 225 测试文件 / 2521 用例,完成后 250 / 2893check:desktoplint + test + build全绿。

已落地

阶段 结果
0 @custom-variant dark6 个幽灵 token 清零z 层级刻度;animate-in 死类替换为 4 个真实 keyframeMCP 徽章映射合一;终端配色接入主题;删 pages/NewTaskModal.tsx 与 7 处死 CSS
1 Spinner / IconButton / Button4 档固定高度 + focus ring + type="button" + 3 个新 variantshared/Buttonshared/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.tslib/clipboard.ts 解开层级倒挂;两个目录各加契约测试
5 Input 强化 + TextArea / SelectField / SearchField / SegmentedControl
6 useDismissable / useAnchoredPosition;替换 ChatInputEmptySession 那 59 行逐字节重复
7 TooltipDropdown 补 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 没转发 refTooltip 定位不到锚点、Dropdown 关闭后焦点丢失。只有一条 console 警告,不报错。原测试全部用原生 <button> 做 triggerDOM 元素原生接受 ref所以测不出来。已加 forwardRef + 两条用函数组件做 trigger 的测试。

  2. light 主题的 warning 徽章对比度 2.66:1AA 需要 4.5)。根因是拿 --color-warning 直接当 --color-warning-container 上的前景。已补齐 --color-on-success/warning/info-container 三组共 9 个 tokenBadge 改用它们;三主题最差对比度从 2.66 升到 5.48。新增 theme/contrast.test.ts 解析 token 并计算对比度守门(反证过:把值改回去,测试精确复现 2.66:1 后变红)。

  3. Dropdown 的 Escape 会连带关闭外层 Modal。这是我在重写 Dropdown 时引入的回归 —— 原实现用 capture + stopPropagationuseDismissable 最初没有。已加 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/2919check: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 分别在自己的范围里撞到「IconButtonsecondary tone」各自给出具体文件和行号。同类还有2xs(20px) 与 xl(40px) 尺寸、borderedhoverTone="danger"(删除图标静止就红会被读成错误状态)、pressedsurface="sidebar"(侧边栏有自己的 hover token三主题下都与 --color-surface-hover 不同),以及 Buttonbase(h-8)、Badgewrap/title/rest props 透传。全部已补齐并配测试。

    这些都不是「agent 偷懒用 className 硬压」,而是它们按 AGENTS.md §3.6 的判断标准正确识别为「缺 variant」并上报。该条已扩写为 §3.7,把这个流程固化下来。

顺带清掉的死物

  • layout/TitleBar.tsx96 行)—— 零引用。审计点名的「设置入口对屏幕阅读器无名」其实根本不渲染
  • scheduledPage.col* 5 个 i18n key × 5 locale —— 表格布局早已被卡片布局取代。
  • RepositoryLaunchControls.test.tsx 一处失效的 vi.mock(迁移后路径没跟上,隔离静默丢失)。新增 __tests__/mockPaths.test.ts 全仓守门。
  • SessionTaskBarTeamStatusBar 那对逐字节相同的进度条,现在都是 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 全绿。

第二轮补的组件能力

IconButton2xl(44px)、soliddisabledStyleButtoninverseBadge/Progress 的 rest props 透传,SearchFieldclearLabel,以及 --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 参数化 —— 反证过:恢复旧实现后,正好 secondarymuted 两条变红,与编译结论完全吻合。

组件库自己违反自己的约定(两处,都已修)

  1. SearchField 拼英文 —— 内部用 `Clear ${label}` 组装 aria-label导致任何已翻译清除按钮的调用方采用它反而是 i18n 退化。两个 agent 独立把它列为"无法采用"的原因。

    第一次修成 clearLabel ?? label 是错的:输入框和清除按钮拿到相同的可访问名,getByLabelText 直接抛"匹配到多个"。最终改为必填 —— 与 IconButton.label 同一个设计,强制调用方提供翻译。

  2. Badge / Progress 不透传 rest props —— 调用方一旦需要 data-testid 就只能退回手写 span而那正是重复的源头。

又清掉的死代码

pages/ToolInspection.tsxpages/AgentTeams.tsx —— agent 核实 ContentRouterAppShell 导入的 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 未接 useAnchoredPositionanchor 是 rect 而非 ref改动要连着 chat/ 的 3 个调用方一起)。
  • ConversationNavigator 的侧向 tooltip —— useAnchoredPosition 只有 top/bottom 系 placement没有 left/right。
  • Card 仍只用于新代码,未批量替换容器组合。
  • MessageBlocksROLE_STYLES 仍用 /8 alpha 填充,需要新的 soft token。