mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。
|
||||
|
||||
General 的 apply 层订阅 `locale/change` 和 `theme/change`,把 snapshot 投影到该 section 声明的 Zustand store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。
|
||||
功能行的 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,只消费已解析结果。
|
||||
|
||||
### 首期 section 范围
|
||||
### 首期注册面
|
||||
|
||||
| section | 插件 | 首期内容 |
|
||||
| 注册面 | 属主插件 | 首期内容 |
|
||||
|---|---|---|
|
||||
| General | `ui-settings-general` | Language(Selector 下拉)与 Appearance(Light/Dark/System 三 cube)真实可切;Permission、Tool Call 仅视觉骨架,无写操作 |
|
||||
| Models | `ui-settings-models` | 仅导航项,内容区为空 |
|
||||
| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现) |
|
||||
| General section(order 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 section(order 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 contract;locale/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 snapshot,Theme service 不访问 DOM;presenter 不出现 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 后残留。
|
||||
|
||||
@@ -233,11 +233,8 @@
|
||||
- id: ui-settings
|
||||
name: '@deepseek-ai/dsh-client-ui-settings'
|
||||
|
||||
- id: ui-settings-general
|
||||
name: '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
- id: ui-settings-models
|
||||
name: '@deepseek-ai/dsh-client-ui-settings-models'
|
||||
- id: ui-models
|
||||
name: '@deepseek-ai/dsh-client-ui-models'
|
||||
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
"@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-settings-general": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
|
||||
@@ -45,10 +45,7 @@
|
||||
"path": "../../packages/client/ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-settings-general"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-settings-models"
|
||||
"path": "../../packages/client/ui-models"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/locale"
|
||||
|
||||
@@ -14,8 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ 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-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ 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'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
|
||||
@@ -14,8 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ 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-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ 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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t)",
|
||||
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -23,18 +23,29 @@
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^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"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
@@ -47,5 +58,8 @@
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
47
packages/client/locale/src/client/LanguageRow.module.css
Normal file
47
packages/client/locale/src/client/LanguageRow.module.css
Normal file
@@ -0,0 +1,47 @@
|
||||
/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator;
|
||||
* the section column removes the separator on its last child). */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
68
packages/client/locale/src/client/LanguageRow.tsx
Normal file
68
packages/client/locale/src/client/LanguageRow.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Language preference row registered into the General section item slot
|
||||
* (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale
|
||||
* menu. Registered by this package — the locale feature owns its own
|
||||
* settings surface.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createLanguageRowStore } from './settings-store.ts'
|
||||
import css from './LanguageRow.module.css'
|
||||
|
||||
/** Injected business face: namespace-bound translate + the preference write. */
|
||||
export interface LanguageRowInjected {
|
||||
/** Translate a `settings.locale` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the active locale (a registered locale id). */
|
||||
setLocale: (id: string) => void
|
||||
}
|
||||
|
||||
/** Full component props: runtime share + store share + injected face. */
|
||||
export type LanguageRowComponentProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
|
||||
|
||||
/**
|
||||
* Render the Language row.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row element tree.
|
||||
*/
|
||||
export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) {
|
||||
const active = useStore(s => s.active)
|
||||
const options = useStore(s => s.options)
|
||||
const [open, setOpen] = useState(false)
|
||||
const activeLabel = options.find(o => o.id === active)?.label ?? active
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('language.title')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={options.map(o => ({ id: o.id, label: o.label }))}
|
||||
selectedId={active}
|
||||
onSelect={(id) => {
|
||||
setLocale(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(v => !v) }}
|
||||
>
|
||||
{activeLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
/**
|
||||
* Browser-side locale registry. Bound translation functions retain stable
|
||||
* identity for injected consumers.
|
||||
* identity for injected consumers. The plugin also registers the Language
|
||||
* preference row into the settings General section — the locale feature owns
|
||||
* its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
import type { LanguageRowInjected } from './LanguageRow.tsx'
|
||||
import { LanguageRow } from './LanguageRow.tsx'
|
||||
import { createLanguageRowStore } from './settings-store.ts'
|
||||
|
||||
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
@@ -53,6 +63,9 @@ export const FALLBACK_LOCALE: LocaleId = 'zh'
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.locale'
|
||||
|
||||
/** localStorage key holding the persisted locale id. */
|
||||
export const STORAGE_KEY = 'dsh.locale'
|
||||
|
||||
@@ -183,16 +196,65 @@ function persistPreference(id: LocaleId): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services (none; the loader passes the export surface as an object plugin). */
|
||||
export const inject: string[] = []
|
||||
/** Required services: the slot registry (the feature registers its own settings row). */
|
||||
export const inject = ['slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the locale service with base dictionaries.
|
||||
* Client plugin body: provide the locale service with base dictionaries and
|
||||
* register the feature-owned Language preference row into the General
|
||||
* section's item slot (a feature owns its settings surface).
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.register(COMMON_NS, 'zh', zh)
|
||||
locale.register(COMMON_NS, 'en', en)
|
||||
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
|
||||
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
|
||||
ctx.provide('locale', locale)
|
||||
|
||||
const store = createLanguageRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
const sync = (snapshot: LocaleSnapshot): void => {
|
||||
bound?.sync(
|
||||
snapshot.active,
|
||||
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
|
||||
snapshot.revision,
|
||||
)
|
||||
}
|
||||
ctx.on('locale/change', sync)
|
||||
const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => {
|
||||
bound = actions
|
||||
// Re-sync from the getter so no event is lost between registration and
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(locale.getLocale())
|
||||
return {
|
||||
t: locale.bind(SETTINGS_NS),
|
||||
setLocale: (id) => { locale.setLocale(id) },
|
||||
}
|
||||
}
|
||||
// Declaration-aware registration; the LEDGER is the has-registered judge
|
||||
// (not a local flag): after an HMR collapse re-declares the slot, the
|
||||
// cascade already removed our entry, and a stale disposer must not block
|
||||
// the re-registration.
|
||||
ctx.effect(() => {
|
||||
let dispose: (() => void) | undefined
|
||||
const tryRegister = (): void => {
|
||||
if (ctx.slots.spec('settings.general.item') === undefined) return
|
||||
if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return
|
||||
dispose = ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
inject: injected,
|
||||
}, LanguageRow)
|
||||
}
|
||||
const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() })
|
||||
tryRegister()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
}
|
||||
}, 'locale: language settings row registration')
|
||||
}
|
||||
|
||||
18
packages/client/locale/src/client/settings-contract.ts
Normal file
18
packages/client/locale/src/client/settings-contract.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 {}
|
||||
47
packages/client/locale/src/client/settings-store.ts
Normal file
47
packages/client/locale/src/client/settings-store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Language row slot store: a mirror of the locale service snapshot. The
|
||||
* plugin's apply-world change listener is the only writer; the row component
|
||||
* reads via props.useStore.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One selectable locale row (id + self-described label). */
|
||||
export interface LanguageOptionRow {
|
||||
/** Locale id (the setLocale argument). */
|
||||
id: string
|
||||
/** Display name in its own language (中文 / English). */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Store state mirrored from the locale snapshot. */
|
||||
export interface LanguageRowState {
|
||||
/** Active locale id. */
|
||||
active: string
|
||||
/** Selectable locales in display order. */
|
||||
options: LanguageOptionRow[]
|
||||
/** Service revision; -1 until first sync so revision 0 lands as a change. */
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** Declared action shape giving the exported factory a stable return type. */
|
||||
type LanguageRowActions = {
|
||||
sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the Language row state and write surface.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function createLanguageRowStore(): EngineStoreHandle<LanguageRowState, LanguageRowActions> {
|
||||
return defineStore({
|
||||
init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }),
|
||||
actions: {
|
||||
sync: (d, active: string, options: LanguageOptionRow[], revision: number) => {
|
||||
if (revision <= d.revision) return
|
||||
d.active = active
|
||||
d.options = options
|
||||
d.revision = revision
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
|
||||
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
@@ -18,8 +20,10 @@ describe('invariant companion', () => {
|
||||
})
|
||||
|
||||
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
|
||||
expect(inject).toEqual([])
|
||||
// The feature registers its own Language settings row, hence the slots edge.
|
||||
expect(inject).toEqual(['slots'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# @deepseek-ai/dsh-client-ui-settings-models
|
||||
# @deepseek-ai/dsh-client-ui-models
|
||||
|
||||
Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-models",
|
||||
"description": "Models settings section plugin: nav entry with an empty content column (model management lands later)",
|
||||
"name": "@deepseek-ai/dsh-client-ui-models",
|
||||
"description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-models: nav copy dictionaries')
|
||||
}, 'ui-models: nav copy dictionaries')
|
||||
// Declaration-aware registration; the LEDGER is the has-registered judge
|
||||
// (not a local flag): after an HMR collapse re-declares the slot, the
|
||||
// cascade already removed our entry, and a stale disposer must not block
|
||||
@@ -63,5 +63,5 @@ export function apply(ctx: ClientContext): void {
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
}
|
||||
}, 'ui-settings-models: section registration')
|
||||
}, 'ui-models: settings section registration')
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`.
|
||||
* @module @deepseek-ai/dsh-client-ui-settings-models/invariant
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-models`.
|
||||
* @module @deepseek-ai/dsh-client-ui-models/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-models'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-settings-models-invariant'
|
||||
export const name = 'client-ui-models-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
@@ -3,7 +3,7 @@ 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-models/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
|
||||
async function bench() {
|
||||
@@ -21,7 +21,7 @@ function declare(slots: SlotsService): () => void {
|
||||
)
|
||||
}
|
||||
|
||||
describe('ui-settings-models apply', () => {
|
||||
describe('ui-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant'
|
||||
import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('invariant companion', () => {
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models')
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-models')
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
3
packages/client/ui-models/tsdown.config.ts
Normal file
3
packages/client/ui-models/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-models', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -1,15 +0,0 @@
|
||||
# @deepseek-ai/dsh-client-ui-settings-general
|
||||
|
||||
General settings section plugin: registers the `general` entry into `settings.section`. Language (中文/English) and Appearance (Light/Dark/System) are live preferences wired to `ctx.locale` / `ctx.theme`; Permission and Tool Call rows are visual skeletons with no write surface.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the section renders browser preference 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.
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-general",
|
||||
"description": "General settings section plugin: Language and Appearance preferences (live), Permission and Tool Call skeleton rows",
|
||||
"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",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^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-client-ui-theme": "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"
|
||||
]
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* General settings section: Permission and Tool Call skeleton rows (visual
|
||||
* only, no interaction), live Language and Appearance preference rows wired
|
||||
* through the injected setLocale/setTheme callbacks and the snapshot-mirror
|
||||
* store. Figma: Settings > Content > Options (501:29983).
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronDownOutline14, IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
|
||||
Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GeneralSectionComponentProps, ThemePreferenceId } from './contract.ts'
|
||||
import css from './GeneralSection.module.css'
|
||||
|
||||
/** Appearance cube order and icons (figma 501:30015-30017: Light, Dark, System). */
|
||||
const THEME_CUBES: readonly { id: ThemePreferenceId; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
|
||||
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
|
||||
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
|
||||
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the General section content column.
|
||||
* @param props - composed slot props (contract.ts).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function GeneralSection(props: GeneralSectionComponentProps) {
|
||||
const { t, setLocale, setTheme, useStore } = props
|
||||
const localeActive = useStore(s => s.localeActive)
|
||||
const localeOptions = useStore(s => s.localeOptions)
|
||||
const themePreference = useStore(s => s.themePreference)
|
||||
const [languageOpen, setLanguageOpen] = useState(false)
|
||||
|
||||
const activeLocaleLabel = localeOptions.find(l => l.id === localeActive)?.label ?? localeActive
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
{/* Permission (skeleton): disabled selector pill. */}
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('permission.title')}</div>
|
||||
<div className={css.desc}>{t('permission.desc')}</div>
|
||||
</div>
|
||||
<button type="button" className={css.selector} disabled>
|
||||
{t('permission.value')}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.title}>{t('toolcall.title')}</div>
|
||||
<div className={css.cubeRow}>
|
||||
<div className={clsx(css.modeCube, css.selected)}>
|
||||
<div className={css.title}>{t('toolcall.schema.title')}</div>
|
||||
<div className={css.desc}>{t('toolcall.schema.desc')}</div>
|
||||
</div>
|
||||
<div className={css.modeCube}>
|
||||
<div className={css.title}>{t('toolcall.code.title')}</div>
|
||||
<div className={css.desc}>{t('toolcall.code.desc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language: selector pill opens the locale menu. */}
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('language.title')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={languageOpen}
|
||||
onClose={() => { setLanguageOpen(false) }}
|
||||
items={localeOptions.map(l => ({ id: l.id, label: l.label }))}
|
||||
selectedId={localeActive}
|
||||
onSelect={(id) => {
|
||||
setLocale(id)
|
||||
setLanguageOpen(false)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageOpen}
|
||||
onClick={() => { setLanguageOpen(v => !v) }}
|
||||
>
|
||||
{activeLocaleLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Appearance: three preference cubes; selection follows the persisted
|
||||
* preference, never the resolved active theme. */}
|
||||
<div className={clsx(css.group, css.last)}>
|
||||
<div className={css.title}>{t('appearance.title')}</div>
|
||||
<div className={css.cubeRow}>
|
||||
{THEME_CUBES.map(({ id, labelKey, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={clsx(css.themeCube, themePreference === id && css.selected)}
|
||||
aria-pressed={themePreference === id}
|
||||
onClick={() => { setTheme(id) }}
|
||||
>
|
||||
<Icon />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* General section component contract: the slot-store state shape, the
|
||||
* injected business face, and the composed props type. The component imports
|
||||
* only from here; service snapshot shapes are mirrored as plain rows so the
|
||||
* presentation layer stays decoupled from the locale/theme packages.
|
||||
*/
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { createGeneralSettingsStore } from './store.ts'
|
||||
|
||||
/** One selectable locale row projected into the store (id + self-described label). */
|
||||
export interface LocaleOptionRow {
|
||||
/** Locale id (the setLocale argument). */
|
||||
id: string
|
||||
/** Display name in its own language (中文 / English). */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Theme preference union mirrored from the theme service snapshot. */
|
||||
export type ThemePreferenceId = 'light' | 'dark' | 'system'
|
||||
|
||||
/**
|
||||
* Store state: mirrors of the locale/theme service snapshots, written only by
|
||||
* the plugin's apply-world change listeners (components have no write path —
|
||||
* preference writes go through the injected callbacks to the services, and
|
||||
* the resulting change events flow back into this mirror).
|
||||
*/
|
||||
export interface GeneralSettingsState {
|
||||
/** Active locale id. */
|
||||
localeActive: string
|
||||
/** Selectable locales in display order. */
|
||||
localeOptions: LocaleOptionRow[]
|
||||
/** Locale service revision (re-renders translated copy on dictionary/locale changes); -1 until first sync. */
|
||||
localeRevision: number
|
||||
/** Persisted theme preference (selection state reads this, never the resolved active theme). */
|
||||
themePreference: ThemePreferenceId
|
||||
/** Theme service revision; -1 until first sync. */
|
||||
themeRevision: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the General section (assembled in
|
||||
* apply): the namespace-bound translate function (stable identity — re-render
|
||||
* on locale change comes from the store revision, not from `t`) and the two
|
||||
* preference write callbacks.
|
||||
*/
|
||||
export interface GeneralSectionInjected {
|
||||
/** Translate a `settings.general` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the active locale (a registered locale id). */
|
||||
setLocale: (id: string) => void
|
||||
/** Switch the theme preference. */
|
||||
setTheme: (id: ThemePreferenceId) => void
|
||||
}
|
||||
|
||||
/** Store handle type for the props share (type-only; the factory stays internal to apply and tests). */
|
||||
export type GeneralSettingsStoreHandle = ReturnType<typeof createGeneralSettingsStore>
|
||||
|
||||
/**
|
||||
* Full component props of the General section: the section owner share
|
||||
* (empty marker) plus the store share and the injected face. No child slots
|
||||
* are declared; menu open state is component-local viewing state.
|
||||
*/
|
||||
export type GeneralSectionComponentProps =
|
||||
PropsRuntime<'settings.section'> & PropsStore<GeneralSettingsStoreHandle> & GeneralSectionInjected
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* General settings section plugin, browser half. Registers the `general`
|
||||
* entry into the shell-declared `settings.section` list slot; Language and
|
||||
* Appearance are live preferences projected from ctx.locale / ctx.theme
|
||||
* through this entry's slot store. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: the locale/theme Context+Events merges and snapshot shapes.
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { GeneralSectionInjected } from './contract.ts'
|
||||
import { createGeneralSettingsStore } from './store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
|
||||
export type {
|
||||
GeneralSectionComponentProps, GeneralSectionInjected, GeneralSettingsState,
|
||||
GeneralSettingsStoreHandle, LocaleOptionRow, ThemePreferenceId,
|
||||
} from './contract.ts'
|
||||
|
||||
/** Dictionary namespace owned by this section (also the nav-label reference prefix). */
|
||||
const NS = 'settings.general'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is 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', 'theme']
|
||||
|
||||
/**
|
||||
* Register the `settings.general` dictionaries and the General section entry
|
||||
* once the `settings.section` declaration is on the ledger. The slot store
|
||||
* mirrors the locale/theme snapshots: change listeners attach here in apply,
|
||||
* write through the bound actions captured at inject time, and the inject
|
||||
* factory re-syncs from the getters so no event is lost between registration
|
||||
* and first render (the store's revision guard drops stale duplicates).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposeZh = ctx.locale.register(NS, 'zh', zh)
|
||||
const disposeEn = ctx.locale.register(NS, 'en', en)
|
||||
return () => {
|
||||
disposeZh()
|
||||
disposeEn()
|
||||
}
|
||||
}, 'ui-settings-general: dictionaries')
|
||||
|
||||
const store = createGeneralSettingsStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
|
||||
const syncLocale = (snapshot: LocaleSnapshot): void => {
|
||||
bound?.syncLocale(
|
||||
snapshot.active,
|
||||
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
|
||||
snapshot.revision,
|
||||
)
|
||||
}
|
||||
const syncTheme = (snapshot: ThemeSnapshot): void => {
|
||||
bound?.syncTheme(snapshot.preference, snapshot.revision)
|
||||
}
|
||||
ctx.on('locale/change', syncLocale)
|
||||
ctx.on('theme/change', syncTheme)
|
||||
|
||||
const injected = (actions: BoundActions<typeof store>): GeneralSectionInjected => {
|
||||
bound = actions
|
||||
syncLocale(ctx.locale.getLocale())
|
||||
syncTheme(ctx.theme.getTheme())
|
||||
return {
|
||||
t: ctx.locale.bind(NS),
|
||||
setLocale: (id) => { ctx.locale.setLocale(id) },
|
||||
setTheme: (id) => { ctx.theme.setTheme(id) },
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
let dispose: (() => void) | undefined
|
||||
// Presence is judged on the ledger, not on the local disposer: an HMR
|
||||
// collapse of the declaring entry removes this entry from the slot core
|
||||
// while `dispose` stays set (the stale disposer is a no-op), so a local
|
||||
// guard would block the re-registration when the declaration returns.
|
||||
const registered = (): boolean =>
|
||||
ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)
|
||||
const tryRegister = (): void => {
|
||||
if (ctx.slots.spec('settings.section') === undefined || registered()) return
|
||||
dispose = ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'general',
|
||||
order: 0,
|
||||
label: ctx.locale.bind(NS)('nav'),
|
||||
store,
|
||||
inject: injected,
|
||||
}, GeneralSection)
|
||||
}
|
||||
// Nav labels are registrant-localized: re-register on locale change so
|
||||
// the ledger carries fresh text (the version bump re-renders the shell).
|
||||
// The ledger check mirrors tryRegister: after an HMR collapse `dispose`
|
||||
// stays set while the entry is gone — relabeling then must stay quiet.
|
||||
const offLocale = ctx.on('locale/change', () => {
|
||||
if (dispose === undefined || !registered()) return
|
||||
dispose()
|
||||
dispose = undefined
|
||||
tryRegister()
|
||||
})
|
||||
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
|
||||
tryRegister()
|
||||
return () => {
|
||||
offLocale()
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
}
|
||||
}, 'ui-settings-general: section registration')
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* General section slot store: locale/theme snapshot mirrors. The plugin
|
||||
* creates the handle at apply time (identity follows the fiber) and its
|
||||
* change listeners are the only writers; components read via props.useStore.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { GeneralSettingsState, LocaleOptionRow, ThemePreferenceId } from './contract.ts'
|
||||
|
||||
/** Declared action shape used to give the exported factory a stable return type. */
|
||||
type GeneralSettingsActions = {
|
||||
syncLocale: (draft: GeneralSettingsState, active: string, options: LocaleOptionRow[], revision: number) => void
|
||||
syncTheme: (draft: GeneralSettingsState, preference: ThemePreferenceId, revision: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the General section state and write surface. Revisions start at -1
|
||||
* so the apply-time initial sync (revision 0) always lands as a change.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function createGeneralSettingsStore(): EngineStoreHandle<GeneralSettingsState, GeneralSettingsActions> {
|
||||
return defineStore({
|
||||
init: (): GeneralSettingsState => ({
|
||||
localeActive: '',
|
||||
localeOptions: [],
|
||||
localeRevision: -1,
|
||||
themePreference: 'system',
|
||||
themeRevision: -1,
|
||||
}),
|
||||
actions: {
|
||||
syncLocale: (d, active: string, options: LocaleOptionRow[], revision: number) => {
|
||||
if (revision <= d.localeRevision) return
|
||||
d.localeActive = active
|
||||
d.localeOptions = options
|
||||
d.localeRevision = revision
|
||||
},
|
||||
syncTheme: (d, preference: ThemePreferenceId, revision: number) => {
|
||||
if (revision <= d.themeRevision) return
|
||||
d.themePreference = preference
|
||||
d.themeRevision = revision
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/** 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 {}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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 section plugin projecting two service change events
|
||||
* into its own slot store — it emits no cordis events of its own and owns no
|
||||
* cross-plugin mutable relation; snapshot/store agreement is asserted by this
|
||||
* package's behavior specs.
|
||||
*/
|
||||
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 */
|
||||
@@ -1,139 +0,0 @@
|
||||
/** apply wiring: dictionary registration, declaration-aware section entry,
|
||||
* snapshot projection into the slot store, locale-driven relabeling, and
|
||||
* recovery after an HMR collapse of the declaring entry. */
|
||||
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 { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/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 { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import type { createGeneralSettingsStore } from '../src/client/store.ts'
|
||||
|
||||
const NS = 'settings.general'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
const theme = new ThemeService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
ctx.provide('theme', theme)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, theme }
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the section list slot from root. */
|
||||
function declareSection(slots: SlotsService): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
|
||||
/** Mirror the framework's inject choreography: bake a real instance from the
|
||||
* declared handle and hand its actions to the entry's inject factory. */
|
||||
function faceOf(slots: SlotsService) {
|
||||
const entry = slots.entries('settings.section')[0]!
|
||||
const handle = entry.store as ReturnType<typeof createGeneralSettingsStore>
|
||||
const instance = handle.create()
|
||||
const face = (entry.inject as unknown as (a: typeof instance.actions) => GeneralSectionInjected)(instance.actions)
|
||||
return { entry, instance, face }
|
||||
}
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the slot, locale, and theme services', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'theme'])
|
||||
})
|
||||
|
||||
it('registers dictionaries and the section entry for declarations before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declareSection(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = before.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(GeneralSection)
|
||||
expect(entry.options).toMatchObject({ id: 'general', order: 0, label: '通用设置' })
|
||||
expect(before.locale.bind(NS)('nav')).toBe('通用设置')
|
||||
|
||||
const after = await bench()
|
||||
const fiber = after.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(0)
|
||||
declareSection(after.slots)
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('settings.section')[0]!.component).toBe(GeneralSection)
|
||||
// Teardown without a live registration exercises the undefined-disposer arm.
|
||||
await fiber.dispose()
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('projects service snapshots into the store and routes face writes back', async () => {
|
||||
const b = await bench()
|
||||
declareSection(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// Events ahead of any inject hit the unbound-actions arm without a store.
|
||||
b.theme.setTheme('dark')
|
||||
|
||||
const { instance, face } = faceOf(b.slots)
|
||||
// The inject-time re-sync sealed the init window: both mirrors are current.
|
||||
expect(instance.getSnapshot().localeActive).toBe('zh')
|
||||
expect(instance.getSnapshot().localeOptions.map(l => l.id)).toEqual(['zh', 'en'])
|
||||
expect(instance.getSnapshot().themePreference).toBe('dark')
|
||||
expect(face.t('nav')).toBe('通用设置')
|
||||
|
||||
face.setLocale('en')
|
||||
expect(b.locale.getLocale().active).toBe('en')
|
||||
expect(instance.getSnapshot().localeActive).toBe('en')
|
||||
expect(face.t('nav')).toBe('General')
|
||||
|
||||
face.setTheme('system')
|
||||
expect(b.theme.getTheme().preference).toBe('system')
|
||||
expect(instance.getSnapshot().themePreference).toBe('system')
|
||||
})
|
||||
|
||||
it('re-registers with a fresh ledger label when the locale changes', async () => {
|
||||
const b = await bench()
|
||||
declareSection(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置')
|
||||
b.locale.setLocale('en')
|
||||
const entry = b.slots.entries('settings.section')[0]!
|
||||
expect(entry.options.label).toBe('General')
|
||||
expect(entry.component).toBe(GeneralSection)
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const host = declareSection(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(1)
|
||||
|
||||
// Collapse: the declarer dies, the cascade removes our entry while the
|
||||
// apply closure still holds its (now stale) disposer.
|
||||
host()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
|
||||
// A locale change inside the collapsed window must stay quiet.
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
|
||||
// Redeclaration restores the entry — with the current locale's label.
|
||||
declareSection(b.slots)
|
||||
await Promise.resolve()
|
||||
const entry = b.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(GeneralSection)
|
||||
expect(entry.options.label).toBe('General')
|
||||
})
|
||||
|
||||
it('removes the entry and the dictionaries on teardown', async () => {
|
||||
const b = await bench()
|
||||
declareSection(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
// Dictionary disposal: translation falls back to the bare key.
|
||||
expect(b.locale.bind(NS)('nav')).toBe('nav')
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/** GeneralSection behavior: skeleton rows stay inert, Language menu drives
|
||||
* setLocale, Appearance cubes follow the preference and drive setTheme. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { createGeneralSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { GeneralSectionComponentProps } from '../src/client/contract.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
|
||||
/** Empty global standard-kit hooks (the section reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
function mount(init?: { active?: string; preference?: 'light' | 'dark' | 'system' }) {
|
||||
// Real store instance — the sanctioned zero-machinery path for tests.
|
||||
const store = createGeneralSettingsStore().create()
|
||||
store.actions.syncLocale(init?.active ?? 'en', LOCALES, 0)
|
||||
store.actions.syncTheme(init?.preference ?? 'system', 0)
|
||||
const setLocale = vi.fn()
|
||||
const setTheme = vi.fn()
|
||||
const props: GeneralSectionComponentProps = {
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useStore: bindSnapshotSelector(store),
|
||||
actions: store.actions,
|
||||
t: (key: string) => en[key] ?? key,
|
||||
setLocale,
|
||||
setTheme,
|
||||
}
|
||||
render(<GeneralSection {...props} />)
|
||||
return { store, setLocale, setTheme }
|
||||
}
|
||||
|
||||
const pressed = (name: RegExp): string | null =>
|
||||
screen.getByRole('button', { name }).getAttribute('aria-pressed')
|
||||
|
||||
describe('GeneralSection', () => {
|
||||
it('renders the four groups with skeleton rows inert', () => {
|
||||
const b = mount()
|
||||
// Permission: disabled selector showing the fixed value.
|
||||
const permission = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
|
||||
expect(permission.disabled).toBe(true)
|
||||
fireEvent.click(permission)
|
||||
// Tool Call: both mode cubes render as plain text, no buttons.
|
||||
expect(screen.getByText('Schema mode')).toBeDefined()
|
||||
expect(screen.getByText('Code mode')).toBeDefined()
|
||||
expect(screen.queryByRole('button', { name: /Schema mode/ })).toBeNull()
|
||||
expect(b.setLocale).not.toHaveBeenCalled()
|
||||
expect(b.setTheme).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the language menu, selects a locale, and closes', () => {
|
||||
const b = mount({ active: 'en' })
|
||||
const trigger = screen.getByRole('button', { name: /English/ })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '中文' }))
|
||||
expect(b.setLocale).toHaveBeenCalledWith('zh')
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the language menu on outside pointerdown without selecting', () => {
|
||||
const b = mount({ active: 'en' })
|
||||
const trigger = screen.getByRole('button', { name: /English/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
|
||||
expect(b.setLocale).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reflects a store locale change in the trigger label (unknown id falls back to the id)', () => {
|
||||
const b = mount({ active: 'en' })
|
||||
act(() => { b.store.actions.syncLocale('zh', LOCALES, 1) })
|
||||
expect(screen.getByRole('button', { name: /中文/ })).toBeDefined()
|
||||
act(() => { b.store.actions.syncLocale('fr', LOCALES, 2) })
|
||||
expect(screen.getByRole('button', { name: /fr/ })).toBeDefined()
|
||||
})
|
||||
|
||||
it('marks the appearance cube matching the preference and switches on click', () => {
|
||||
const b = mount({ preference: 'dark' })
|
||||
expect(pressed(/Dark/)).toBe('true')
|
||||
expect(pressed(/Light/)).toBe('false')
|
||||
expect(pressed(/System/)).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Light/ }))
|
||||
expect(b.setTheme).toHaveBeenCalledWith('light')
|
||||
// Selection follows the store mirror, not the click echo.
|
||||
act(() => { b.store.actions.syncTheme('light', 1) })
|
||||
expect(pressed(/Light/)).toBe('true')
|
||||
expect(pressed(/Dark/)).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
/** General settings store: snapshot-mirror actions and the revision guard. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createGeneralSettingsStore } from '../src/client/store.ts'
|
||||
|
||||
const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
|
||||
describe('createGeneralSettingsStore', () => {
|
||||
it('init shape: empty mirrors with revisions at -1', () => {
|
||||
const store = createGeneralSettingsStore().create()
|
||||
expect(store.getSnapshot()).toEqual({
|
||||
localeActive: '',
|
||||
localeOptions: [],
|
||||
localeRevision: -1,
|
||||
themePreference: 'system',
|
||||
themeRevision: -1,
|
||||
})
|
||||
})
|
||||
|
||||
it('syncLocale mirrors the snapshot and advances the revision', () => {
|
||||
const store = createGeneralSettingsStore().create()
|
||||
store.actions.syncLocale('zh', LOCALES, 0)
|
||||
expect(store.getSnapshot().localeActive).toBe('zh')
|
||||
expect(store.getSnapshot().localeOptions).toEqual(LOCALES)
|
||||
expect(store.getSnapshot().localeRevision).toBe(0)
|
||||
|
||||
store.actions.syncLocale('en', LOCALES, 1)
|
||||
expect(store.getSnapshot().localeActive).toBe('en')
|
||||
expect(store.getSnapshot().localeRevision).toBe(1)
|
||||
})
|
||||
|
||||
it('syncLocale revision guard drops stale and duplicate writes', () => {
|
||||
const store = createGeneralSettingsStore().create()
|
||||
store.actions.syncLocale('en', LOCALES, 5)
|
||||
// Stale (lower) and duplicate (equal) revisions leave the mirror intact.
|
||||
store.actions.syncLocale('zh', LOCALES, 4)
|
||||
store.actions.syncLocale('zh', LOCALES, 5)
|
||||
expect(store.getSnapshot().localeActive).toBe('en')
|
||||
expect(store.getSnapshot().localeRevision).toBe(5)
|
||||
})
|
||||
|
||||
it('syncTheme mirrors the preference and guards its revision independently', () => {
|
||||
const store = createGeneralSettingsStore().create()
|
||||
store.actions.syncTheme('dark', 0)
|
||||
expect(store.getSnapshot().themePreference).toBe('dark')
|
||||
expect(store.getSnapshot().themeRevision).toBe(0)
|
||||
|
||||
store.actions.syncTheme('light', 2)
|
||||
expect(store.getSnapshot().themePreference).toBe('light')
|
||||
|
||||
// Stale theme write is dropped; the locale revision axis is untouched.
|
||||
store.actions.syncTheme('system', 1)
|
||||
expect(store.getSnapshot().themePreference).toBe('light')
|
||||
expect(store.getSnapshot().themeRevision).toBe(2)
|
||||
expect(store.getSnapshot().localeRevision).toBe(-1)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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": "../ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -1,3 +0,0 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -1,6 +1,7 @@
|
||||
/* General section rows (figma 501:29983 'Options'): four groups, 16px
|
||||
* vertical padding each, hairline separator under all but the last. The
|
||||
* shell's content column owns the outer horizontal padding. */
|
||||
/* General section rows (figma 501:29983 'Options'): stacked groups, 16px
|
||||
* vertical padding each, hairline separator under all but the last child
|
||||
* (feature-contributed rows carry their own row chrome and separators; the
|
||||
* :last-child rule strips the trailing one wherever the column ends). */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
@@ -8,6 +9,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -26,10 +31,6 @@
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */
|
||||
.rowText {
|
||||
flex: 1;
|
||||
@@ -79,7 +80,7 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Cube rows share an 8px gap; cubes stretch to equal height. */
|
||||
/* Tool Call mode cubes share an 8px gap. */
|
||||
.cubeRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -102,27 +103,6 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
|
||||
* icon-over-label column, gap 4). */
|
||||
.themeCube {
|
||||
box-sizing: border-box;
|
||||
width: 276px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 20px 32px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
|
||||
* step has no alias-layer name). */
|
||||
.selected {
|
||||
51
packages/client/ui-settings/src/client/GeneralSection.tsx
Normal file
51
packages/client/ui-settings/src/client/GeneralSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GeneralSectionComponentProps } from './contract/slots.ts'
|
||||
import css from './GeneralSection.module.css'
|
||||
|
||||
/**
|
||||
* Render the General section content column.
|
||||
* @param props - composed slot props (contract/slots.ts).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) {
|
||||
return (
|
||||
<div className={css.section}>
|
||||
{/* Permission (skeleton): disabled selector pill. */}
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('permission.title')}</div>
|
||||
<div className={css.desc}>{t('permission.desc')}</div>
|
||||
</div>
|
||||
<button type="button" className={css.selector} disabled>
|
||||
{t('permission.value')}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.title}>{t('toolcall.title')}</div>
|
||||
<div className={css.cubeRow}>
|
||||
<div className={`${css.modeCube} ${css.selected}`}>
|
||||
<div className={css.title}>{t('toolcall.schema.title')}</div>
|
||||
<div className={css.desc}>{t('toolcall.schema.desc')}</div>
|
||||
</div>
|
||||
<div className={css.modeCube}>
|
||||
<div className={css.title}>{t('toolcall.code.title')}</div>
|
||||
<div className={css.desc}>{t('toolcall.code.desc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature-owned preference rows (Language, Appearance, …). */}
|
||||
{renderSlot('settings.general.item', {})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Settings shell slot contract: the shell occupies the sidebar-owned
|
||||
* `sidebar.settings` hole and declares the `settings.section` list slot that
|
||||
* section plugins (General, Models, …) contribute pages into.
|
||||
* 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.
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
@@ -19,6 +23,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* re-render trigger). Sections render inside the panel content column.
|
||||
*/
|
||||
'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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +74,21 @@ export type SettingsRootInjected = {
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
/**
|
||||
* Settings shell plugin, browser half. Occupies the sidebar-owned
|
||||
* `sidebar.settings` hole with the trigger row + modal panel, declares the
|
||||
* `settings.section` list slot, and projects that ledger into the panel
|
||||
* navigation. Export discipline: packages/client/AGENTS.md.
|
||||
* `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.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// 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 { SettingsRootInjected } from './contract/slots.ts'
|
||||
import type { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts'
|
||||
export type {
|
||||
GeneralSectionComponentProps, GeneralSectionInjected,
|
||||
SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
@@ -22,18 +29,20 @@ export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionO
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/**
|
||||
* Register the settings shell into `sidebar.settings` once the declaration is
|
||||
* on the ledger.
|
||||
* Register the settings shell into `sidebar.settings` and the shell-owned
|
||||
* General section into `settings.section`, each once its 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', { trigger: '设置', title: '设置', close: '关闭' }),
|
||||
ctx.locale.register('settings', 'en', { trigger: 'Settings', title: 'Settings', close: 'Close' }),
|
||||
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(':')
|
||||
@@ -73,4 +82,38 @@ export function apply(ctx: ClientContext): void {
|
||||
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.
|
||||
// Same ledger-judged deferral; label re-registers on locale change.
|
||||
const generalInjected = (): GeneralSectionInjected => ({
|
||||
t: ctx.locale.bind('settings'),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
let dispose: (() => void) | undefined
|
||||
const tryRegister = (): void => {
|
||||
if (ctx.slots.spec('settings.section') === undefined) return
|
||||
if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return
|
||||
dispose = 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)
|
||||
}
|
||||
const offLocale = ctx.on('locale/change', () => {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
tryRegister()
|
||||
})
|
||||
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
|
||||
tryRegister()
|
||||
return () => {
|
||||
offLocale()
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
}
|
||||
}, 'ui-settings: general section registration')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* `settings.general` namespace dictionaries. Skeleton-row technical copy
|
||||
* `settings` namespace dictionaries: shell chrome plus the shell-owned
|
||||
* General section (nav label, skeleton rows). Skeleton-row technical copy
|
||||
* (Read only / Schema mode / Code mode and their descriptions) is shared
|
||||
* verbatim across locales per the Figma design.
|
||||
* verbatim across locales per the Figma design. Feature-owned rows
|
||||
* (Language, Appearance) ship their copy in their own packages.
|
||||
*/
|
||||
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
@@ -16,27 +18,23 @@ const SHARED = {
|
||||
/** Simplified Chinese dictionary. */
|
||||
export const zh: LocaleDict = {
|
||||
...SHARED,
|
||||
'nav': '通用设置',
|
||||
'trigger': '设置',
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'general.nav': '通用设置',
|
||||
'permission.title': '权限',
|
||||
'permission.desc': '选择默认权限模式',
|
||||
'toolcall.title': '工具调用',
|
||||
'language.title': '语言',
|
||||
'appearance.title': '外观',
|
||||
'appearance.light': '浅色',
|
||||
'appearance.dark': '深色',
|
||||
'appearance.system': '跟随系统',
|
||||
}
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: LocaleDict = {
|
||||
...SHARED,
|
||||
'nav': 'General',
|
||||
'trigger': 'Settings',
|
||||
'title': 'Settings',
|
||||
'close': 'Close',
|
||||
'general.nav': 'General',
|
||||
'permission.title': 'Permission',
|
||||
'permission.desc': 'Choose default permission mode',
|
||||
'toolcall.title': 'Tool Call',
|
||||
'language.title': 'Language',
|
||||
'appearance.title': 'Appearance',
|
||||
'appearance.light': 'Light',
|
||||
'appearance.dark': 'Dark',
|
||||
'appearance.system': 'System',
|
||||
}
|
||||
@@ -4,8 +4,9 @@ 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 { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { GeneralSectionInjected, 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()
|
||||
@@ -77,11 +78,14 @@ describe('ui-settings apply', () => {
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots)
|
||||
expect(injected.sections()).toEqual([])
|
||||
// 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: '通用设置' }])
|
||||
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
|
||||
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
|
||||
b.slots.register({ name: 'settings.section', id: 'a', order: 5 } as never, () => null)
|
||||
expect(injected.sections()).toEqual([
|
||||
{ id: 'a', order: 0, label: '' },
|
||||
{ id: 'general', order: 0, label: '通用设置' },
|
||||
{ id: 'a', order: 5, label: '' },
|
||||
{ id: 'z', order: 20, label: 'Z' },
|
||||
])
|
||||
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
|
||||
@@ -118,3 +122,72 @@ describe('ui-settings apply', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
47
packages/client/ui-settings/tests/general-section.spec.tsx
Normal file
47
packages/client/ui-settings/tests/general-section.spec.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// @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 { GeneralSection } from '../src/client/GeneralSection.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'],
|
||||
)
|
||||
const props: GeneralSectionComponentProps = {
|
||||
t: (key) => en[key] ?? key,
|
||||
renderSlot,
|
||||
}
|
||||
const view = render(<GeneralSection {...props} />)
|
||||
return { view, renderSlot }
|
||||
}
|
||||
|
||||
describe('GeneralSection', () => {
|
||||
it('renders the Permission skeleton row with the disabled selector', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Permission')).toBeTruthy()
|
||||
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
|
||||
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
|
||||
expect(selector.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the Tool Call skeleton cubes with schema pinned selected', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Tool Call')).toBeTruthy()
|
||||
const schema = screen.getByText('Schema mode')
|
||||
const code = screen.getByText('Code mode')
|
||||
expect(schema.parentElement!.className).toContain('selected')
|
||||
expect(code.parentElement!.className).not.toContain('selected')
|
||||
expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy()
|
||||
expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the feature-contributed item slot after the skeleton rows', () => {
|
||||
const { renderSlot } = mount()
|
||||
expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {})
|
||||
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-theme",
|
||||
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets",
|
||||
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -24,18 +24,32 @@
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"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-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"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-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
@@ -48,5 +62,8 @@
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/client/ui-theme/src/client/AppearanceRow.module.css
Normal file
51
packages/client/ui-theme/src/client/AppearanceRow.module.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8,
|
||||
* pad 16/0, hairline separator; the section column strips it when last). */
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.cubeRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
|
||||
* icon-over-label column, gap 4). */
|
||||
.themeCube {
|
||||
box-sizing: border-box;
|
||||
width: 276px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 20px 32px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
|
||||
* step has no alias-layer name). */
|
||||
.selected {
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
border-color: var(--dsw-static-neutral-bluish-400);
|
||||
}
|
||||
63
packages/client/ui-theme/src/client/AppearanceRow.tsx
Normal file
63
packages/client/ui-theme/src/client/AppearanceRow.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Appearance preference row registered into the General section item slot
|
||||
* (figma 501:30012 'Frame 2117131228'): title + three preference cubes.
|
||||
* Registered by this package — the theme feature owns its own settings
|
||||
* surface. Selection follows the persisted preference, never the resolved
|
||||
* active theme.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemePreference } from './index.ts'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createAppearanceRowStore } from './settings-store.ts'
|
||||
import css from './AppearanceRow.module.css'
|
||||
|
||||
/** Injected business face: namespace-bound translate + the preference write. */
|
||||
export interface AppearanceRowInjected {
|
||||
/** Translate a `settings.theme` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the theme preference. */
|
||||
setTheme: (id: ThemePreference) => void
|
||||
}
|
||||
|
||||
/** Full component props: runtime share + store share + injected face. */
|
||||
export type AppearanceRowComponentProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
|
||||
|
||||
/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
|
||||
const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
|
||||
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
|
||||
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
|
||||
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the Appearance row.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row element tree.
|
||||
*/
|
||||
export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) {
|
||||
const preference = useStore(s => s.preference)
|
||||
return (
|
||||
<div className={css.group}>
|
||||
<div className={css.title}>{t('appearance.title')}</div>
|
||||
<div className={css.cubeRow}>
|
||||
{CUBES.map(({ id, labelKey, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={clsx(css.themeCube, preference === id && css.selected)}
|
||||
aria-pressed={preference === id}
|
||||
onClick={() => { setTheme(id) }}
|
||||
>
|
||||
<Icon />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,24 @@
|
||||
* Browser theme registry over the `--dsw-*` token stylesheets. The service
|
||||
* owns the theme preference (light/dark/system), resolves `system` through
|
||||
* `prefers-color-scheme`, and publishes immutable snapshots; it never touches
|
||||
* the DOM — ui-layout's presenter consumes the resolved snapshot.
|
||||
* the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin
|
||||
* also registers the Appearance preference row into the settings General
|
||||
* section — the theme feature owns its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
export type { AppearanceRowState } from './settings-store.ts'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.theme'
|
||||
|
||||
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
||||
export type ThemeTokens = Record<string, string>
|
||||
@@ -200,13 +215,75 @@ function persistPreference(preference: ThemePreference): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services (none; the loader passes the export surface as an object plugin). */
|
||||
export const inject: string[] = []
|
||||
/** Required services: slots + locale (the feature registers its own settings row with localized copy). */
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the theme service.
|
||||
* Client plugin body: provide the theme service and register the
|
||||
* feature-owned Appearance preference row into the General section's item
|
||||
* slot (a feature owns its settings surface).
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.provide('theme', new ThemeService(ctx))
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(SETTINGS_NS, 'zh', {
|
||||
'appearance.title': '外观',
|
||||
'appearance.light': '浅色',
|
||||
'appearance.dark': '深色',
|
||||
'appearance.system': '跟随系统',
|
||||
}),
|
||||
ctx.locale.register(SETTINGS_NS, 'en', {
|
||||
'appearance.title': 'Appearance',
|
||||
'appearance.light': 'Light',
|
||||
'appearance.dark': 'Dark',
|
||||
'appearance.system': 'System',
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-theme: settings row dictionaries')
|
||||
|
||||
const store = createAppearanceRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
const sync = (snapshot: ThemeSnapshot): void => {
|
||||
bound?.sync(snapshot.preference, snapshot.revision)
|
||||
}
|
||||
ctx.on('theme/change', sync)
|
||||
const injected = (actions: BoundActions<typeof store>): AppearanceRowInjected => {
|
||||
bound = actions
|
||||
// Re-sync from the getter so no event is lost between registration and
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(theme.getTheme())
|
||||
return {
|
||||
t: ctx.locale.bind(SETTINGS_NS),
|
||||
setTheme: (id) => { theme.setTheme(id) },
|
||||
}
|
||||
}
|
||||
// Declaration-aware registration; the LEDGER is the has-registered judge
|
||||
// (not a local flag): after an HMR collapse re-declares the slot, the
|
||||
// cascade already removed our entry, and a stale disposer must not block
|
||||
// the re-registration.
|
||||
ctx.effect(() => {
|
||||
let dispose: (() => void) | undefined
|
||||
const tryRegister = (): void => {
|
||||
if (ctx.slots.spec('settings.general.item') === undefined) return
|
||||
if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return
|
||||
dispose = ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'appearance',
|
||||
order: 10,
|
||||
store,
|
||||
inject: injected,
|
||||
}, AppearanceRow)
|
||||
}
|
||||
const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() })
|
||||
tryRegister()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
}
|
||||
}, 'ui-theme: appearance settings row registration')
|
||||
}
|
||||
|
||||
17
packages/client/ui-theme/src/client/settings-contract.ts
Normal file
17
packages/client/ui-theme/src/client/settings-contract.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 {}
|
||||
37
packages/client/ui-theme/src/client/settings-store.ts
Normal file
37
packages/client/ui-theme/src/client/settings-store.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Appearance row slot store: a mirror of the theme service snapshot. The
|
||||
* plugin's apply-world change listener is the only writer; the row component
|
||||
* reads via props.useStore.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ThemePreference } from './index.ts'
|
||||
|
||||
/** Store state mirrored from the theme snapshot. */
|
||||
export interface AppearanceRowState {
|
||||
/** Persisted preference (selection state reads this, never the resolved active theme). */
|
||||
preference: ThemePreference
|
||||
/** Service revision; -1 until first sync so revision 0 lands as a change. */
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** Declared action shape giving the exported factory a stable return type. */
|
||||
type AppearanceRowActions = {
|
||||
sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the Appearance row state and write surface.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function createAppearanceRowStore(): EngineStoreHandle<AppearanceRowState, AppearanceRowActions> {
|
||||
return defineStore({
|
||||
init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }),
|
||||
actions: {
|
||||
sync: (d, preference: ThemePreference, revision: number) => {
|
||||
if (revision <= d.revision) return
|
||||
d.preference = preference
|
||||
d.revision = revision
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
6
packages/client/ui-theme/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-theme/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -4,6 +4,8 @@ import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
|
||||
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
@@ -18,9 +20,13 @@ describe('invariant companion', () => {
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('client apply provides ctx.theme with no service prerequisites', async () => {
|
||||
expect(inject).toEqual([])
|
||||
it('client apply provides ctx.theme over the slots/locale edges', async () => {
|
||||
// The feature registers its own Appearance settings row with localized
|
||||
// copy, hence the slots + locale edges.
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
})
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
141
pnpm-lock.yaml
generated
141
pnpm-lock.yaml
generated
@@ -140,18 +140,15 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-models':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-models
|
||||
'@deepseek-ai/dsh-client-ui-question':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-question
|
||||
'@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-settings-models':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-settings-models
|
||||
'@deepseek-ai/dsh-client-ui-sidebar':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-sidebar
|
||||
@@ -750,13 +747,32 @@ importers:
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
|
||||
|
||||
packages/client/locale:
|
||||
dependencies:
|
||||
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-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/modules:
|
||||
devDependencies:
|
||||
@@ -865,6 +881,33 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-models:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
'@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-primitives:
|
||||
dependencies:
|
||||
clsx:
|
||||
@@ -979,70 +1022,6 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-settings-general:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@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-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-ui-theme':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-theme
|
||||
'@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-models:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
'@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-sidebar:
|
||||
dependencies:
|
||||
clsx:
|
||||
@@ -1087,13 +1066,35 @@ importers:
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/ui-theme:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@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-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-trajectory:
|
||||
devDependencies:
|
||||
|
||||
@@ -60,8 +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-settings-models': { 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.' },
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
|
||||
@@ -116,8 +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-settings-models": ["./packages/client/ui-settings-models/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"],
|
||||
"@deepseek-ai/dsh-*": [
|
||||
|
||||
@@ -39,8 +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-settings-models" },
|
||||
{ "path": "./packages/client/ui-models" },
|
||||
{ "path": "./packages/client/locale" },
|
||||
{ "path": "./packages/client/web" },
|
||||
{ "path": "./apps/web" }
|
||||
|
||||
Reference in New Issue
Block a user