The rejected shape is a settings satellite per feature; the ownerless copy stays with ui-settings-general, which carries no feature surface.
131 lines
10 KiB
Markdown
131 lines
10 KiB
Markdown
# 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 判在位、`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 的一等等待物
|
||
|
||
`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 后残留。
|