Ver Fonte

refactor(gui): copy-free settings shell; ui-settings-general owns ownerless copy

The shell is now a pure composition face: no dictionaries, no locale
dependency, and three new chrome content seats (settings.trigger /
settings.header / settings.close) whose slot content also carries the
accessible names (trigger text, dialog aria-labelledby, visually hidden
close label). ui-settings-general returns as the owner of copy that
belongs to no single feature: chrome content, the General section with
its item slot, and the settings dictionaries. Slot types split homes —
trigger/header/close/section live in the shell contract; the
settings.general.item entry moves to the locale package (the common
dependency of every item registrant), with ui-theme consuming it
through a re-export seam; the verbatim duplicate merges are gone and
the dependency graph is a clean DAG.
imccyu há 1 mês atrás
pai
commit
a04a223bab
41 ficheiros alterados com 806 adições e 334 exclusões
  1. 2 2
      .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml
  2. 11 7
      .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
  3. 11 7
      .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md
  4. 3 0
      apps/cli/cordis.yml
  5. 3 2
      apps/cli/package.json
  6. 3 0
      apps/cli/tsconfig.json
  7. 2 1
      apps/web/tests/session-title.snapshot.ts
  8. 1 1
      apps/web/tests/smoke-real.e2e.ts
  9. 2 1
      apps/web/tests/workspace-flow.snapshot.ts
  10. 1 0
      packages/client/locale/src/client/index.ts
  11. 19 11
      packages/client/locale/src/client/settings-contract.ts
  12. 15 0
      packages/client/ui-settings-general/README.md
  13. 67 0
      packages/client/ui-settings-general/package.json
  14. 0 0
      packages/client/ui-settings-general/src/client/GeneralSection.module.css
  15. 16 6
      packages/client/ui-settings-general/src/client/GeneralSection.tsx
  16. 7 0
      packages/client/ui-settings-general/src/client/chrome.module.css
  17. 56 0
      packages/client/ui-settings-general/src/client/chrome.tsx
  18. 87 0
      packages/client/ui-settings-general/src/client/index.ts
  19. 0 0
      packages/client/ui-settings-general/src/client/locales.ts
  20. 6 0
      packages/client/ui-settings-general/src/css-modules.d.ts
  21. 4 0
      packages/client/ui-settings-general/src/index.ts
  22. 32 0
      packages/client/ui-settings-general/src/invariant.ts
  23. 150 0
      packages/client/ui-settings-general/tests/apply.spec.ts
  24. 37 16
      packages/client/ui-settings-general/tests/components.spec.tsx
  25. 18 0
      packages/client/ui-settings-general/tests/invariant.spec.ts
  26. 33 0
      packages/client/ui-settings-general/tsconfig.json
  27. 3 0
      packages/client/ui-settings-general/tsdown.config.ts
  28. 2 2
      packages/client/ui-settings/README.md
  29. 1 3
      packages/client/ui-settings/package.json
  30. 10 0
      packages/client/ui-settings/src/client/SettingsRoot.module.css
  31. 21 22
      packages/client/ui-settings/src/client/SettingsRoot.tsx
  32. 50 47
      packages/client/ui-settings/src/client/contract/slots.ts
  33. 20 56
      packages/client/ui-settings/src/client/index.ts
  34. 30 114
      packages/client/ui-settings/tests/apply.spec.ts
  35. 41 16
      packages/client/ui-settings/tests/settings-root.spec.tsx
  36. 0 3
      packages/client/ui-settings/tsconfig.json
  37. 7 15
      packages/client/ui-theme/src/client/settings-contract.ts
  38. 32 2
      pnpm-lock.yaml
  39. 1 0
      scripts/verify-package-readme-model-experience.ts
  40. 1 0
      tsconfig.base.json
  41. 1 0
      tsconfig.client.json

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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
-2026-07-25-client-settings-locale-theme.md: 0cfa244f7e9856aede3d01a80404a794374e16be
-2026-07-25-client-settings-locale-theme.zh.md: 7caa79d5409b2a11078efb8e52273dc70f89f085
+2026-07-25-client-settings-locale-theme.md: b6a127037c50066fe9aa501bb73005b9c56869fa
+2026-07-25-client-settings-locale-theme.zh.md: a871f06945fb420016df95b23167e72f59c3e5c5

+ 11 - 7
.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md

@@ -10,9 +10,9 @@ The browser client's existing Settings is written directly inside the Sidebar, a
 
 ## Proposal
 
-**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell provides only the composition surface (the top-level section list plus the item list inside General) and neither imports nor enumerates any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). The only content the shell carries itself is the first top-level directory, General (skeleton rows plus the item slot declaration), because it belongs to no single feature.
+**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell is a pure composition surface: it only declares slots and renders the chrome structure — zero copy, no locale dependency, and neither importing nor enumerating any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). Content that belongs to no single feature (the trigger/title/close chrome copy, the General directory with its skeleton rows, the `settings` dictionary) is owned by `ui-settings-general` — the owner of the ownerless copy, not a feature satellite package.
 
-The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by a feature plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by the shell itself (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order.
+The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares four slots: `settings.trigger` / `settings.header` / `settings.close` (chrome content seats, single) and `settings.section` (top-level pages, list). Accessible names all resolve from slot content: the trigger's accessible name is its text content, the dialog points at the header content node via aria-labelledby, and close is a visually hidden text seat. Each section is contributed by a feature plugin; the shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by `ui-settings-general` (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order.
 
 The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind.
 
@@ -28,13 +28,14 @@ The theme service never touches the DOM. `ui-layout` reads the Theme getter init
 
 | Registration surface | Owning plugin | First-phase content |
 |---|---|---|
-| General section (order 0) | built into the `ui-settings` shell | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration |
+| chrome content (trigger/header/close) | `ui-settings-general` | Settings entry-row icon and copy, panel title, close hidden text |
+| General section (order 0) | `ui-settings-general` | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration |
 | Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch |
 | Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) |
 | Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package |
 | Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) |
 
-The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — shell copy (the chrome plus the General skeletons) lives in the `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`).
+The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — the chrome plus the General skeletons live in `ui-settings-general`'s `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`).
 
 ### Slot topology
 
@@ -42,16 +43,19 @@ The first phase localizes only the copy inside the Settings overlay; dictionarie
 root
 └─ sidebar
    └─ sidebar.settings                   single/root
-      └─ ui-settings(壳)
+      └─ ui-settings(壳,零文案)
+         ├─ settings.trigger             single/root  ui-settings-general 注册
+         ├─ settings.header              single/root  ui-settings-general 注册
+         ├─ settings.close               single/root  ui-settings-general 注册
          └─ settings.section             list/root
-            ├─ general (order 0)         ui-settings 壳自带
+            ├─ general (order 0)         ui-settings-general 注册
             │  └─ settings.general.item  list/root
             │     ├─ language (0)        locale 注册
             │     └─ appearance (10)     ui-theme 注册
             └─ models (order 10)         ui-models 注册
 ```
 
-Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The `settings.general.item` SlotMap entry's canonical home is the ui-settings contract; locale/ui-theme, because of the reference cycle (the shell consumes ctx.locale), consume that entry as verbatim duplicated merges, with declaration merging guaranteeing the copies agree.
+Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
 
 ### Future work: promote slot declarations to first-class injectable waits
 

+ 11 - 7
.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md

@@ -10,9 +10,9 @@ Status: proposed
 
 ## Proposal
 
-**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳只提供组合面(一级 section 列表 + General 内的 item 列表),不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。壳自带的唯一内容是第一个一级目录 General(骨架行 + item 坑位声明),因为它不属于任何单一功能
+**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包
 
-Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由功能插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由壳自己注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。
+Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明四个坑:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。
 
 Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。
 
@@ -28,13 +28,14 @@ Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订
 
 | 注册面 | 属主插件 | 首期内容 |
 |---|---|---|
-| General section(order 0)| `ui-settings` 壳自带 | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 |
+| chrome 内容(trigger/header/close)| `ui-settings-general` | 设置入口行图标+文案、面板标题、close 隐藏文本 |
+| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 |
 | Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 |
 | Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) |
 | Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 |
 | Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) |
 
-首期只翻译 Settings 浮层内文案;字典就近——壳文案(chrome + General 骨架归 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。
+首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。
 
 ### Slot topology
 
@@ -42,16 +43,19 @@ Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订
 root
 └─ sidebar
    └─ sidebar.settings                   single/root
-      └─ ui-settings(壳)
+      └─ ui-settings(壳,零文案)
+         ├─ settings.trigger             single/root  ui-settings-general 注册
+         ├─ settings.header              single/root  ui-settings-general 注册
+         ├─ settings.close               single/root  ui-settings-general 注册
          └─ settings.section             list/root
-            ├─ general (order 0)         ui-settings 壳自带
+            ├─ general (order 0)         ui-settings-general 注册
             │  └─ settings.general.item  list/root
             │     ├─ language (0)        locale 注册
             │     └─ appearance (10)     ui-theme 注册
             └─ models (order 10)         ui-models 注册
 ```
 
-section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contract;locale/ui-theme 因引用环(壳消费 ctx.locale)以逐字重复合并的方式消费该条目,declaration merging 保证副本一致
+section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费
 
 ### Future work:坑位声明升格为可 inject 的一等等待物
 

+ 3 - 0
apps/cli/cordis.yml

@@ -233,6 +233,9 @@
 - id: ui-settings
   name: '@deepseek-ai/dsh-client-ui-settings'
 
+- id: ui-settings-general
+  name: '@deepseek-ai/dsh-client-ui-settings-general'
+
 - id: ui-models
   name: '@deepseek-ai/dsh-client-ui-models'
 

+ 3 - 2
apps/cli/package.json

@@ -28,10 +28,11 @@
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-models": "workspace:^",
     "@deepseek-ai/dsh-client-ui-question": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-models": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
     "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",

+ 3 - 0
apps/cli/tsconfig.json

@@ -44,6 +44,9 @@
     {
       "path": "../../packages/client/ui-settings"
     },
+    {
+      "path": "../../packages/client/ui-settings-general"
+    },
     {
       "path": "../../packages/client/ui-models"
     },

+ 2 - 1
apps/web/tests/session-title.snapshot.ts

@@ -13,7 +13,8 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
   { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] },
+  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
+  { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
   { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
   { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
   { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },

+ 1 - 1
apps/web/tests/smoke-real.e2e.ts

@@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise<number> {
 // Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
 // plugin's client bundle exists and exports apply, the loader fail-louds and
 // the frame never appears.
-const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
+const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', 'ui-question', 'ui-trajectory']
 const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
 const notReady = UI_PLUGIN_DIRS.filter((dir) => {
   const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')

+ 2 - 1
apps/web/tests/workspace-flow.snapshot.ts

@@ -13,7 +13,8 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
   { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] },
+  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
+  { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
   { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
   { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
   {

+ 1 - 0
packages/client/locale/src/client/index.ts

@@ -15,6 +15,7 @@ import { createLanguageRowStore } from './settings-store.ts'
 
 export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
 export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
+export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
 
 /** Translate a key with optional params. */
 export type Translate = (key: string, params?: Record<string, unknown>) => string

+ 19 - 11
packages/client/locale/src/client/settings-contract.ts

@@ -1,18 +1,26 @@
 /**
- * Settings-surface slot merge consumed by this package's Language row. The
- * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract
- * (declaring is claiming: the shell's General entry declares the slot); this
- * file repeats the entry verbatim because the shell consumes ctx.locale
- * (project reference ui-settings -> locale), so importing the shell's types
- * from here would close a reference cycle. TypeScript declaration merging
- * rejects diverging duplicates, so every program that sees both copies (the
- * shell's own build, the client aggregate) enforces identity.
+ * The `settings.general.item` slot type — one preference row inside the
+ * settings General section, contributed by the feature plugin that owns the
+ * preference (locale → Language, ui-theme → Appearance). Options: `id` (row
+ * key), `order` (row position). Rows draw their own internals (row layout,
+ * separators via CSS); the section column only stacks them.
+ *
+ * TYPE HOME RATIONALE: the slot is declared at runtime by
+ * ui-settings-general's General entry, but its type lives here — this
+ * package is the common dependency of every item registrant (any settings
+ * row carries copy, so every registrant already depends on locale), whereas
+ * the declarer's own contract is unreachable for locale/ui-theme without a
+ * reference cycle.
  */
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
-    /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */
-    'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
+    /** One preference row inside the settings General section (see module JSDoc). */
+    'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
   }
 }
 
-export {}
+/** Owner share of a General preference row (the section supplies nothing). */
+export interface SettingsGeneralItemOwnerProps {
+  /** Marker field: item owner props are intentionally empty. */
+  children?: never
+}

+ 15 - 0
packages/client/ui-settings-general/README.md

@@ -0,0 +1,15 @@
+# @deepseek-ai/dsh-client-ui-settings-general
+
+Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages.
+
+## Model Experience
+
+None, as the plugin renders browser settings UI; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine.

+ 67 - 0
packages/client/ui-settings-general/package.json

@@ -0,0 +1,67 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-settings-general",
+  "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [
+      "@deepseek-ai/dsh-client-runtime",
+      "@deepseek-ai/dsh-client-ui-settings",
+      "@deepseek-ai/dsh-client-locale"
+    ],
+    "platform": "web"
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/react": "~18.3.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 0 - 0
packages/client/ui-settings/src/client/GeneralSection.module.css → packages/client/ui-settings-general/src/client/GeneralSection.module.css


+ 16 - 6
packages/client/ui-settings/src/client/GeneralSection.tsx → packages/client/ui-settings-general/src/client/GeneralSection.tsx

@@ -1,14 +1,24 @@
 /**
- * Shell-owned General section (figma 501:29983 'Options'): Permission and
- * Tool Call skeleton rows, then the feature-contributed preference rows from
- * the `settings.general.item` slot (locale → Language, ui-theme →
- * Appearance). The section column stacks rows; each row draws its own
- * internals and separator.
+ * The General section (figma 501:29983 'Options'): Permission and Tool Call
+ * skeleton rows, then the feature-contributed preference rows from the
+ * `settings.general.item` slot (locale → Language, ui-theme → Appearance).
+ * The section column stacks rows; each row draws its own internals and
+ * separator.
  */
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { GeneralSectionComponentProps } from './contract/slots.ts'
+import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import css from './GeneralSection.module.css'
 
+/** Injected face of the General section: the settings-namespace translate. */
+export interface GeneralSectionInjected {
+  /** Translate a `settings` dictionary key to the active-locale text. */
+  t: (key: string) => string
+}
+
+/** Full component props: section owner share + item render share + inject face. */
+export type GeneralSectionComponentProps =
+  PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected
+
 /**
  * Render the General section content column.
  * @param props - composed slot props (contract/slots.ts).

+ 7 - 0
packages/client/ui-settings-general/src/client/chrome.module.css

@@ -0,0 +1,7 @@
+/* Trigger row label (the shell's button provides layout/colors; the label
+ * only guards against overflow during the sidebar collapse crossfade). */
+
+.triggerLabel {
+  overflow: hidden;
+  white-space: nowrap;
+}

+ 56 - 0
packages/client/ui-settings-general/src/client/chrome.tsx

@@ -0,0 +1,56 @@
+/**
+ * Shell chrome content registered into the shell's trigger/header seats: the
+ * trigger row icon + label (figma sidebar foot) and the panel title text.
+ * The shell renders the surrounding chrome (button, nav heading row) and
+ * reads each entry's `label` option for aria text.
+ */
+import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import css from './chrome.module.css'
+
+/** Injected face of both chrome seats: the settings-namespace translate. */
+export interface ChromeInjected {
+  /** Translate a `settings` dictionary key to the active-locale text. */
+  t: (key: string) => string
+}
+
+/** Trigger content props: the sidebar column state + translate. */
+export type TriggerContentProps = PropsRuntime<'settings.trigger'> & ChromeInjected
+
+/** Header content props: translate only. */
+export type HeaderContentProps = PropsRuntime<'settings.header'> & ChromeInjected
+
+/**
+ * Render the trigger row content (icon; label only in the wide column).
+ * @param props - composed slot props.
+ * @returns the trigger content fragment.
+ */
+export function TriggerContent({ wide, t }: TriggerContentProps) {
+  return (
+    <>
+      <IconSettingsOutline14 size={wide ? 14 : 18} />
+      {wide && <span className={css.triggerLabel}>{t('trigger')}</span>}
+    </>
+  )
+}
+
+/**
+ * Render the panel title text.
+ * @param props - composed slot props.
+ * @returns the title text node.
+ */
+export function HeaderContent({ t }: HeaderContentProps) {
+  return <>{t('title')}</>
+}
+
+/** Close-button label text props: translate only. */
+export type CloseLabelProps = PropsRuntime<'settings.close'> & ChromeInjected
+
+/**
+ * Render the close button's visually-hidden label text.
+ * @param props - composed slot props.
+ * @returns the label text node.
+ */
+export function CloseLabel({ t }: CloseLabelProps) {
+  return <>{t('close')}</>
+}

+ 87 - 0
packages/client/ui-settings-general/src/client/index.ts

@@ -0,0 +1,87 @@
+/**
+ * Settings ownerless-copy plugin, browser half: registers everything on the
+ * Settings surface that belongs to no single feature — the trigger/header
+ * chrome content, the General section (skeleton rows + the
+ * `settings.general.item` slot declaration), and the `settings`
+ * dictionaries. Feature-owned rows and sections stay with their features.
+ * Export discipline: packages/client/AGENTS.md.
+ */
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
+import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
+import type { ChromeInjected } from './chrome.tsx'
+import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
+import type { GeneralSectionInjected } from './GeneralSection.tsx'
+import { GeneralSection } from './GeneralSection.tsx'
+import { en, zh } from './locales.ts'
+
+export type {
+  ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps,
+} from './chrome.tsx'
+export type {
+  GeneralSectionComponentProps, GeneralSectionInjected,
+} from './GeneralSection.tsx'
+
+/** Dictionary namespace owned by this plugin (shell chrome + General copy). */
+const NS = 'settings'
+
+/**
+ * Required services (cordis fiber inject). The target slots are declared by
+ * ui-settings' apply, whose activation order relative to this one is NOT
+ * constrained; registration goes through declaration-aware deferral.
+ */
+export const inject = ['slots', 'locale']
+
+/**
+ * Register the `settings` dictionaries, the chrome content, and the General
+ * section, each once its slot declaration is on the ledger.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => {
+    const disposers = [
+      ctx.locale.register(NS, 'zh', zh),
+      ctx.locale.register(NS, 'en', en),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'ui-settings-general: dictionaries')
+
+  const t = ctx.locale.bind(NS)
+  const chromeInjected = (): ChromeInjected => ({ t })
+  const generalInjected = (): GeneralSectionInjected => ({ t })
+
+  // All four seats refresh on locale change: re-registration bumps each
+  // slot's ledger version, which re-renders the outlets through their own
+  // subscriptions (outlet memoization would swallow a parent-only render).
+  ctx.effect(() => {
+    const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
+      ctx.slots.register({ name: 'settings.trigger', inject: chromeInjected }, TriggerContent))
+    const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
+      ctx.slots.register({ name: 'settings.header', inject: chromeInjected }, HeaderContent))
+    const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
+      ctx.slots.register({ name: 'settings.close', inject: chromeInjected }, CloseLabel))
+    const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
+      ctx.slots.register({
+        name: 'settings.section',
+        id: 'general',
+        order: 0,
+        label: t('general.nav'),
+        children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
+        inject: generalInjected,
+      }, GeneralSection))
+    const offLocale = ctx.on('locale/change', () => {
+      trigger.refresh()
+      header.refresh()
+      close.refresh()
+      general.refresh()
+    })
+    return () => {
+      offLocale()
+      trigger.dispose()
+      header.dispose()
+      close.dispose()
+      general.dispose()
+    }
+  }, 'ui-settings-general: chrome and section registrations')
+}

+ 0 - 0
packages/client/ui-settings/src/client/locales.ts → packages/client/ui-settings-general/src/client/locales.ts


+ 6 - 0
packages/client/ui-settings-general/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-settings-general/src/index.ts

@@ -0,0 +1,4 @@
+/** Host loader entry for the browser implementation exported from `./client`. */
+
+/** Host plugin body — no host-side behavior for the general settings plugin. */
+export function apply(): void {}

+ 32 - 0
packages/client/ui-settings-general/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`.
+ * @module @deepseek-ai/dsh-client-ui-settings-general/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-settings-general-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: a copy-owning registrant contributing chrome content
+ * and the General section into shell-declared slots — it emits no cordis
+ * events and owns no cross-plugin mutable relation; slot conflicts already
+ * fail loud in the slot core at load time.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 150 - 0
packages/client/ui-settings-general/tests/apply.spec.ts

@@ -0,0 +1,150 @@
+/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
+import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
+import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client'
+import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
+import { GeneralSection } from '../src/client/GeneralSection.tsx'
+
+/** The four seats this plugin fills (slot name → expected component). */
+const SEATS = [
+  ['settings.trigger', TriggerContent],
+  ['settings.header', HeaderContent],
+  ['settings.close', CloseLabel],
+  ['settings.section', GeneralSection],
+] as const
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotsService).await()
+  const locale = new LocaleService(ctx)
+  ctx.provide('locale', locale)
+  return { ctx, slots: ctx.get('slots') as SlotsService, locale }
+}
+
+/** Declare the shell's four child slots the way ui-settings' entry does. */
+function declare(slots: SlotsService): () => void {
+  return slots.register(
+    {
+      name: 'root',
+      children: {
+        'settings.trigger': { kind: 'single', scope: 'root' },
+        'settings.header': { kind: 'single', scope: 'root' },
+        'settings.close': { kind: 'single', scope: 'root' },
+        'settings.section': { kind: 'list', scope: 'root' },
+      },
+    } as never,
+    () => null,
+  )
+}
+
+function generalEntry(slots: SlotsService) {
+  return slots.entries('settings.section').find(e => e.component === GeneralSection)
+}
+
+describe('ui-settings-general apply', () => {
+  it('declares the services it uses', () => {
+    expect(inject).toEqual(['slots', 'locale'])
+  })
+
+  it('fills all four seats for declarations before or after apply', async () => {
+    const before = await bench()
+    declare(before.slots)
+    await before.ctx.plugin({ inject: [...inject], apply }).await()
+    for (const [name, component] of SEATS) {
+      expect(before.slots.entries(name)[0]!.component).toBe(component)
+    }
+    const entry = generalEntry(before.slots)!
+    expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' })
+    expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+    const injected = (entry.inject as unknown as () => GeneralSectionInjected)()
+    expect(injected.t('permission.title')).toBe('权限')
+    // The chrome seats share one inject face: the settings-ns translate.
+    const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)()
+    expect(chrome.t('trigger')).toBe('设置')
+
+    const after = await bench()
+    await after.ctx.plugin({ inject: [...inject], apply }).await()
+    for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
+    declare(after.slots)
+    await Promise.resolve()
+    for (const [name, component] of SEATS) {
+      expect(after.slots.entries(name)[0]!.component).toBe(component)
+      // The self-inflicted ledger notifications hit the duplicate guard.
+      expect(after.slots.entries(name)).toHaveLength(1)
+    }
+  })
+
+  it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
+    const b = await bench()
+    declare(b.slots)
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(b.locale.bind('settings')('title')).toBe('设置')
+    b.locale.setLocale('en')
+    expect(b.locale.bind('settings')('close')).toBe('Close')
+    b.locale.setLocale('zh')
+    await fiber.dispose()
+    // The (ns, locale) seats are free again — the dictionary disposers ran.
+    expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
+    expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
+  })
+
+  it('refreshes all four seats on locale change with fresh General label text', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
+    b.locale.setLocale('en')
+    // Every seat re-registered (version moved) and the label re-resolved.
+    SEATS.forEach(([name], i) => {
+      expect(b.slots.getVersion(name)).toBeGreaterThan(zhVersions[i]!)
+      expect(b.slots.entries(name)).toHaveLength(1)
+    })
+    expect(generalEntry(b.slots)!.options.label).toBe('General')
+    b.locale.setLocale('zh')
+    expect(generalEntry(b.slots)!.options.label).toBe('通用设置')
+  })
+
+  it('locale change while the slots are undeclared stays a no-op', async () => {
+    const b = await bench()
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    b.locale.setLocale('en')
+    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
+    b.locale.setLocale('zh')
+  })
+
+  it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
+    const b = await bench()
+    const redeclare = declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    // Declarer unload: the cascade removes every seat entry and the item
+    // declaration while our local disposers go stale.
+    redeclare()
+    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
+    expect(b.slots.spec('settings.general.item')).toBeUndefined()
+    declare(b.slots)
+    await Promise.resolve()
+    for (const [name, component] of SEATS) {
+      expect(b.slots.entries(name)[0]!.component).toBe(component)
+    }
+    expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+    // The recovered registrations still ride the locale path.
+    b.locale.setLocale('en')
+    expect(generalEntry(b.slots)!.options.label).toBe('General')
+    b.locale.setLocale('zh')
+  })
+
+  it('removes every seat and the item declaration on teardown', async () => {
+    const b = await bench()
+    declare(b.slots)
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(b.slots.spec('settings.general.item')).toBeDefined()
+    await fiber.dispose()
+    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
+    expect(b.slots.spec('settings.general.item')).toBeUndefined()
+  })
+})

+ 37 - 16
packages/client/ui-settings/tests/general-section.spec.tsx → packages/client/ui-settings-general/tests/components.spec.tsx

@@ -1,29 +1,50 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render, screen } from '@testing-library/react'
-import type { GeneralSectionComponentProps } from '../src/client/contract/slots.ts'
+import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
 import { GeneralSection } from '../src/client/GeneralSection.tsx'
+import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
 import { en } from '../src/client/locales.ts'
 
 afterEach(cleanup)
 
-function mount() {
-  const renderSlot = vi.fn(
-    ((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
-  )
-  // Global standard kit stubs: the section consumes neither hook.
-  const unusedHook = (() => { throw new Error('unused by GeneralSection') }) as never
-  const props: GeneralSectionComponentProps = {
-    useSessions: unusedHook,
-    useWorkspaces: unusedHook,
-    t: (key) => en[key] ?? key,
-    renderSlot,
-  }
-  const view = render(<GeneralSection {...props} />)
-  return { view, renderSlot }
-}
+const t = (key: string) => en[key] ?? key
+
+// Global standard kit stubs: none of these components consume the hooks.
+const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never
+const kit = { useSessions: unusedHook, useWorkspaces: unusedHook }
+
+describe('chrome content', () => {
+  it('TriggerContent renders the icon with the label in the wide column', () => {
+    const { container } = render(<TriggerContent {...kit} wide t={t} />)
+    expect(container.querySelector('svg')).toBeTruthy()
+    expect(screen.getByText('Settings')).toBeTruthy()
+  })
+
+  it('TriggerContent drops the label in the rail state', () => {
+    const { container } = render(<TriggerContent {...kit} wide={false} t={t} />)
+    expect(container.querySelector('svg')).toBeTruthy()
+    expect(screen.queryByText('Settings')).toBeNull()
+  })
+
+  it('HeaderContent and CloseLabel render their translated text', () => {
+    render(<HeaderContent {...kit} t={t} />)
+    render(<CloseLabel {...kit} t={t} />)
+    expect(screen.getByText('Settings')).toBeTruthy()
+    expect(screen.getByText('Close')).toBeTruthy()
+  })
+})
 
 describe('GeneralSection', () => {
+  function mount() {
+    const renderSlot = vi.fn(
+      ((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
+    )
+    const props: GeneralSectionComponentProps = { ...kit, t, renderSlot }
+    const view = render(<GeneralSection {...props} />)
+    return { view, renderSlot }
+  }
+
   it('renders the Permission skeleton row with the disabled selector', () => {
     mount()
     expect(screen.getByText('Permission')).toBeTruthy()

+ 18 - 0
packages/client/ui-settings-general/tests/invariant.spec.ts

@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+
+describe('invariant companion', () => {
+  it('registers under the package name with an empty installer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantService, { enabled: true })
+    await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
+  })
+
+  it('node-half apply is a no-op host placeholder', async () => {
+    const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
+    apply()
+    expect(true).toBe(true) // reaching here without throw is the contract
+  })
+})

+ 33 - 0
packages/client/ui-settings-general/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../runtime"
+    },
+    {
+      "path": "../ui-settings"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-settings-general/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 2 - 2
packages/client/ui-settings/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-ui-settings
 
-Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering).
+Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger.
 
 ## Model Experience
 
@@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet.
+- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general.

+ 1 - 3
packages/client/ui-settings/package.json

@@ -25,8 +25,7 @@
   "dshClient": {
     "inject": [
       "@deepseek-ai/dsh-client-runtime",
-      "@deepseek-ai/dsh-client-ui-sidebar",
-      "@deepseek-ai/dsh-client-locale"
+      "@deepseek-ai/dsh-client-ui-sidebar"
     ],
     "platform": "web"
   },
@@ -47,7 +46,6 @@
     "react": "^18.2.0"
   },
   "devDependencies": {
-    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",

+ 10 - 0
packages/client/ui-settings/src/client/SettingsRoot.module.css

@@ -190,3 +190,13 @@
   padding: 0 24px 8px;
   overflow-y: auto;
 }
+
+/* Visually-hidden text seat (close button accessible name from slot content). */
+.hiddenLabel {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  white-space: nowrap;
+}

+ 21 - 22
packages/client/ui-settings/src/client/SettingsRoot.tsx

@@ -1,15 +1,15 @@
 /**
  * Settings shell root: the sidebar-foot trigger row plus the centered modal
- * panel (figma 501:29947, 1080x700) with the section nav rail. Modal open
- * state and the active section id are component-local viewing state; the
- * section ledger arrives through the injected face (nav labels are
- * registrant-localized — the shell owns no locale/theme subscription).
+ * panel (figma 501:29947, 1080x700) with the section nav rail. The shell is
+ * a pure composition face — every piece of text (trigger label, panel title,
+ * close label, sections) arrives from registrants through slots; accessible
+ * names resolve to that content (trigger: its own text; dialog:
+ * aria-labelledby the title node; close: visually-hidden slot text). Modal
+ * open state and the active section id are component-local viewing state.
  */
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useRef, useState } from 'react'
 import clsx from 'clsx'
-import {
-  IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16,
-} from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SettingsRootComponentProps } from './contract/slots.ts'
 import css from './SettingsRoot.module.css'
 
@@ -20,7 +20,6 @@ function navIcon(id: string) {
 }
 
 type PanelProps = {
-  translate: SettingsRootComponentProps['translate']
   rows: ReturnType<SettingsRootComponentProps['sections']>
   renderSlot: SettingsRootComponentProps['renderSlot']
   onClose: () => void
@@ -31,11 +30,12 @@ type PanelProps = {
  * header button, a mask click, and document-level Escape (mounted only while
  * open, so the listener lifetime is the panel's).
  */
-function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
+function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
   // Local selection; entries can unmount underneath it, so the render-time
   // projection falls back to the first row when the id is gone.
   const [activeId, setActiveId] = useState<string | undefined>(undefined)
   const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
+  const titleId = useId()
 
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
@@ -52,9 +52,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
   return (
     <div className={css.overlay} role="presentation">
       <div className={css.mask} aria-hidden="true" onClick={onClose} />
-      <div className={css.panel} role="dialog" aria-modal="true" aria-label={translate('settings:title')}>
-        <nav className={css.nav} aria-label={translate('settings:title')}>
-          <div className={css.navTitle}>{translate('settings:title')}</div>
+      <div className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}>
+        <nav className={css.nav}>
+          <div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
           <div className={css.navList}>
             {rows.map((row) => (
               <button
@@ -72,8 +72,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
         </nav>
         <div className={css.content}>
           <div className={css.header}>
-            <button ref={closeButton} type="button" className={css.close} aria-label={translate('settings:close')} onClick={onClose}>
+            <button ref={closeButton} type="button" className={css.close} onClick={onClose}>
               <IconCloseOutline16 size={14} />
+              <span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span>
             </button>
           </div>
           <div className={css.options}>
@@ -91,13 +92,13 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
  * @returns the settings shell element tree.
  */
 export function SettingsRoot(props: SettingsRootComponentProps) {
-  const { wide, translate, subscribeSections, sectionsVersion, sections, renderSlot } = props
+  const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
   const [open, setOpen] = useState(false)
   const close = useCallback(() => { setOpen(false) }, [])
 
-  // The ledger tick is the shell's only subscription: sections re-register
-  // with freshly localized labels on locale change, so the version bump also
-  // re-renders the shell's own translate()-read chrome copy.
+  // The ledger tick keeps the nav rows fresh: registrants re-register with
+  // freshly localized text on locale change, and the trigger/header/close
+  // seats re-render through their own outlets' subscriptions.
   // State = ledger version: same-version notifications dedupe to no render.
   const [, setSectionsRev] = useState(() => sectionsVersion())
   useEffect(
@@ -111,15 +112,13 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
       <button
         type="button"
         className={clsx(css.trigger, !wide && css.rail)}
-        aria-label={translate('settings:trigger')}
         aria-haspopup="dialog"
         aria-expanded={open}
         onClick={() => { setOpen(true) }}
       >
-        <IconSettingsOutline14 size={wide ? 14 : 18} />
-        {wide && <span className={css.triggerLabel}>{translate('settings:trigger')}</span>}
+        {renderSlot('settings.trigger', { wide })}
       </button>
-      {open && <SettingsPanel translate={translate} rows={rows} renderSlot={renderSlot} onClose={close} />}
+      {open && <SettingsPanel rows={rows} renderSlot={renderSlot} onClose={close} />}
     </>
   )
 }

+ 50 - 47
packages/client/ui-settings/src/client/contract/slots.ts

@@ -1,11 +1,11 @@
 /**
- * Settings shell slot contract. The shell occupies the sidebar-owned
- * `sidebar.settings` hole, declares the `settings.section` list slot that
- * feature plugins contribute top-level pages into, and ships the first
- * section itself: General, whose `settings.general.item` list slot receives
- * preference rows from the features that own them (locale → Language,
- * ui-theme → Appearance). A feature owns its settings surface — adding a
- * setting never means editing the shell.
+ * Settings shell slot contract — the canonical home of every settings slot
+ * type. The shell is a pure composition face with zero copy of its own: it
+ * occupies the sidebar-owned `sidebar.settings` hole and declares the slots
+ * below; ALL text (trigger label, panel title, close aria, section content)
+ * arrives from registrants. A feature owns its settings surface — adding a
+ * setting never means editing the shell; copy that belongs to no single
+ * feature (chrome, the General section) is owned by ui-settings-general.
  */
 import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
@@ -14,28 +14,54 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
+    /**
+     * The sidebar-foot trigger row content: icon + label, supplied as slot
+     * content (the accessible name comes from the content — rail state
+     * renders the label visually hidden). The shell renders the button
+     * chrome and owns open state. Absent contribution degrades to an
+     * icon-only button without an accessible name (broken-composition state;
+     * the shipped composition always registers the seat).
+     */
+    'settings.trigger': { kind: 'single'; scope: 'root'; owner: SettingsTriggerOwnerProps }
+    /**
+     * The panel title text seat. Content renders inside the nav heading row;
+     * the dialog's accessible name points at that node via aria-labelledby.
+     * Absent contribution leaves the heading empty.
+     */
+    'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
+    /**
+     * The close button's visually-hidden label text (the button itself —
+     * icon, geometry, focus — is shell chrome). Absent contribution leaves
+     * the button without an accessible name (broken-composition state).
+     */
+    'settings.close': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
     /**
      * One settings page per list entry. Registrant options carry the nav
      * identity: `id` (section key, drives `only` filtering), `order` (nav
      * position), `label` (registrant-localized display text — the registrant
      * re-registers with fresh text on locale change, so the shell never
-     * subscribes locale/theme state; the ledger bump doubles as the shell's
+     * subscribes locale state; the ledger bump doubles as the shell's
      * re-render trigger). Sections render inside the panel content column.
+     * (`settings.general.item`, declared by ui-settings-general's General
+     * entry, is typed in the locale package — the common dependency of every
+     * item registrant; the shell neither declares nor renders it.)
      */
     'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
-    /**
-     * One preference row inside the General section, contributed by the
-     * feature plugin that owns the preference (locale → Language, ui-theme →
-     * Appearance). Options: `id` (row key), `order` (row position). Rows
-     * draw their own internals (row layout, separators via CSS); the section
-     * column only stacks them. NOTE: packages/client/locale and ui-theme
-     * repeat this entry verbatim (reference-cycle avoidance) — declaration
-     * merging enforces the copies stay identical; edit all three together.
-     */
-    'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
   }
 }
 
+/** Owner share of the trigger content seat: the sidebar column state. */
+export interface SettingsTriggerOwnerProps {
+  /** Whether the sidebar renders wide content (false = 56px rail, icon only). */
+  wide: boolean
+}
+
+/** Owner share of the header title seat (the shell supplies nothing). */
+export interface SettingsHeaderOwnerProps {
+  /** Marker field: header owner props are intentionally empty. */
+  children?: never
+}
+
 /**
  * Owner share of a settings section entry. The shell owns modal visibility
  * and navigation; sections receive nothing but the render site (their data
@@ -48,16 +74,9 @@ export interface SettingsSectionOwnerProps {
 
 /**
  * Registrant-private injected share of the settings shell (assembled in
- * apply): locale-resolved nav labels come through `translate`.
+ * apply): ledger projections only — the shell reads no locale state.
  */
 export type SettingsRootInjected = {
-  /**
-   * Resolve a "<ns>:<key>" locale reference to the active-locale text —
-   * shell chrome copy only (trigger/title/close); nav labels arrive already
-   * localized. Read at render time; the locale-change re-render rides the
-   * section ledger bump, not a shell-owned subscription.
-   */
-  translate: (ref: string) => string
   /** Read the settings.section ledger version (nav invalidation). */
   sectionsVersion: () => number
   /** Subscribe to settings.section ledger changes. */
@@ -68,27 +87,11 @@ export type SettingsRootInjected = {
 
 /**
  * Full component props of the settings shell root: the sidebar owner share
- * (wide/rail state) plus the declared section render share and the injected
- * face. No store is registered — modal open state and active section id are
+ * (wide/rail state) plus the declared render shares and the injected face.
+ * No store is registered — modal open state and active section id are
  * component-local viewing state.
  */
 export type SettingsRootComponentProps =
-  PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected
-
-/**
- * Injected share of the shell-owned General section: the shell's own
- * `settings` namespace translate function for the skeleton rows (Permission,
- * Tool Call). Live preference rows arrive through the item slot with their
- * own faces.
- */
-export type GeneralSectionInjected = {
-  /** Translate a `settings` dictionary key to the active-locale text. */
-  t: (key: string) => string
-}
-
-/**
- * Full component props of the shell-owned General section: the section owner
- * share, the declared item render share, and the injected face.
- */
-export type GeneralSectionComponentProps =
-  PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected
+  PropsRuntime<'sidebar.settings'>
+  & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
+  & SettingsRootInjected

+ 20 - 56
packages/client/ui-settings/src/client/index.ts

@@ -1,24 +1,21 @@
 /**
- * Settings shell plugin, browser half. Occupies the sidebar-owned
- * `sidebar.settings` hole with the trigger row + modal panel, declares the
- * `settings.section` list slot, projects that ledger into the panel
- * navigation, and ships the first section itself: General, which declares
- * the `settings.general.item` slot that feature plugins contribute
- * preference rows into. Export discipline: packages/client/AGENTS.md.
+ * Settings shell plugin, browser half. A pure composition face: occupies the
+ * sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
+ * panel, declares the `settings.trigger` / `settings.header` /
+ * `settings.section` slots, and projects the section ledger into the panel
+ * navigation. The shell ships no copy and reads no locale state — all text
+ * arrives from registrants (ui-settings-general owns the chrome and General
+ * content; features own their rows and sections). Export discipline:
+ * packages/client/AGENTS.md.
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
-// Type-only: pulls the locale plugin's Context/Events merges (ctx.locale,
-// 'locale/change') into this program.
-import type {} from '@deepseek-ai/dsh-client-locale/client'
-import type { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts'
+import type { SettingsRootInjected } from './contract/slots.ts'
 import { SettingsRoot } from './SettingsRoot.tsx'
-import { GeneralSection } from './GeneralSection.tsx'
-import { en, zh } from './locales.ts'
 
 export type {
-  GeneralSectionComponentProps, GeneralSectionInjected,
-  SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps,
+  SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
+  SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
 } from './contract/slots.ts'
 
 /**
@@ -27,29 +24,15 @@ export type {
  * constrained (dshClient.inject edges are informational); registration goes
  * through declaration-aware deferral.
  */
-export const inject = ['slots', 'locale']
+export const inject = ['slots']
 
 /**
- * Register the settings shell into `sidebar.settings` and the shell-owned
- * General section into `settings.section`, each once its declaration is on
- * the ledger.
+ * Register the settings shell into `sidebar.settings` once the declaration is
+ * on the ledger.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
-  ctx.effect(() => {
-    const disposers = [
-      ctx.locale.register('settings', 'zh', zh),
-      ctx.locale.register('settings', 'en', en),
-    ]
-    return () => { for (const dispose of disposers) dispose() }
-  }, 'ui-settings: shell copy dictionaries')
-
   const injected = (): SettingsRootInjected => ({
-    translate: (ref) => {
-      const colon = ref.indexOf(':')
-      if (colon === -1) return ref
-      return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1))
-    },
     sectionsVersion: () => ctx.slots.getVersion('settings.section'),
     subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
     sections: () => ctx.slots.entries('settings.section')
@@ -65,33 +48,14 @@ export function apply(ctx: ClientContext): void {
     const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
       ctx.slots.register({
         name: 'sidebar.settings',
-        children: { 'settings.section': { kind: 'list', scope: 'root' } },
+        children: {
+          'settings.trigger': { kind: 'single', scope: 'root' },
+          'settings.header': { kind: 'single', scope: 'root' },
+          'settings.close': { kind: 'single', scope: 'root' },
+          'settings.section': { kind: 'list', scope: 'root' },
+        },
         inject: injected,
       }, SettingsRoot))
     return () => { deferred.dispose() }
   }, 'ui-settings: shell registration')
-
-  // The shell's own General section: first page, declares the item slot the
-  // feature plugins (locale, ui-theme, …) contribute preference rows into.
-  const generalInjected = (): GeneralSectionInjected => ({
-    t: ctx.locale.bind('settings'),
-  })
-  ctx.effect(() => {
-    const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
-      ctx.slots.register({
-        name: 'settings.section',
-        id: 'general',
-        order: 0,
-        label: ctx.locale.bind('settings')('general.nav'),
-        children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
-        inject: generalInjected,
-      }, GeneralSection))
-    // Nav labels are registrant-localized: refresh on locale change so the
-    // ledger carries fresh text (the version bump re-renders the shell).
-    const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
-    return () => {
-      offLocale()
-      deferred.dispose()
-    }
-  }, 'ui-settings: general section registration')
 }

+ 30 - 114
packages/client/ui-settings/tests/apply.spec.ts

@@ -1,19 +1,15 @@
-/** Settings shell registration: declaration-aware deferral, the injected face, and HMR recovery. */
+/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
-import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import type { GeneralSectionInjected, SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
+import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
 import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
-import { GeneralSection } from '../src/client/GeneralSection.tsx'
 
 async function bench() {
   const ctx = new Context()
   await ctx.plugin(SlotsService).await()
-  const locale = new LocaleService(ctx)
-  ctx.provide('locale', locale)
-  return { ctx, slots: ctx.get('slots') as SlotsService, locale }
+  return { ctx, slots: ctx.get('slots') as SlotsService }
 }
 
 function declare(slots: SlotsService): () => void {
@@ -28,17 +24,27 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
   return (entry.inject as () => SettingsRootInjected)()
 }
 
+/** The shell's four child declarations (chrome seats + the section list). */
+const CHILD_SPECS = {
+  'settings.trigger': { kind: 'single', scope: 'root' },
+  'settings.header': { kind: 'single', scope: 'root' },
+  'settings.close': { kind: 'single', scope: 'root' },
+  'settings.section': { kind: 'list', scope: 'root' },
+} as const
+
 describe('ui-settings apply', () => {
-  it('declares the services it uses', () => {
-    expect(inject).toEqual(['slots', 'locale'])
+  it('declares only the slot registry (a pure composition face, no locale)', () => {
+    expect(inject).toEqual(['slots'])
   })
 
-  it('registers the shell for declarations that arrive before or after apply', async () => {
+  it('registers the shell and declares the four child slots, before or after the declaration', async () => {
     const before = await bench()
     declare(before.slots)
     await before.ctx.plugin({ inject: [...inject], apply }).await()
     expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    expect(before.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' })
+    for (const [name, spec] of Object.entries(CHILD_SPECS)) {
+      expect(before.slots.spec(name as never)).toEqual(spec)
+    }
 
     const after = await bench()
     await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -50,43 +56,17 @@ describe('ui-settings apply', () => {
     expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
   })
 
-  it('registers the zh/en shell dictionaries and disposes them with the fiber', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.locale.bind('settings')('title')).toBe('设置')
-    b.locale.setLocale('en')
-    expect(b.locale.bind('settings')('close')).toBe('Close')
-    await fiber.dispose()
-    // The (ns, locale) seats are free again — the dictionary disposers ran.
-    expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
-    expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
-  })
-
-  it('exposes translate over "<ns>:<key>" refs with literal echo for plain text', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const injected = injectedOf(b.slots)
-    expect(injected.translate('settings:title')).toBe('设置')
-    expect(injected.translate('no colon ref')).toBe('no colon ref')
-  })
-
   it('projects the section ledger into ordered nav rows with option defaults', async () => {
     const b = await bench()
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const injected = injectedOf(b.slots)
-    // The shell ships its own General section (order 0) — the ledger is never
-    // empty once apply settles.
-    expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }])
+    // The shell ships no sections of its own — registrants fill the ledger.
+    expect(injected.sections()).toEqual([])
     b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
-    // No order and no label: both projection defaults apply (order 0 ties
-    // keep registration sequence, so 'a' lands after the General entry).
+    // No order and no label: both projection defaults apply.
     b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
     expect(injected.sections()).toEqual([
-      { id: 'general', order: 0, label: '通用设置' },
       { id: 'a', order: 0, label: '' },
       { id: 'z', order: 20, label: 'Z' },
     ])
@@ -104,92 +84,28 @@ describe('ui-settings apply', () => {
     const redeclare = declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
-    // Declarer unload: the cascade removes our entry and the slot spec while
-    // our local disposer variable goes stale.
+    // Declarer unload: the cascade removes our entry and every child
+    // declaration while our local disposer variable goes stale.
     redeclare()
     expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
+    expect(b.slots.spec('settings.trigger')).toBeUndefined()
     declare(b.slots)
     await Promise.resolve()
     expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    expect(b.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' })
+    for (const [name, spec] of Object.entries(CHILD_SPECS)) {
+      expect(b.slots.spec(name as never)).toEqual(spec)
+    }
   })
 
-  it('unregisters the shell and collapses settings.section on teardown', async () => {
+  it('unregisters the shell and collapses all four child slots on teardown', async () => {
     const b = await bench()
     declare(b.slots)
     const fiber = b.ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
     await fiber.dispose()
     expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
-    expect(b.slots.spec('settings.section')).toBeUndefined()
-  })
-})
-
-describe('ui-settings general section', () => {
-  it('registers the shell-owned General entry and declares the item slot', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const entry = b.slots.entries('settings.section')[0]!
-    expect(entry.component).toBe(GeneralSection)
-    expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' })
-    expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
-    const injected = (entry.inject as () => GeneralSectionInjected)()
-    expect(injected.t('permission.title')).toBe('权限')
-  })
-
-  it('re-registers with fresh label text on locale change', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    b.locale.setLocale('en')
-    expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General')
-    b.locale.setLocale('zh')
-    expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置')
-  })
-
-  it('locale change while settings.section is undeclared stays a no-op', async () => {
-    const b = await bench()
-    // No sidebar.settings declaration: the shell never registers, so
-    // settings.section is never declared either.
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    b.locale.setLocale('en')
-    expect(b.slots.entries('settings.section')).toHaveLength(0)
-    b.locale.setLocale('zh')
-  })
-
-  it('re-registers after an HMR collapse of the whole chain (stale disposer must not block)', async () => {
-    const b = await bench()
-    const redeclare = declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(b.slots.entries('settings.section')).toHaveLength(1)
-    // Root declarer unload: the cascade removes the shell entry, the
-    // settings.section declaration, and the General entry below it.
-    redeclare()
-    expect(b.slots.entries('settings.section')).toHaveLength(0)
-    expect(b.slots.spec('settings.general.item')).toBeUndefined()
-    declare(b.slots)
-    // Two deferral hops: the shell re-registers (re-declaring
-    // settings.section), then General re-registers into it.
-    await Promise.resolve()
-    await Promise.resolve()
-    const entry = b.slots.entries('settings.section')[0]!
-    expect(entry.component).toBe(GeneralSection)
-    expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
-    // The recovered registration still rides the locale path.
-    b.locale.setLocale('en')
-    expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General')
-    b.locale.setLocale('zh')
-  })
-
-  it('removes the General entry and its item declaration on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.slots.spec('settings.general.item')).toBeDefined()
-    await fiber.dispose()
-    expect(b.slots.entries('settings.section')).toHaveLength(0)
-    expect(b.slots.spec('settings.general.item')).toBeUndefined()
+    for (const name of Object.keys(CHILD_SPECS)) {
+      expect(b.slots.spec(name as never)).toBeUndefined()
+    }
   })
 })

+ 41 - 16
packages/client/ui-settings/tests/settings-root.spec.tsx

@@ -6,14 +6,15 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
 
 afterEach(cleanup)
 
-const DICT: Record<string, string> = {
-  'settings:trigger': 'Settings',
-  'settings:title': 'Settings',
-  'settings:close': 'Close',
-}
-
 type Row = { id: string; order: number; label: string }
 
+/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
+const SEAT_CONTENT: Record<string, string> = {
+  'settings.trigger': 'Settings',
+  'settings.header': 'Settings Title',
+  'settings.close': 'Close',
+}
+
 function mount({
   wide = true,
   rows = [
@@ -26,8 +27,10 @@ function mount({
   let version = 0
   const listeners = new Set<() => void>()
   const renderSlot = vi.fn(
-    ((_key: string, _owner: unknown, opts?: { only?: string }) =>
-      <div data-testid={`section-${opts?.only ?? 'all'}`} />) as SettingsRootComponentProps['renderSlot'],
+    ((key: string, _owner: unknown, opts?: { only?: string }) => {
+      if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
+      return SEAT_CONTENT[key]
+    }) as SettingsRootComponentProps['renderSlot'],
   )
   // Global standard kit stubs: the shell consumes neither hook.
   const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
@@ -35,7 +38,6 @@ function mount({
     useSessions: unusedHook,
     useWorkspaces: unusedHook,
     wide,
-    translate: (ref) => DICT[ref] ?? ref,
     sectionsVersion: () => version,
     subscribeSections: (listener) => {
       listeners.add(listener)
@@ -60,19 +62,41 @@ function openPanel() {
 }
 
 describe('SettingsRoot trigger', () => {
-  it('renders the wide row with the label and opens the dialog', () => {
-    mount()
+  it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
+    const { renderSlot } = mount()
     const trigger = screen.getByRole('button', { name: 'Settings' })
-    expect(trigger.textContent).toContain('Settings')
+    expect(trigger.hasAttribute('aria-label')).toBe(false)
+    expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
     expect(trigger.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(trigger)
     expect(screen.getByRole('dialog')).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
   })
 
-  it('drops the label in the rail state', () => {
-    mount({ wide: false })
-    expect(screen.getByRole('button', { name: 'Settings' }).textContent).toBe('')
+  it('hands the rail state to the trigger seat', () => {
+    const { renderSlot } = mount({ wide: false })
+    expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
+  })
+})
+
+describe('SettingsPanel chrome seats', () => {
+  it('names the dialog via aria-labelledby pointing at the header seat node', () => {
+    mount()
+    openPanel()
+    const dialog = screen.getByRole('dialog')
+    const titleId = dialog.getAttribute('aria-labelledby')!
+    expect(titleId).toBeTruthy()
+    const title = document.getElementById(titleId)!
+    expect(title.textContent).toBe('Settings Title')
+    expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
+  })
+
+  it('names the close button through the visually-hidden close seat text', () => {
+    mount()
+    openPanel()
+    const close = screen.getByRole('button', { name: 'Close' })
+    expect(close.hasAttribute('aria-label')).toBe(false)
+    expect(close.textContent).toContain('Close')
   })
 })
 
@@ -143,7 +167,8 @@ describe('SettingsPanel navigation', () => {
     const { renderSlot } = mount({ rows: [] })
     openPanel()
     expect(screen.getByRole('dialog')).toBeTruthy()
-    expect(renderSlot).not.toHaveBeenCalled()
+    const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
+    expect(sectionCalls).toHaveLength(0)
   })
 
   it('drops the ledger subscription on unmount', () => {

+ 0 - 3
packages/client/ui-settings/tsconfig.json

@@ -23,9 +23,6 @@
     {
       "path": "../ui-sidebar"
     },
-    {
-      "path": "../locale"
-    },
     {
       "path": "../../support/invariants"
     }

+ 7 - 15
packages/client/ui-theme/src/client/settings-contract.ts

@@ -1,17 +1,9 @@
 /**
- * Settings-surface slot merge consumed by this package's Appearance row. The
- * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract
- * (declaring is claiming: the shell's General entry declares the slot); this
- * file repeats the entry verbatim because the settings shell sits above the
- * feature layer, so importing its types from here would invert the layering.
- * TypeScript declaration merging rejects diverging duplicates, so every
- * program that sees both copies enforces identity.
+ * Re-export seam for the `settings.general.item` slot type consumed by this
+ * package's Appearance row. The canonical home is the locale package (the
+ * common dependency of every item registrant); this file exists so row
+ * modules import the type from within their own package.
  */
-declare module '@deepseek-ai/dsh-client-ui-slots' {
-  interface SlotMap {
-    /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */
-    'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
-  }
-}
-
-export {}
+export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client'
+// Side-effect type import: pulls the SlotMap merge into this program.
+import type {} from '@deepseek-ai/dsh-client-locale/client'

+ 32 - 2
pnpm-lock.yaml

@@ -149,6 +149,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-settings':
         specifier: workspace:^
         version: link:../../packages/client/ui-settings
+      '@deepseek-ai/dsh-client-ui-settings-general':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-settings-general
       '@deepseek-ai/dsh-client-ui-sidebar':
         specifier: workspace:^
         version: link:../../packages/client/ui-sidebar
@@ -992,6 +995,33 @@ importers:
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
+    devDependencies:
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-sidebar':
+        specifier: workspace:^
+        version: link:../ui-sidebar
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+
+  packages/client/ui-settings-general:
     devDependencies:
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
@@ -1002,9 +1032,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives
-      '@deepseek-ai/dsh-client-ui-sidebar':
+      '@deepseek-ai/dsh-client-ui-settings':
         specifier: workspace:^
-        version: link:../ui-sidebar
+        version: link:../ui-settings
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -60,6 +60,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
+  'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },

+ 1 - 0
tsconfig.base.json

@@ -116,6 +116,7 @@
       "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"],
       "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
       "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"],
+      "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"],
       "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"],
       "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
       "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],

+ 1 - 0
tsconfig.client.json

@@ -39,6 +39,7 @@
     { "path": "./packages/client/ui-trajectory" },
     { "path": "./packages/client/ui-theme" },
     { "path": "./packages/client/ui-settings" },
+    { "path": "./packages/client/ui-settings-general" },
     { "path": "./packages/client/ui-models" },
     { "path": "./packages/client/locale" },
     { "path": "./packages/client/web" },