refactor(gui): features register their own settings surfaces

Settings collaboration direction (recorded in the note): the shell only
provides composition faces — feature plugins register themselves. The
General section moves into the ui-settings shell (order 0, skeleton
rows) and declares the settings.general.item list slot; locale registers
the Language row and ui-theme the Appearance row (each with its own
store mirror, dictionaries, and ledger-judged deferral); the
ui-settings-general package is gone. ui-settings-models becomes
ui-models — a feature package that contributes its Settings section
rather than a settings-owned satellite. The item-slot SlotMap entry is
authored in the ui-settings contract and repeated verbatim in
locale/ui-theme (reference-cycle avoidance; declaration merging keeps
the copies identical).
This commit is contained in:
imccyu
2026-07-26 02:51:36 +08:00
parent 2ee4cda066
commit 23a60ade67
62 changed files with 1008 additions and 1049 deletions

View File

@@ -10,41 +10,48 @@ The browser client's existing Settings is written directly inside the Sidebar, a
## Proposal
The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by an independent plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`.
**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.
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 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.
`@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default).
General's apply layer subscribes to `locale/change` and `theme/change` and projects the snapshots into the Zustand store declared by that section. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services.
Each feature row's apply layer subscribes to its own change event (locale to `locale/change`, ui-theme to `theme/change`) and projects the snapshot into the slot store declared when that row registered. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services.
The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition.
The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results.
### First-phase section scope
### First-phase registration surfaces
| section | Plugin | First-phase content |
| Registration surface | Owning plugin | First-phase content |
|---|---|---|
| General | `ui-settings-general` | Language (Selector dropdown) and Appearance (Light/Dark/System three cubes) genuinely switch; Permission and Tool Call are visual skeletons only, with no write operations |
| Models | `ui-settings-models` | Navigation item only; the content area is empty |
| Plugin | no package | Not built this phase, and the navigation does not show the item (an external-link entry with no target never renders; once a later plugin registers the section it appears automatically) |
| 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 |
| 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 (the General rows plus the navigation); copy on other pages is untouched.
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`).
### Slot topology
```text
root
└─ sidebar
└─ sidebar.settings single/root
└─ ui-settings
└─ settings.section list/root
├─ general ui-settings-general
└─ models ui-settings-models
└─ sidebar.settings single/root
└─ ui-settings(壳)
└─ settings.section list/root
├─ general (order 0) ui-settings 壳自带
│ └─ settings.general.item list/root
│ ├─ language (0) locale 注册
│ └─ appearance (10) ui-theme 注册
└─ models (order 10) ui-models 注册
```
Section contributions use declaration-aware deferral and do not depend on the client manifest's apply order.
Section and item contributions both use declaration-aware deferral 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.
### Service contracts
@@ -95,13 +102,16 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on
**Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin.
**One `ui-settings-*` package per section (the first-cut implementation).** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and settings-general depending back on the locale/theme services forms an intermediate layer that exists purely for the package split. After converging on feature-owner self-registration, General belongs to the shell (it belongs to no single feature) and preference rows ship with their feature packages.
**Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding.
## Acceptance criteria
- The Settings shell depends only on the slot ledger, never on any section implementation.
- The Settings shell depends only on the slot ledger, never on any feature implementation; General's item list likewise depends only on the ledger.
- Adding a settings item = the feature package registering it itself (a section or a general item), with zero shell changes.
- Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events.
- The General store initializes from the getters and is thereafter updated by the two events with local re-renders.
- Each feature row's store initializes from the getter and is thereafter updated by its own change event with local re-renders.
- Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter.
- 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately.
- Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes.

View File

@@ -10,41 +10,48 @@ Status: proposed
## Proposal
Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由独立插件贡献Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section
**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳只提供组合面(一级 section 列表 + General 内的 item 列表),不 import 也不枚举任何功能;一个功能要出现在 Settings 里由它自己的插件向对应坑位注册——locale 注册 Language 行ui-theme 注册 Appearance 行ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能Theme 的设置选择就随 ui-theme 一起交付)。壳自带的唯一内容是第一个一级目录 General骨架行 + item 坑位声明),因为它不属于任何单一功能
Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由功能插件贡献Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由壳自己注册order 0并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。
Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。
`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale``ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshotservice 自己持久化偏好(只存 id坏值回退默认
General 的 apply 层订 `locale/change` `theme/change`,把 snapshot 投影到该 section 声明的 Zustand store。React 组件只读 `useStore`、写注入的 setter callback不读取 ctx 或 service。
功能行的 apply 层各自订阅自家 change eventlocale `locale/change`ui-theme 订 `theme/change`,把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback不读取 ctx 或 service。
Theme 偏好三态:`light``dark``system`,默认 `system`无持久化偏好或坏值时。system 的解析属主题领域ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshotsnapshot 同时携带 `preference` 与解析后的 `active` 定义。
Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 tokenpresenter 不感知 system只消费已解析结果。
### 首期 section 范围
### 首期注册面
| section | 插件 | 首期内容 |
| 注册面 | 属主插件 | 首期内容 |
|---|---|---|
| General | `ui-settings-general` | LanguageSelector 下拉)与 AppearanceLight/Dark/System 三 cube真实可切Permission、Tool Call 视觉骨架无写操作 |
| Models | `ui-settings-models` | 仅导航项,内容区为空 |
| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现 |
| General sectionorder 0| `ui-settings` 壳自带 | 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 sectionorder 10| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 |
| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) |
首期只翻译 Settings 浮层内文案General 各行 + 导航);其他页面文案不动
首期只翻译 Settings 浮层内文案字典就近——壳文案chrome + General 骨架)归 `settings` namespace功能行文案归各功能包`settings.locale``settings.theme``settings.models`
### Slot topology
```text
root
└─ sidebar
└─ sidebar.settings single/root
└─ ui-settings
└─ settings.section list/root
├─ general ui-settings-general
└─ models ui-settings-models
└─ sidebar.settings single/root
└─ ui-settings(壳)
└─ settings.section list/root
├─ general (order 0) ui-settings 壳自带
│ └─ settings.general.item list/root
│ ├─ language (0) locale 注册
│ └─ appearance (10) ui-theme 注册
└─ models (order 10) ui-models 注册
```
section contribution 使用 declaration-aware deferral不依赖 client manifest 的 apply 顺序。
section/item contribution 使用 declaration-aware deferral不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contractlocale/ui-theme 因引用环(壳消费 ctx.locale以逐字重复合并的方式消费该条目declaration merging 保证副本一致。
### Service contracts
@@ -95,13 +102,16 @@ Locale 内置中文和 English`setLocale`/`setTheme` 是唯一写入口,未
**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。
**每个 section 单开 `ui-settings-*` 包(首版实现)。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且 settings-general 反向依赖 locale/theme 服务形成纯粹为拆包而生的中间层。收敛为功能属主自注册后General 归壳不属任何单一功能preference 行随功能包交付。
**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。
## Acceptance criteria
- Settings 壳只依赖 slot ledger不依赖任一 section 实现
- Settings 壳只依赖 slot ledger不依赖任一功能实现General 的 item 列表同样只依赖 ledger
- 新增一个设置项 = 功能包自己注册section 或 general item零壳改动。
- Locale 与 Theme 的写入只走 setter持续同步只走 change event。
- General store 初始化走 getter后续由两个 event 更新并局部重渲染。
- 功能行 store 初始化走 getter后续由自家 change event 更新并局部重渲染。
- Layout 独立应用 Theme snapshotTheme service 不访问 DOMpresenter 不出现 system 分支。
- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。
- Models 只有导航项与空内容区Permission、Tool Call 骨架无写操作。
@@ -109,4 +119,4 @@ Locale 内置中文和 English`setLocale`/`setTheme` 是唯一写入口,未
## Risks
slot 声明与 contribution 的 apply 顺序不固定,所有 section 必须保留 declaration-aware registration 和幂等防护。service event 可能早于 section 首次渲染General store 的 init 与 controller attach 都必须从 getter 对齐当前 snapshot。Layout 卸载时必须清理自己设置的全局属性ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。
slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger而非本地 disposer判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本locale、ui-theme与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。