From 452b2a816a58151b48d6db7c8ce9c6ec761d988f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 14:32:59 +0800 Subject: [PATCH 1/4] refactor(client): share the capsule tag, switch, and idle state dot Client feature plugins never import one another's components, so ui-primitives is their only channel for sharing one. Nothing told an author to look there first, and the package offered nothing to look at: its README named six source files against forty-plus exports. Add Tag (read-only capsule badge, eight tones) and Switch (36x20 toggle with a required accessible name), extend StateDotState with idle for a tracked subject with no activity, and move ui-agent-preset, ui-settings-plugins, and ui-settings-plugin-inventory onto them. The plugin inventory's conditional tag referenced --dsw-alias-state-warning-primary, which does not exist, so both themes had been painting its hardcoded #b45309 fallback. State the reuse rule as the first step of the new-component checklist, and give the README a component catalog so the rule has something to check. The rule is guidance, not a gate: a package may still write its own control, and the Agent Note records the five that stay local. --- ...shared-client-control-primitives.i18n.yaml | 6 ++ ...-09-05-shared-client-control-primitives.md | 73 +++++++++++++++++++ ...-05-shared-client-control-primitives.zh.md | 73 +++++++++++++++++++ docs/web-styling.i18n.yaml | 4 +- docs/web-styling.md | 1 + docs/web-styling.zh.md | 1 + packages/client/AGENTS.md | 14 ++-- .../src/client/AgentPresetSection.module.css | 19 +---- .../src/client/AgentPresetSection.tsx | 8 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 35 +++++++++ packages/client/ui-primitives/README.zh.md | 35 +++++++++ packages/client/ui-primitives/src/Pill.tsx | 3 +- .../ui-primitives/src/StateDot.module.css | 6 ++ .../client/ui-primitives/src/StateDot.tsx | 7 +- .../ui-primitives/src/Switch.module.css | 42 +++++++++++ packages/client/ui-primitives/src/Switch.tsx | 41 +++++++++++ .../client/ui-primitives/src/Tag.module.css | 55 ++++++++++++++ packages/client/ui-primitives/src/Tag.tsx | 42 +++++++++++ packages/client/ui-primitives/src/index.ts | 3 + .../tests/state-dot-styles.client.spec.ts | 23 ++++++ .../tests/state-dot.client.spec.tsx | 2 +- .../tests/switch.client.spec.tsx | 54 ++++++++++++++ .../ui-primitives/tests/tag.client.spec.tsx | 42 +++++++++++ .../PluginInventorySettingsTab.module.css | 56 +------------- .../src/client/PluginInventorySettingsTab.tsx | 39 +++++++--- .../src/client/PluginCard.module.css | 10 +-- .../src/client/PluginCard.tsx | 4 +- .../SubagentModelSelectionCard.module.css | 41 ----------- .../src/client/SubagentModelSelectionCard.tsx | 17 ++--- .../src/client/fields.module.css | 22 ------ .../ui-settings-plugins/src/client/fields.tsx | 5 +- 32 files changed, 603 insertions(+), 184 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md create mode 100644 .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md create mode 100644 packages/client/ui-primitives/src/Switch.module.css create mode 100644 packages/client/ui-primitives/src/Switch.tsx create mode 100644 packages/client/ui-primitives/src/Tag.module.css create mode 100644 packages/client/ui-primitives/src/Tag.tsx create mode 100644 packages/client/ui-primitives/tests/state-dot-styles.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/switch.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/tag.client.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml new file mode 100644 index 0000000000..11cf1f37f3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md +2026-09-05-shared-client-control-primitives.md: 2847f52ccd9d8134fd71e97fcf964a5bfa6daac2 +2026-09-05-shared-client-control-primitives.zh.md: 1071736e52a6978a0ad8ddfd91976d32605f5af9 diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md new file mode 100644 index 0000000000..2847f52ccd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md @@ -0,0 +1,73 @@ +# Agent Note: Shared client control primitives + +Status: implemented + +English | [中文](2026-09-05-shared-client-control-primitives.zh.md) + +## Problem + +Client feature plugins compose through slots and never import one another's values, so `@deepseek-ai/dsh-client-ui-primitives` is their only channel for sharing a React component. A control that grows inside one feature package is invisible to the next package that needs the same thing, and copying its markup and CSS is the cheapest move available. Three families had diverged that way. A 36×20 toggle switch existed only inside `ui-settings-plugins`. Read-only capsule badges were declared five separate times across `ui-agent-preset`, `ui-settings-plugins`, and `ui-settings-plugin-inventory`, with two different corner radii and separately authored palettes. A plugin-phase status dot was reimplemented in `ui-settings-plugin-inventory` next to the shared `StateDot` it duplicates. + +Neither half of the fix was available to an author. No checklist told them to look in `ui-primitives` before writing a control, and the package gave them nothing to look at: its README named six source files while the package exported more than forty symbols. + +## Decision + +**A control that a second client package needs lives in `ui-primitives`.** The rule is guidance for authors, not a gate: a feature package may still write its own component when its need is genuinely specific, and the [what stays local](#what-stays-local) list below records the cases from this change. What the rule forbids is the copy — when a control already exists, the author either uses it or lifts the deliberate difference into a prop. + +`Tag` is the read-only capsule badge. Its geometry is fixed at the size `ui-agent-preset` established: `999px` radius, `1px 8px` padding, 11px text on a 17px line, 500 weight, `inline-flex`, no wrapping. A single closed `TagTone` union selects the palette, and every member exists because a shipped call site needed it: `outline` and `solid` from the agent-preset section, `neutral` and `quiet` from the plugin settings fields, and `success`, `info`, `warning`, and `danger` from the plugin inventory's enablement tags. The component carries no copy of its own, as every Cordis-free primitive must. + +`Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control. + +`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. + +**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does. + +The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too. + +## Finding the duplicates + +A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly three rules — `Tag` itself, the broken badge below, and the `flex: none` placement class the unsaved marker kept. + + +## What stays local + +A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial: + +- **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`. +- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance. +- **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge. +- **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one. +- **`ui-agent-preset`'s broken badge** shares the capsule geometry but carries a solid error fill that no second site uses, and it is the hover anchor for a tooltip element of its own. `Tag` would have to keep a palette override in the feature stylesheet and depend on cross-file CSS ordering to win it. + +## Alternatives considered + +**A `SettingsCard` primitive.** Rejected. `.card` appears in fifteen packages, but only three carry settings-page semantics, and those three differ in behaviour rather than appearance: `ui-settings-plugins`' `PluginCard` stages edits and collapses only after a Host-confirmed save, `ui-agent-preset`'s cards are selectable, and the plugin inventory's are read-only. `PluginCard`'s own header comment already records why it cannot use the shared disclosure row. A single component would have to accept all three behaviours through props that no caller uses more than one of. + +**A gate rejecting `role="switch"` or a `.switch` rule outside `ui-primitives`.** Rejected. The goal is that authors reuse what exists, not that they are prevented from building. A gate would fail a package with a legitimately specific control — `ui-trajectory`'s labelled toolbar toggle is exactly that case — and the cost of the false rejection lands on the author least able to argue with it. The catalog plus the checklist step address the actual failure, which is that authors did not know the control existed. + +**Preserving every current appearance behind extra `Tag` props.** Rejected. The plugin inventory's 5px radius and the agent-preset capsule are the same kind of tag in two shapes, and neither difference was decided. Keeping both would fix an accident into a public union and leave the next author to guess which one to pick. + +**Extending `Pill` instead of adding `Tag`.** Rejected. `Pill` is 24px tall on a 12px radius with 12px text; the badge baseline is denser and rounder. Merging them would produce one component whose size depends on whether `onClick` is present, and would erase the read-only/selectable distinction that the catalog needs in order to answer "which one do I want". + +**A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which six ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. + +**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. + +**Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule. + +## Testing + +`Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices. + +The four migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. + +That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request. + +## Consequences + +- A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports. +- `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument. +- The plugin inventory's tags change from a 5px rectangle to a capsule and its phase dots gain a halo and, in the loading phase, an animation. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. +- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids. +- The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists. +- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies. diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md new file mode 100644 index 0000000000..1071736e52 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md @@ -0,0 +1,73 @@ +# Agent Note: Shared client control primitives + +Status: implemented + +[English](2026-09-05-shared-client-control-primitives.md) | 中文 + +## Problem + +客户端功能插件通过 slot 组合,彼此从不 import 对方的值,因此 `@deepseek-ai/dsh-client-ui-primitives` 是它们共享 React 组件的唯一通道。在某个功能包内部长出来的控件,对下一个需要同样东西的包不可见,而复制它的标记与 CSS 是当时最省事的做法。三类控件已经这样分叉:36×20 的开关只存在于 `ui-settings-plugins`;只读胶囊徽章在 `ui-agent-preset`、`ui-settings-plugins`、`ui-settings-plugin-inventory` 里被分别声明了五次,带着两种圆角和各自编写的配色;插件相位状态点在 `ui-settings-plugin-inventory` 里被重新实现,就在它所复制的共享 `StateDot` 旁边。 + +修复所需的两半,作者当时一半也拿不到。没有任何清单要求他们在写控件前先看 `ui-primitives`,而这个包也没给他们可看的东西:README 只列了六个源文件,包却导出了四十多个符号。 + +## Decision + +**第二个客户端包需要的控件,住在 `ui-primitives`。** 这条规则是对作者的引导,不是门禁:功能包在需求确实特殊时仍可以自己写组件,下面的[保留在原包的控件](#what-stays-local)记录了本次改动中的这些例子。规则禁止的是复制——控件已经存在时,作者要么用它,要么把那处有意的差异提升成一个 prop。 + +`Tag` 是只读胶囊徽章。几何锁定在 `ui-agent-preset` 确立的尺寸:`999px` 圆角、`1px 8px` 内边距、11px 字号配 17px 行高、500 字重、`inline-flex`、不换行。单个闭合的 `TagTone` 联合选择配色,每个成员都因为有已发布的调用点需要它而存在:`outline` 与 `solid` 来自 agent preset 区块,`neutral` 与 `quiet` 来自插件设置字段,`success`、`info`、`warning`、`danger` 来自插件清单的启用标签。与所有 Cordis-free 原语一样,这个组件不自带任何文案。 + +`Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。 + +`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。 + +**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。 + +规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。 + +## 如何找出重复 + +按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配三条规则——`Tag` 本身、下面那个损坏徽章,以及未保存标记保留下来的 `flex: none` 布局类。 + + +## 保留在原包的控件 + +机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似: + +- **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。 +- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。 +- **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。 +- **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。 +- **`ui-agent-preset` 的损坏徽章**共用胶囊几何,但带着没有第二处使用的实底错误色填充,而且它是自带提示元素的悬停锚点。改用 `Tag` 就得在功能包样式表里保留一份配色覆盖,并依赖跨文件 CSS 顺序来让它生效。 + +## Alternatives considered + +**做一个 `SettingsCard` 原语。** 否决。`.card` 出现在十五个包里,但只有三处带设置页语义,而这三处的差异在行为而不在外观:`ui-settings-plugins` 的 `PluginCard` 暂存编辑,且只在 Host 确认保存后才折叠;`ui-agent-preset` 的卡片可选中;插件清单的卡片是只读的。`PluginCard` 自己的头注释已经记录了它为何不能使用共享折叠行。单个组件将不得不通过 props 接纳全部三种行为,而没有任何一个调用方会用到其中一种以上。 + +**加门禁,拒绝 `ui-primitives` 之外的 `role="switch"` 或 `.switch` 规则。** 否决。目标是让作者复用已有的东西,不是阻止他们构建。门禁会挂掉一个确实有特殊控件的包——`ui-trajectory` 的带标签工具栏开关正是这种情况——而误拒的代价落在最没法跟它讲道理的作者身上。目录加清单条目针对的是真正的失败原因:作者不知道那个控件存在。 + +**用额外的 `Tag` props 保留现有的每一种外观。** 否决。插件清单的 5px 圆角与 agent preset 的胶囊是同一类标签的两种形状,两处差异都不是决定出来的。两个都留会把一次意外固化进公开联合,并且让下一个作者去猜该挑哪个。 + +**扩展 `Pill` 而不是新增 `Tag`。** 否决。`Pill` 高 24px、圆角 12px、字号 12px;徽章基准更密也更圆。合并会产生一个尺寸取决于是否传了 `onClick` 的组件,并抹掉只读与可选中的区分——而目录正需要这个区分来回答"我该用哪个"。 + +**给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中六种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 + +**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 + +**把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。 + +## Testing + +`Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。 + +四个迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 + +这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。 + +## Consequences + +- 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。 +- `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。 +- 插件清单的标签从 5px 矩形变为胶囊,相位点获得光晕,loading 相位还会有动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 +- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。 +- 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。 +- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。 diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 6dd20572ee..f6757eeea9 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/web-styling.md -web-styling.md: 250936b82f06b47d00c0b6646cbea5bdb41f42ea -web-styling.zh.md: 9845b4f5d8341624d294a008f505c0fb1ec9757a +web-styling.md: dbd696307d5044c165c77948059859eecd32b4a7 +web-styling.zh.md: af3f66960e8e0596577d155919a051688aca372b diff --git a/docs/web-styling.md b/docs/web-styling.md index 250936b82f..dbd696307d 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -12,6 +12,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi ## Component rules +- Reuse the control before restyling one: the [ui-primitives component catalog](../packages/client/ui-primitives/README.md#component-catalog) is the only channel that crosses feature packages, and a deliberate visual difference belongs in a prop there rather than in a second copy ([decision](../.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md)). - Use CSS Modules and `clsx`; do not add a component library or Tailwind. - Use `--dsw-alias-*` semantic tokens in feature components. Do not copy static palette values or write literal colors there. - Keep theme selectors out of feature component CSS. Light/dark overrides belong to the theme owner. diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 9845b4f5d8..af3f66960e 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -12,6 +12,7 @@ ## 组件规则 +- 重新设计控件样式之前先复用控件:[ui-primitives 组件目录](../packages/client/ui-primitives/README.zh.md#component-catalog)是唯一跨功能包的通道,有意的视觉差异应作为那里的一个 prop,而不是另起一份拷贝([决定](../.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md))。 - 使用 CSS Modules 和 `clsx`;不得添加组件库或 Tailwind。 - 功能组件使用 `--dsw-alias-*` 语义 token。不得复制静态色板值或在其中写入颜色字面量。 - 功能组件 CSS 不得包含主题选择器。明暗主题覆盖属于主题所有方。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 435c9d6b17..26fa1309ec 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -144,9 +144,11 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com ## New component checklist -1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [Slots reference](../../docs/subsystems/slots.md). No other composition route exists. -2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. -3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. -4. Tokens only in CSS; product copy follows the localization rule above; English comments. -5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. -6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. +1. **Check the [ui-primitives catalog](ui-primitives/README.md#component-catalog) before writing a control.** A plugin cannot import another plugin's component, so `ui-primitives` is the only place a control can be shared: reuse the primitive that already fits, and lift a deliberate visual difference into a prop rather than starting a second copy. Writing your own component in your own package is fine when the need is genuinely specific — what is not fine is copying a control that already exists. Once a second package needs the same control, promote it. + +2. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [Slots reference](../../docs/subsystems/slots.md). No other composition route exists. +3. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. +4. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. +5. Tokens only in CSS; product copy follows the localization rule above; English comments. +6. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. +7. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index d019c87964..658788e38a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -198,26 +198,9 @@ line-height: 1.4; } -.badge, -.inUse { - border-radius: 999px; - corner-shape: round; - padding: 1px 8px; - font-size: 11px; - line-height: 17px; - white-space: nowrap; - font-weight: 500; -} - -.badge { - border: 0.5px solid var(--dsw-alias-border-l4); - color: var(--dsw-alias-label-tertiary); -} - +/* Placement only: the capsule geometry and palette come from `Tag`. */ .inUse { margin-left: auto; - background: var(--dsw-alias-label-primary); - color: var(--dsw-alias-bg-layer-3); } /* Bounded to four lines. A preset publishes its own description, so one long diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 6ec76eea14..1da7d49450 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -13,7 +13,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip, + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -296,10 +296,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { ) : null} - + {row.trust === 'user' ? t('userTrust') : t('builtIn')} - - {row.isDefault ? {t('inUse')} : null} + + {row.isDefault ? {t('inUse')} : null} {/* Visually hidden, deliberately: the pointer path is the diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index ccd55950f1..5438d90de1 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: ce18a574e0058b3e7e6f50e91fa2671af6563faa -README.zh.md: 527f91a6b932ea05d8d6262058eba37de67c65ce +README.md: de407ca491309c27b69e6ddbecad903b90f19e66 +README.zh.md: 2bfa4e5bdb9d55803ad3ed71d51e0f14ddf6aff6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ce18a574e0..de407ca491 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -27,6 +27,41 @@ English | [中文](README.zh.md) Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system. + +### Component catalog + +Check this table before writing a control in a feature package. A plugin cannot import another plugin's component, so this package is the only place a control can be shared: reuse what fits, and lift a deliberate visual difference into a prop rather than starting a second copy. + +| Export | What it is | +|---|---| +| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. | +| `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. | +| `Input` | Single-line text entry for search boxes and inline forms. | +| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. | +| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | +| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | +| `StateDot` | Status mark: `done`, `warning`, `ongoing`, `error`, or `idle`. `aria-hidden`, so the render site owns the name. | +| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | +| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | +| `Modal` | Centered dialog over a page mask. | +| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | +| `OnboardingSurface` | First-run stage that holds the application root inert. | +| `Tooltip` | Hover text on a cloned anchor, placed on one of four sides. | +| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | +| `Toast` | Transient top-center banner held for the owner's `holdMs`. | +| `JsonTree`, `JsonBlock` | Read-only JSON inspection. | +| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. | +| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | + +Three pairs are easy to confuse: + +- **`Tag` against `Pill`.** `Pill` is selectable — it takes `active` and `onClick` and drives view switchers and filters. `Tag` is read-only and takes neither. Passing `active` without `onClick` means you want `Tag`. +- **`DisclosureRow` against a card.** The row lays its title and content side by side at a fixed 24px. A card that stacks a name over a description is a different layout, and belongs in the feature package — `ui-settings-plugins`' `PluginCard` is the precedent and records why. +- **`FoldToggle` against the exported surface.** It is package-internal and not exported; the output cards use it for their head-tail fold. + +Writing your own component in your own package is fine when the need is genuinely specific. What is not fine is copying a control that already exists here — and once a second package needs the same control, it belongs in this package ([decision](../../../.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md)). + ### Controls and icons `Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 527f91a6b9..2bfa4e5bdb 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -27,6 +27,41 @@ kind: "package-library" 只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。 + +### 组件目录 + +在功能包里写控件之前,先查这张表。插件无法 import 另一个插件的组件,因此本包是控件唯一可以共享的地方:合适的就复用,有意的视觉差异提升成 prop,而不是另起一份拷贝。 + +| 导出 | 是什么 | +|---|---| +| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 | +| `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 | +| `Input` | 单行文本输入,用于搜索框与行内表单。 | +| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。 | +| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | +| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | +| `StateDot` | 状态标记:`done`、`warning`、`ongoing`、`error` 或 `idle`。它是 `aria-hidden` 的,名称由渲染点提供。 | +| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | +| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | +| `Modal` | 页面遮罩之上的居中对话框。 | +| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | +| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | +| `Tooltip` | 克隆锚点上的悬停文本,可置于四个方向之一。 | +| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | +| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | +| `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | +| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。 | +| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | + +有三组容易混淆: + +- **`Tag` 与 `Pill`。** `Pill` 可选中——它接受 `active` 与 `onClick`,驱动视图切换与筛选器。`Tag` 只读,两者都不接受。传了 `active` 却没有 `onClick`,说明你要的是 `Tag`。 +- **`DisclosureRow` 与卡片。** 该行以固定 24px 把标题与内容左右排列。把名称叠在描述之上的卡片是另一种布局,属于功能包——`ui-settings-plugins` 的 `PluginCard` 是先例,并记录了原因。 +- **`FoldToggle` 与对外导出面。** 它是包内组件,未导出;输出卡片用它做头尾折叠。 + +需求确实特殊时,在自己的包里写自己的组件没有问题。不可以的是复制这里已有的控件——而当第二个包需要同一个控件时,它就该住进本包([决定](../../../.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md))。 + ### 控件与图标 `Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 diff --git a/packages/client/ui-primitives/src/Pill.tsx b/packages/client/ui-primitives/src/Pill.tsx index 2c3c24c1ad..347b0a89f8 100644 --- a/packages/client/ui-primitives/src/Pill.tsx +++ b/packages/client/ui-primitives/src/Pill.tsx @@ -1,4 +1,5 @@ -// Pill: small rounded label chip (view switcher tabs, filters, badges). +// Pill: selectable capsule button (view switcher tabs, filters). The +// read-only badge is `Tag` — reach for it when there is no `onClick`. import type { ButtonHTMLAttributes, ReactNode } from 'react' import clsx from 'clsx' diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index 7faf3ad25f..265dd1b0a6 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -44,6 +44,12 @@ color: var(--dsw-alias-state-error-primary); } +/* Idle is the absence of activity, not a fourth outcome: it stays on the + * tertiary label color so it recedes beside the three outcome colors. */ +.dot[data-state='idle'] { + color: var(--dsw-alias-label-tertiary); +} + /* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe * holds, no tweening — the retro feel), peaking when the chase hits it and * decaying over the next three cells. Phase offsets come from per-rect diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index d2b9254e30..4cb2a6fa67 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -1,8 +1,11 @@ import clsx from 'clsx' import css from './StateDot.module.css' -/** Four-color state semantic (green done / amber user-attention / blue running ring / red error). */ -export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' +/** + * State semantic: green done / amber user-attention / blue running ring / + * red error / grey idle for a tracked subject with nothing in progress. + */ +export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' | 'idle' /** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ const MATRIX_CELLS: readonly (readonly [number, number])[] = [ diff --git a/packages/client/ui-primitives/src/Switch.module.css b/packages/client/ui-primitives/src/Switch.module.css new file mode 100644 index 0000000000..66dd4e3643 --- /dev/null +++ b/packages/client/ui-primitives/src/Switch.module.css @@ -0,0 +1,42 @@ +/* The on/off appearance keys off aria-checked rather than a parallel class, so + * the visual state cannot disagree with the state assistive technology reads. */ +.switch { + box-sizing: border-box; + position: relative; + flex: 0 0 auto; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switch[aria-checked='true'] { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switch[aria-checked='true'] .thumb { + transform: translateX(16px); +} diff --git a/packages/client/ui-primitives/src/Switch.tsx b/packages/client/ui-primitives/src/Switch.tsx new file mode 100644 index 0000000000..d21730a051 --- /dev/null +++ b/packages/client/ui-primitives/src/Switch.tsx @@ -0,0 +1,41 @@ +// Switch: two-state toggle. `label` is required and has no default, so a render +// site cannot ship the control without an accessible name. + +import clsx from 'clsx' +import css from './Switch.module.css' + +/** + * Render a toggle switch. + * @param props.checked - the current state; the control is fully controlled. + * @param props.onChange - called with the state the click asks for. + * @param props.label - localized accessible name, owned by the render site. + * @param props.disabled - whether the deployment allows the toggle at all. + * @param props.title - localized hover text, typically why the toggle is locked. + * @param props.className - extra class for layout placement. + * @returns the switch element. + */ +export function Switch({ checked, onChange, label, disabled = false, title, className }: { + checked: boolean + onChange: (next: boolean) => void + label: string + disabled?: boolean + title?: string | undefined + // `| undefined` so a caller can forward an optional class straight through + // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined). + className?: string | undefined +}) { + return ( + + ) +} diff --git a/packages/client/ui-primitives/src/Tag.module.css b/packages/client/ui-primitives/src/Tag.module.css new file mode 100644 index 0000000000..4586d034c3 --- /dev/null +++ b/packages/client/ui-primitives/src/Tag.module.css @@ -0,0 +1,55 @@ +/* Capsule geometry is fixed: a tag reads as one size everywhere, and only its + * palette varies. Tone colors ride background/border/color so a render site can + * still position the tag with its own class without touching the palette. */ +.tag { + display: inline-flex; + align-items: center; + border-radius: 999px; + corner-shape: round; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + font-weight: 500; + white-space: nowrap; +} + +.tag[data-tone='outline'] { + border: 0.5px solid var(--dsw-alias-border-l4); + color: var(--dsw-alias-label-tertiary); +} + +.tag[data-tone='solid'] { + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.tag[data-tone='neutral'] { + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); +} + +.tag[data-tone='quiet'] { + color: var(--dsw-alias-label-tertiary); +} + +/* Status tones tint their own color at 10% for the fill, so a palette change + * moves fill and text together and neither needs a second token. */ +.tag[data-tone='success'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.tag[data-tone='info'] { + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); + color: var(--dsw-alias-state-business-primary); +} + +.tag[data-tone='warning'] { + background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); + color: var(--dsw-alias-state-warn-primary); +} + +.tag[data-tone='danger'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-primitives/src/Tag.tsx b/packages/client/ui-primitives/src/Tag.tsx new file mode 100644 index 0000000000..b99e9ba208 --- /dev/null +++ b/packages/client/ui-primitives/src/Tag.tsx @@ -0,0 +1,42 @@ +// Tag: read-only capsule badge. The selectable capsule button is `Pill` — +// a tag takes no `onClick` and no `active`, and carries no copy of its own. + +import type { ReactNode } from 'react' +import clsx from 'clsx' +import css from './Tag.module.css' + +/** Palette selector; each tone names one shipped appearance. */ +export type TagTone = + /** Hairline outline on tertiary text: the read-only default. */ + | 'outline' + /** Inverted fill: one tag per group that names the current selection. */ + | 'solid' + /** Platform-gray fill: a neutral fact with no status meaning. */ + | 'neutral' + /** Text only, no fill: a fact stated more quietly than `neutral`. */ + | 'quiet' + /** Tinted green: a healthy or enabled state. */ + | 'success' + /** Tinted blue: informational classification, not health. */ + | 'info' + /** Tinted amber: attention needed, not yet a failure. */ + | 'warning' + /** Tinted red: a failure. */ + | 'danger' + +/** + * Render a read-only tag. + * @param props.tone - which palette to use (default `outline`). + * @param props.className - extra class for layout placement. + * @param props.children - the localized label, owned by the render site. + * @returns the tag element. + */ +export function Tag({ tone = 'outline', className, children }: { + tone?: TagTone + // `| undefined` so a caller can forward an optional class straight through + // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined). + className?: string | undefined + children?: ReactNode +}) { + return {children} +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 1f33f81a98..16199de859 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -9,6 +9,9 @@ export type { DisclosureRowProps } from './DisclosureRow.tsx' export { Button } from './Button.tsx' export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' +export { Tag } from './Tag.tsx' +export type { TagTone } from './Tag.tsx' +export { Switch } from './Switch.tsx' export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx' diff --git a/packages/client/ui-primitives/tests/state-dot-styles.client.spec.ts b/packages/client/ui-primitives/tests/state-dot-styles.client.spec.ts new file mode 100644 index 0000000000..1065fae748 --- /dev/null +++ b/packages/client/ui-primitives/tests/state-dot-styles.client.spec.ts @@ -0,0 +1,23 @@ +/** + * StateDot's palette as CSS text. jsdom has no layout and CSS Modules resolve + * to class-name maps in the component suites, so the only place the per-state + * colors can be read is the stylesheet itself: a state whose rule is missing + * renders on the inherited color instead of its own, which no render assertion + * would catch. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/StateDot.module.css', import.meta.url)), 'utf8') + +describe('StateDot.module.css', () => { + it.each(['done', 'warning', 'error', 'idle'] as const)('gives the %s state its own color rule', (state) => { + expect(css).toContain(`.dot[data-state='${state}']`) + }) + + it('keeps ongoing on the animated matrix rather than a solid-dot rule', () => { + expect(css).not.toContain(".dot[data-state='ongoing']") + expect(css).toContain('@keyframes dsh-state-dot-chase') + }) +}) diff --git a/packages/client/ui-primitives/tests/state-dot.client.spec.tsx b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx index 86e41c7787..0ca00d04a4 100644 --- a/packages/client/ui-primitives/tests/state-dot.client.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx @@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('StateDot', () => { - it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => { + it.each(['done', 'warning', 'ongoing', 'error', 'idle'] as const)('renders state %s as data-state', (state) => { const { container } = render() const dot = container.firstElementChild as HTMLElement expect(dot.dataset['state']).toBe(state) diff --git a/packages/client/ui-primitives/tests/switch.client.spec.tsx b/packages/client/ui-primitives/tests/switch.client.spec.tsx new file mode 100644 index 0000000000..a80eae48d3 --- /dev/null +++ b/packages/client/ui-primitives/tests/switch.client.spec.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Switch } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +describe('Switch', () => { + it('exposes its state and its caller-owned name to assistive technology', () => { + render( {}} />) + const control = screen.getByRole('switch', { name: 'Subagent model selection' }) + expect(control.getAttribute('aria-checked')).toBe('true') + }) + + it('reports the state the click asks for, not the state it has', () => { + const onChange = vi.fn() + const { rerender } = render() + fireEvent.click(screen.getByRole('switch')) + expect(onChange).toHaveBeenCalledWith(true) + + onChange.mockClear() + rerender() + fireEvent.click(screen.getByRole('switch')) + expect(onChange).toHaveBeenCalledWith(false) + }) + + it('stays silent while disabled', () => { + const onChange = vi.fn() + render() + const control = screen.getByRole('switch') + expect((control as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(control) + expect(onChange).not.toHaveBeenCalled() + }) + + it('carries a lock reason as the hover title when the owner names one', () => { + render( {}} />) + expect(screen.getByRole('switch').getAttribute('title')).toBe('Managed by policy') + }) + + it('never submits a surrounding form', () => { + const onSubmit = vi.fn((event: { preventDefault: () => void }) => { event.preventDefault() }) + render(
{}} />) + fireEvent.click(screen.getByRole('switch')) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('keeps a caller class alongside its own so a render site can place it', () => { + render( {}} />) + const control = screen.getByRole('switch') + expect(control.classList.contains('placed')).toBe(true) + expect(control.classList.length).toBeGreaterThan(1) + }) +}) diff --git a/packages/client/ui-primitives/tests/tag.client.spec.tsx b/packages/client/ui-primitives/tests/tag.client.spec.tsx new file mode 100644 index 0000000000..f208650d53 --- /dev/null +++ b/packages/client/ui-primitives/tests/tag.client.spec.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { Tag } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TagTone } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +const TONES = ['outline', 'solid', 'neutral', 'quiet', 'success', 'info', 'warning', 'danger'] as const + +describe('Tag', () => { + it('renders its caller-owned copy in a non-interactive span', () => { + render(Built-in) + const tag = screen.getByText('Built-in') + expect(tag.tagName).toBe('SPAN') + expect(tag.closest('button')).toBeNull() + }) + + it.each(TONES)('carries tone %s as data-tone', (tone) => { + const { container } = render(label) + const tag = container.firstElementChild as HTMLElement + expect(tag.dataset['tone']).toBe(tone) + }) + + it('defaults to the outline tone', () => { + const { container } = render(label) + expect((container.firstElementChild as HTMLElement).dataset['tone']).toBe('outline') + }) + + it('keeps a caller class alongside its own so a render site can place it', () => { + const { container } = render(label) + const tag = container.firstElementChild as HTMLElement + expect(tag.classList.contains('placed')).toBe(true) + expect(tag.classList.length).toBeGreaterThan(1) + }) + + it('rejects unknown tones at the type level', () => { + const bad = (tone: TagTone) => tone + // @ts-expect-error 'muted' is not one of the eight tones + expect(bad('muted')).toBe('muted') + }) +}) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 7bc8daf3df..d914492230 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -168,59 +168,11 @@ color: var(--dsw-alias-label-tertiary); } -.statusDot { - display: inline-block; - width: 7px; - height: 7px; - flex: none; - border-radius: 999px; - corner-shape: round; - background: var(--dsw-alias-label-tertiary); -} - -.statusDot[data-phase='active'] { - background: var(--dsw-alias-state-success-primary); -} - -.statusDot[data-phase='failed'] { - background: var(--dsw-alias-state-error-primary); -} - -.statusDot[data-phase='loading'] { - background: var(--dsw-alias-state-business-primary); -} - -.configTag { +/* Accessible-name and layout wrapper: StateDot is aria-hidden and carries no + name, and the row needs the phase to stay on one flex line. */ +.phaseDot { display: inline-flex; - align-items: center; - min-height: 20px; - border-radius: 5px; - padding: 1px 6px; - background: var(--dsw-alias-bg-layer-1); - color: var(--dsw-alias-label-secondary); - font-size: 11px; - line-height: 16px; - white-space: nowrap; -} - -.configTag[data-kind='enabled'] { - background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); - color: var(--dsw-alias-state-success-primary); -} - -.configTag[data-kind='preset'] { - background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); - color: var(--dsw-alias-state-business-primary); -} - -.configTag[data-kind='conditional'] { - background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b45309) 12%, transparent); - color: var(--dsw-alias-state-warning-primary, #b45309); -} - -.configTag[data-kind='failed'] { - background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); - color: var(--dsw-alias-state-error-primary); + flex: none; } .group { diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 058406baaf..4b0a0a8e5b 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -4,7 +4,10 @@ import { IconChevronDownOutline14, IconSearchOutline16, Menu, + StateDot, + Tag, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { StateDotState, TagTone } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginInventoryLocaleKey } from './locales.ts' import css from './PluginInventorySettingsTab.module.css' @@ -147,23 +150,41 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: { ) } +/* `pending` and `unloading` both mean no work is in progress, which is what + * `idle` marks; only `loading` is activity the dot animates. */ +const PHASE_DOT_STATES = { + pending: 'idle', + loading: 'ongoing', + active: 'done', + failed: 'error', + unloading: 'idle', +} as const satisfies Record, StateDotState> + /** Status dot naming a live root-fiber phase; rows with no live fiber show none. */ function PhaseDot({ phase, t }: { readonly phase: NonNullable; readonly t: Translate }): ReactNode { const status = phaseLabel(phase, t) + /* StateDot is aria-hidden, so the phase name lives on this wrapper. */ return ( - + + + ) } +/** Enablement states one inventory row can report. */ +type EnablementKind = 'enabled' | 'disabled' | 'conditional' | 'preset' | 'failed' + +const TAG_TONES = { + enabled: 'success', + disabled: 'neutral', + conditional: 'warning', + preset: 'info', + failed: 'danger', +} as const satisfies Record + /** Enablement tag; `kind` selects the palette. */ -function StateTag({ kind, label }: { readonly kind: string; readonly label: string }): ReactNode { - return {label} +function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly label: string }): ReactNode { + return {label} } /** Render the read-only plugin inventory: agent presets first, then the global plane. */ diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.module.css b/packages/client/ui-settings-plugins/src/client/PluginCard.module.css index d98d43ebc2..a146c5792e 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.module.css @@ -86,17 +86,9 @@ } /* Carried on the header so a collapsed card still says it holds edits. */ +/* Placement only: the capsule geometry and palette come from `Tag`. */ .pending { flex: none; - border-radius: 999px; - corner-shape: round; - padding: 1px 8px; - font-size: 11px; - line-height: 17px; - font-weight: 500; - white-space: nowrap; - background: var(--dsw-alias-bg-module-platform); - color: var(--dsw-alias-label-secondary); } .footer { diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx index 472a9f0e85..710fb90e06 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx @@ -16,7 +16,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutline14, Tag } from '@deepseek-ai/dsh-client-ui-primitives' import type { CardShell } from './card-form.ts' import type { PluginsSettingsLocaleKey } from './locales.ts' import css from './PluginCard.module.css' @@ -75,7 +75,7 @@ export function PluginCard(props: PluginCardProps) { {title} {props.t(props.descriptionKey)} - {state.dirty ? {props.t('unsaved')} : null} + {state.dirty ? {props.t('unsaved')} : null} {open diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 580dfa6bc2..ed215ec210 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -19,47 +19,6 @@ min-width: 0; } -.switch { - box-sizing: border-box; - position: relative; - flex: 0 0 auto; - width: 36px; - height: 20px; - padding: 2px; - border: 0; - border-radius: 10px; - background: var(--dsw-alias-border-l3); - cursor: pointer; -} - -.switchOn { - background: var(--dsw-alias-brand-primary); -} - -.switch:disabled { - cursor: default; - opacity: 0.5; -} - -.switch:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; -} - -.thumb { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - corner-shape: round; - background: var(--dsw-alias-label-primary-foreground); - transition: transform 120ms ease; -} - -.switchOn .thumb { - transform: translateX(16px); -} - .selection { display: grid; gap: 10px; diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index e5ba1ffc11..132eef9a1c 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -1,6 +1,6 @@ /** User control for model-selectable subagent delegation in new sessions. */ -import clsx from 'clsx' +import { Switch } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SubagentModelCandidate, @@ -73,17 +73,12 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
{t('subagentModelSelectionToggle')} - + onChange={props.toggleEnabled} + />

{t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')} diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-settings-plugins/src/client/fields.module.css index b0ab9eaef9..2963c590b8 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-settings-plugins/src/client/fields.module.css @@ -32,28 +32,6 @@ gap: 8px; } -.badge { - border-radius: 999px; - corner-shape: round; - padding: 1px 8px; - font-size: 11px; - line-height: 17px; - white-space: nowrap; - font-weight: 500; - background: var(--dsw-alias-bg-module-platform); - color: var(--dsw-alias-label-secondary); -} - -.badgeMuted { - border-radius: 999px; - corner-shape: round; - padding: 1px 8px; - font-size: 11px; - line-height: 17px; - white-space: nowrap; - color: var(--dsw-alias-label-tertiary); -} - .reset { border: none; background: none; diff --git a/packages/client/ui-settings-plugins/src/client/fields.tsx b/packages/client/ui-settings-plugins/src/client/fields.tsx index a30998ddad..6131caf1a7 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.tsx +++ b/packages/client/ui-settings-plugins/src/client/fields.tsx @@ -6,6 +6,7 @@ * card's save is the single point where a draft becomes a document mutation. */ +import { Tag } from '@deepseek-ai/dsh-client-ui-primitives' import css from './fields.module.css' /** What every field control needs regardless of its value type. */ @@ -56,7 +57,7 @@ export function ValueField(props: FieldProps & { {props.overridden ? ( - {props.overriddenLabel} + {props.overriddenLabel}

Date: Sat, 5 Sep 2026 14:54:42 +0800 Subject: [PATCH 2/4] fix(client): animate the unloading phase dot and correct the catalog `unloading` marks a teardown the Host is running, which an async disposer can hold for a while, so the inventory's dot animates it like `loading` instead of showing the static grey `idle` mark that means no activity. Correct four documentation facts against the shipped code: StateDot's render JSDoc still promised four states, the catalog claimed four Tooltip sides where TooltipSide has three, the Agent Note's geometry audit counted three matching rules where two match, and the migrated-site and shipped- appearance counts had drifted. Record two things the first pass left out: TerminalBlock renders its exit status as a static Pill, which the Tag/Pill catalog entry now accounts for by size rather than by interactivity alone, and the disabled inventory tag changed fill token, which joins the visual-change list. Pin Tag's eight tones with a stylesheet spec, for the same reason StateDot has one: CSS Modules resolve to class-name maps in component suites, so a missing or wrong tone rule passes every render assertion. --- ...shared-client-control-primitives.i18n.yaml | 4 +- ...-09-05-shared-client-control-primitives.md | 9 +++-- ...-05-shared-client-control-primitives.zh.md | 9 +++-- packages/client/AGENTS.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +-- packages/client/ui-primitives/README.zh.md | 6 +-- .../client/ui-primitives/src/StateDot.tsx | 2 +- packages/client/ui-primitives/src/Switch.tsx | 3 +- .../client/ui-primitives/src/Tag.module.css | 6 ++- .../tests/state-dot.client.spec.tsx | 2 +- .../tests/tag-styles.client.spec.ts | 39 +++++++++++++++++++ .../src/client/PluginInventorySettingsTab.tsx | 7 ++-- 13 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 packages/client/ui-primitives/tests/tag-styles.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml index 11cf1f37f3..89a705592d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md -2026-09-05-shared-client-control-primitives.md: 2847f52ccd9d8134fd71e97fcf964a5bfa6daac2 -2026-09-05-shared-client-control-primitives.zh.md: 1071736e52a6978a0ad8ddfd91976d32605f5af9 +2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0 +2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md index 2847f52ccd..14128497cf 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md @@ -26,7 +26,7 @@ The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AG ## Finding the duplicates -A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly three rules — `Tag` itself, the broken badge below, and the `flex: none` placement class the unsaved marker kept. +A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches. ## What stays local @@ -37,6 +37,7 @@ A mechanical search groups these controls with the promoted three. They stay in - **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance. - **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge. - **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one. +- **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so. - **`ui-agent-preset`'s broken badge** shares the capsule geometry but carries a solid error fill that no second site uses, and it is the hover anchor for a tooltip element of its own. `Tag` would have to keep a palette override in the feature stylesheet and depend on cross-file CSS ordering to win it. ## Alternatives considered @@ -49,7 +50,7 @@ A mechanical search groups these controls with the promoted three. They stay in **Extending `Pill` instead of adding `Tag`.** Rejected. `Pill` is 24px tall on a 12px radius with 12px text; the badge baseline is denser and rounder. Merging them would produce one component whose size depends on whether `onClick` is present, and would erase the read-only/selectable distinction that the catalog needs in order to answer "which one do I want". -**A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which six ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. +**A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. **Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. @@ -59,7 +60,7 @@ A mechanical search groups these controls with the promoted three. They stay in `Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices. -The four migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. +The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request. @@ -67,7 +68,7 @@ That is also the limit of the automated evidence. No gate in this repository com - A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports. - `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument. -- The plugin inventory's tags change from a 5px rectangle to a capsule and its phase dots gain a halo and, in the loading phase, an animation. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. +- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. - The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids. - The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists. - `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies. diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md index 1071736e52..66268b48f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md @@ -26,7 +26,7 @@ Status: implemented ## 如何找出重复 -按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配三条规则——`Tag` 本身、下面那个损坏徽章,以及未保存标记保留下来的 `flex: none` 布局类。 +按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。 ## 保留在原包的控件 @@ -37,6 +37,7 @@ Status: implemented - **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。 - **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。 - **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。 +- **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。 - **`ui-agent-preset` 的损坏徽章**共用胶囊几何,但带着没有第二处使用的实底错误色填充,而且它是自带提示元素的悬停锚点。改用 `Tag` 就得在功能包样式表里保留一份配色覆盖,并依赖跨文件 CSS 顺序来让它生效。 ## Alternatives considered @@ -49,7 +50,7 @@ Status: implemented **扩展 `Pill` 而不是新增 `Tag`。** 否决。`Pill` 高 24px、圆角 12px、字号 12px;徽章基准更密也更圆。合并会产生一个尺寸取决于是否传了 `onClick` 的组件,并抹掉只读与可选中的区分——而目录正需要这个区分来回答"我该用哪个"。 -**给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中六种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 +**给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 **让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 @@ -59,7 +60,7 @@ Status: implemented `Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。 -四个迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 +迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。 @@ -67,7 +68,7 @@ Status: implemented - 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。 - `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。 -- 插件清单的标签从 5px 矩形变为胶囊,相位点获得光晕,loading 相位还会有动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 +- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 - 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。 - 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。 - `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 26fa1309ec..03ac78d63b 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -144,7 +144,7 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com ## New component checklist -1. **Check the [ui-primitives catalog](ui-primitives/README.md#component-catalog) before writing a control.** A plugin cannot import another plugin's component, so `ui-primitives` is the only place a control can be shared: reuse the primitive that already fits, and lift a deliberate visual difference into a prop rather than starting a second copy. Writing your own component in your own package is fine when the need is genuinely specific — what is not fine is copying a control that already exists. Once a second package needs the same control, promote it. +1. **Check the [ui-primitives catalog](ui-primitives/README.md#component-catalog) before writing a control.** A plugin cannot import another plugin's component, so `ui-primitives` is the only place a control can be shared; the catalog states when to reuse, when to promote, and when your own package is the right home. 2. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [Slots reference](../../docs/subsystems/slots.md). No other composition route exists. 3. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 5438d90de1..5f093e3fbc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: de407ca491309c27b69e6ddbecad903b90f19e66 -README.zh.md: 2bfa4e5bdb9d55803ad3ed71d51e0f14ddf6aff6 +README.md: 88a61fb3601db62aa5355861234469c760b1eb20 +README.zh.md: 35cbbaadd6b44b64fe7bcefad02f748a1b932982 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index de407ca491..88a61fb360 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Modal` | Centered dialog over a page mask. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | -| `Tooltip` | Hover text on a cloned anchor, placed on one of four sides. | +| `Tooltip` | Hover text on a cloned anchor, placed right, bottom, or top. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | @@ -56,7 +56,7 @@ Check this table before writing a control in a feature package. A plugin cannot Three pairs are easy to confuse: -- **`Tag` against `Pill`.** `Pill` is selectable — it takes `active` and `onClick` and drives view switchers and filters. `Tag` is read-only and takes neither. Passing `active` without `onClick` means you want `Tag`. +- **`Tag` against `Pill`.** Reach for `Tag` for a read-only badge at the 11px capsule size, and for `Pill` when the capsule is selectable (`active` and `onClick`, as view switchers and filters use) or when it must sit on a 24px text line — `TerminalBlock` renders its exit status as a static `Pill` for exactly that reason. Size decides as much as interactivity here; the two are not interchangeable. - **`DisclosureRow` against a card.** The row lays its title and content side by side at a fixed 24px. A card that stacks a name over a description is a different layout, and belongs in the feature package — `ui-settings-plugins`' `PluginCard` is the precedent and records why. - **`FoldToggle` against the exported surface.** It is package-internal and not exported; the output cards use it for their head-tail fold. @@ -64,7 +64,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2bfa4e5bdb..35cbbaadd6 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Modal` | 页面遮罩之上的居中对话框。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | -| `Tooltip` | 克隆锚点上的悬停文本,可置于四个方向之一。 | +| `Tooltip` | 克隆锚点上的悬停文本,可置于右、下、上三个方向。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | @@ -56,7 +56,7 @@ kind: "package-library" 有三组容易混淆: -- **`Tag` 与 `Pill`。** `Pill` 可选中——它接受 `active` 与 `onClick`,驱动视图切换与筛选器。`Tag` 只读,两者都不接受。传了 `active` 却没有 `onClick`,说明你要的是 `Tag`。 +- **`Tag` 与 `Pill`。** 11px 胶囊尺寸的只读徽章用 `Tag`;胶囊可选中(`active` 与 `onClick`,视图切换与筛选器就是这样用的),或者必须落在 24px 文本行上时用 `Pill`——`TerminalBlock` 把退出状态渲染成静态 `Pill` 正是后一种情况。这里尺寸和是否可交互同样是判据,两者不可互换。 - **`DisclosureRow` 与卡片。** 该行以固定 24px 把标题与内容左右排列。把名称叠在描述之上的卡片是另一种布局,属于功能包——`ui-settings-plugins` 的 `PluginCard` 是先例,并记录了原因。 - **`FoldToggle` 与对外导出面。** 它是包内组件,未导出;输出卡片用它做头尾折叠。 @@ -64,7 +64,7 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index 4cb2a6fa67..0cc825e75a 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -14,7 +14,7 @@ const MATRIX_CELLS: readonly (readonly [number, number])[] = [ /** * Render a state dot. - * @param props.state - which of the four states to show. + * @param props.state - which of `done`, `warning`, `ongoing`, `error`, or `idle` to show. * @param props.size - outer diameter in px (default 10, the figma size). * @param props.className - extra class for layout placement. * @returns the dot element (aria-hidden; pair with text for accessibility). diff --git a/packages/client/ui-primitives/src/Switch.tsx b/packages/client/ui-primitives/src/Switch.tsx index d21730a051..980a08f6be 100644 --- a/packages/client/ui-primitives/src/Switch.tsx +++ b/packages/client/ui-primitives/src/Switch.tsx @@ -9,7 +9,8 @@ import css from './Switch.module.css' * @param props.checked - the current state; the control is fully controlled. * @param props.onChange - called with the state the click asks for. * @param props.label - localized accessible name, owned by the render site. - * @param props.disabled - whether the deployment allows the toggle at all. + * @param props.disabled - whether the control refuses input; owners also set it + * while a write is in flight, not only when a deployment locks the toggle. * @param props.title - localized hover text, typically why the toggle is locked. * @param props.className - extra class for layout placement. * @returns the switch element. diff --git a/packages/client/ui-primitives/src/Tag.module.css b/packages/client/ui-primitives/src/Tag.module.css index 4586d034c3..65380320ac 100644 --- a/packages/client/ui-primitives/src/Tag.module.css +++ b/packages/client/ui-primitives/src/Tag.module.css @@ -32,8 +32,10 @@ color: var(--dsw-alias-label-tertiary); } -/* Status tones tint their own color at 10% for the fill, so a palette change - * moves fill and text together and neither needs a second token. */ +/* Status tones tint their own color for the fill, so a palette change moves + * fill and text together and neither needs a second token. The tint is 10%, + * except `warning`, which keeps the 12% the plugin inventory's conditional + * tag shipped with — matching it is what makes this a pure consolidation. */ .tag[data-tone='success'] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); diff --git a/packages/client/ui-primitives/tests/state-dot.client.spec.tsx b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx index 0ca00d04a4..309f2e9c00 100644 --- a/packages/client/ui-primitives/tests/state-dot.client.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx @@ -40,7 +40,7 @@ describe('StateDot', () => { it('rejects unknown states at the type level', () => { const bad = (state: StateDotState) => state - // @ts-expect-error 'paused' is not one of the four states + // @ts-expect-error 'paused' is not one of the five states expect(bad('paused')).toBe('paused') }) }) diff --git a/packages/client/ui-primitives/tests/tag-styles.client.spec.ts b/packages/client/ui-primitives/tests/tag-styles.client.spec.ts new file mode 100644 index 0000000000..ecaa6b124b --- /dev/null +++ b/packages/client/ui-primitives/tests/tag-styles.client.spec.ts @@ -0,0 +1,39 @@ +/** + * Tag's palette as CSS text. CSS Modules resolve to class-name maps in the + * component suites, so a tone whose rule is missing renders on the inherited + * color and `tag.client.spec.tsx` — which asserts the `data-tone` attribute — + * still passes. Only the stylesheet can prove each tone paints something. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/Tag.module.css', import.meta.url)), 'utf8') + +const TONES = ['outline', 'solid', 'neutral', 'quiet', 'success', 'info', 'warning', 'danger'] as const + +function declarations(tone: string): string { + const rule = new RegExp(`\\.tag\\[data-tone='${tone}'\\]\\s*\\{([^}]*)\\}`).exec(css) + if (rule === null) throw new Error(`Tag.module.css has no rule for the \`${tone}\` tone`) + return rule[1] ?? '' +} + +describe('Tag.module.css', () => { + it.each(TONES)('gives the %s tone a rule that paints', (tone) => { + expect(declarations(tone)).toMatch(/color:|background:|border:/) + }) + + it('tints every status tone at 10% except the warning the inventory shipped', () => { + for (const tone of ['success', 'info', 'danger'] as const) { + expect(declarations(tone)).toContain('10%, transparent') + } + expect(declarations('warning')).toContain('12%, transparent') + }) + + it('keeps the capsule geometry on the base rule, not per tone', () => { + const base = /^\.tag \{([^}]*)\}/m.exec(css)?.[1] ?? '' + expect(base).toContain('border-radius: 999px') + expect(base).toContain('padding: 1px 8px') + for (const tone of TONES) expect(declarations(tone)).not.toContain('border-radius') + }) +}) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 4b0a0a8e5b..31e37a6ca0 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -150,14 +150,15 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: { ) } -/* `pending` and `unloading` both mean no work is in progress, which is what - * `idle` marks; only `loading` is activity the dot animates. */ +/* `pending` is the only phase with no work under way. `loading` and + * `unloading` are both live transitions the Host is running — an async + * disposer can hold `unloading` for a while — so both animate. */ const PHASE_DOT_STATES = { pending: 'idle', loading: 'ongoing', active: 'done', failed: 'error', - unloading: 'idle', + unloading: 'ongoing', } as const satisfies Record, StateDotState> /** Status dot naming a live root-fiber phase; rows with no live fiber show none. */ From 853de8ad0b2d54a042f8455eabce02ac72c7fbeb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 15:04:17 +0800 Subject: [PATCH 3/4] docs(client): finish the five-state and Pill/Tag corrections The `idle` addition left two maintainer-facing statements behind: the README's limitations entry still listed four supported StateDot states, and TerminalBlock's run-state JSDoc still counted the union as four while correctly saying three of them are reachable there. Pill's header comment still sent every capsule without `onClick` to Tag, which contradicts both the catalog this pull request corrected and TerminalBlock's own static read-only Pill two files away. Size separates the two controls as much as interactivity does, and the comment now says so. The function JSDoc and the static-mode tests stay as they are: they describe supported behavior. --- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Pill.tsx | 6 ++++-- packages/client/ui-primitives/src/TerminalBlock.tsx | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 5f093e3fbc..f23f2466f7 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 88a61fb3601db62aa5355861234469c760b1eb20 -README.zh.md: 35cbbaadd6b44b64fe7bcefad02f748a1b932982 +README.md: ca94639053913d7a1b0e14966c6bd0fecfdfc646 +README.zh.md: 9dface29c79f6ae62db85d18a49d6d5e1430a181 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 88a61fb360..ca94639053 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -141,7 +141,7 @@ These limits define how the atoms behave at the edges; they are current package - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. -- **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, and error. +- **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle. - **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR colors, carriage return, backspace, erase-in-line, tab stops, and character width are honored; absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 35cbbaadd6..9dface29c7 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -141,7 +141,7 @@ kind: "package-library" - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 -- **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。 +- **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。 - **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过 primitive 的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色、回车、退格、行内擦除、制表位与字符宽度会被遵循;绝对光标定位、清屏与备用屏幕序列会被剥离。 diff --git a/packages/client/ui-primitives/src/Pill.tsx b/packages/client/ui-primitives/src/Pill.tsx index 347b0a89f8..8e2762c714 100644 --- a/packages/client/ui-primitives/src/Pill.tsx +++ b/packages/client/ui-primitives/src/Pill.tsx @@ -1,5 +1,7 @@ -// Pill: selectable capsule button (view switcher tabs, filters). The -// read-only badge is `Tag` — reach for it when there is no `onClick`. +// Pill: capsule at the 24px text-line size, selectable when given `onClick` +// (view switcher tabs, filters) and a static span otherwise — TerminalBlock's +// exit status is the read-only case. The 11px read-only badge is `Tag`; size +// separates the two as much as interactivity does. import type { ButtonHTMLAttributes, ReactNode } from 'react' import clsx from 'clsx' diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 26e26fe2d2..173a7e317f 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -103,7 +103,7 @@ function statusText( * Run-state indicator for the command, shown at the head of the prompt line so * the card states whether the command is still running without the reader * having to infer it from the presence of output. Three of {@link StateDotState}'s - * four states are reachable: the running chase (the same + * five states are reachable: the running chase (the same * indicator a running tool row's leading icon uses, so the row and its card * never disagree), green for a clean settle, red for a signal or a non-zero * exit — the same status distinction {@link statusText} draws for the pill. A From c279350e03990953f2cc4df0c9802acb2e825cf8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 15:11:25 +0800 Subject: [PATCH 4/4] fix(client): keep the Switch track capsule under the global corner shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The track's radius is half its height, so its corners consume the whole side and the global `superellipse(1.5)` squares the ends off — beside a thumb that already opts out at `border-radius: 50%`, leaving a round knob in a squared-off slot. Pair `corner-shape: round` with the radius, the way every full-round radius in the repository does. The corner-shape stylesheet spec recognizes 50%, 100%, and radii at or above 99px, so a radius that is full-round only relative to its own box passes it; the comment records that gap at the rule it affects. --- packages/client/ui-primitives/src/Switch.module.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-primitives/src/Switch.module.css b/packages/client/ui-primitives/src/Switch.module.css index 66dd4e3643..c038331a66 100644 --- a/packages/client/ui-primitives/src/Switch.module.css +++ b/packages/client/ui-primitives/src/Switch.module.css @@ -1,5 +1,12 @@ /* The on/off appearance keys off aria-checked rather than a parallel class, so - * the visual state cannot disagree with the state assistive technology reads. */ + * the visual state cannot disagree with the state assistive technology reads. + * + * The track is a capsule: its radius is half its height, so the corners consume + * the whole side and the global superellipse would square the ends off against + * the round thumb inside it. `corner-shape: round` opts the track out, the way + * every full-round radius does. The corner-shape spec does not catch this one — + * it recognizes 50%, 100%, and radii at or above 99px, not a radius that is + * full-round only relative to its own box. */ .switch { box-sizing: border-box; position: relative; @@ -9,6 +16,7 @@ padding: 2px; border: 0; border-radius: 10px; + corner-shape: round; background: var(--dsw-alias-border-l3); cursor: pointer; }