# Agent Note: Client Settings、Locale 与 Theme 分层

Status: proposed

[English](2026-07-25-client-settings-locale-theme.md) | 中文

## Problem

浏览器端已有的 Settings 直接写在 Sidebar 内，语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展，偏好状态没有稳定的跨插件服务契约，主题 registry 同时承担状态与呈现职责。

## Proposal

**协作导向（后续所有模块接入 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.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 均关闭。无任何中间菜单形态。

`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`，`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot；service 自己持久化偏好（只存 id，坏值回退默认）。

功能行的 apply 层各自订阅自家 change event（locale 订 `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 且系统配色变化时重发 snapshot；snapshot 同时携带 `preference` 与解析后的 `active` 定义。

Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter，随后订阅 `theme/change`，由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token；presenter 不感知 system，只消费已解析结果。

### 首期注册面

| 注册面 | 属主插件 | 首期内容 |
|---|---|---|
| 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 骨架归 `ui-settings-general` 的 `settings` namespace，功能行文案归各功能包（`settings.locale`、`settings.theme`、`settings.models`）。

### Slot topology

```text
root
└─ sidebar
   └─ sidebar.settings                   single/root
      └─ 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 注册
            │  └─ 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 判在位、一键 dispose；本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk，不再 `refresh()`），不依赖 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 的一等等待物

`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场，消失/重现的生命周期语义一致；差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期，stale-disposer 判在位机器可整体消失。方向（另开 PR）：SlotsService 在声明落账/级联拆除处把每个坑位桥接成 `slot:<name>` 服务（value 为坑位 spec），注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:<name>'], cb)`，随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界：嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名（需测试）；`slot:` 名字空间与 typo 静默等待的口径；provide 键是平面名（`slot:a.b` 是一个键，不是 `ctx.slots` 的属性路径）。本期维持 `deferRegistration()` 函数形式。

### Service contracts

```ts
export type ThemePreference = 'light' | 'dark' | 'system'

export interface ThemeDefinition {
  id: string
  colorScheme: 'light' | 'dark'
  tokens: Record<string, string>
}

export interface ThemeSnapshot {
  preference: ThemePreference
  active: ThemeDefinition            // system 已解析为具体 light/dark 定义
  themes: readonly ThemeDefinition[]
  revision: number
}

export interface LocaleDefinition {
  id: 'zh' | 'en'
  label: string
}

export interface LocaleSnapshot {
  active: 'zh' | 'en'
  locales: readonly LocaleDefinition[]
  revision: number
}

export interface Events {
  /** @param snapshot - Current locale registry snapshot. @mode emit */
  'locale/change'(snapshot: LocaleSnapshot): void
  /** @param snapshot - Current theme registry snapshot. @mode emit */
  'theme/change'(snapshot: ThemeSnapshot): void
}
```

Locale 内置中文和 English；`setLocale`/`setTheme` 是唯一写入口，未知 id 失败。

## Alternatives considered

**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者；全树刷新放大影响面，也把业务偏好接入 shell。

**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境，生命周期与全局样式所有权不清；Layout 已经拥有页面根呈现边界。

**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义，呈现层被迫理解偏好语义；解析放服务侧则所有消费者拿到一致的已解析 snapshot。

**Settings import 并枚举各 section。** 新增页面必须修改壳插件，破坏「每个功能由自己的插件占坑」的组合模型。

**按功能为每个 section 单开 `ui-settings-*` 卫星包。** 设置面与功能本体分家：改 Theme 行为要动两个包，包数随设置项线性膨胀，且卫星包反向依赖 locale/theme 服务，形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层：preference 行随功能包交付；`ui-settings-general` 只收无主文案（chrome 与 General 骨架），不承载任何功能的设置面。

**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存，易变值会陈旧；为每个 service 自造 React hook 也绕开 slot store 的统一绑定。

## Acceptance criteria

- Settings 壳只依赖 slot ledger，不依赖任一功能实现；General 的 item 列表同样只依赖 ledger。
- 新增一个设置项 = 功能包自己注册（section 或 general item），零壳改动。
- Locale 与 Theme 的写入只走 setter，持续同步只走 change event。
- 功能行 store 初始化走 getter，后续由自家 change event 更新并局部重渲染。
- Layout 独立应用 Theme snapshot，Theme service 不访问 DOM；presenter 不出现 system 分支。
- 中文/English 与 Light/Dark/System 能切换并刷新后恢复；偏好为 system 时系统配色变化即时生效。
- Models 只有导航项与空内容区；Permission、Tool Call 骨架无写操作。
- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。

## Risks

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 后残留。
