refactor(gui): features register their own settings surfaces

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

View File

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

View File

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

View File

@@ -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'

View File

@@ -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:^",

View File

@@ -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"

View File

@@ -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'] },

View File

@@ -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',

View File

@@ -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"
}
}

View 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;
}

View 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>
)
}

View File

@@ -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')
}

View 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 {}

View 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
},
},
})
}

View File

@@ -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)

View File

@@ -8,6 +8,15 @@
"src"
],
"references": [
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},

View File

@@ -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.

View File

@@ -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",

View File

@@ -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')
}

View File

@@ -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']

View File

@@ -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'])
})

View File

@@ -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
})

View 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'])

View File

@@ -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.

View File

@@ -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"
]
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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')
}

View File

@@ -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
},
},
})
}

View File

@@ -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 {}

View File

@@ -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 */

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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
})
})

View File

@@ -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)
})
})

View File

@@ -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"
}
]
}

View File

@@ -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'])

View File

@@ -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'])

View File

@@ -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 {

View 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>
)
}

View File

@@ -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

View File

@@ -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')
}

View File

@@ -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',
}

View File

@@ -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()
})
})

View 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()
})
})

View File

@@ -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"
}
}

View 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);
}

View 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>
)
}

View File

@@ -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')
}

View 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 {}

View 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
},
},
})
}

View File

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

View File

@@ -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)
})

View File

@@ -8,6 +8,18 @@
"src"
],
"references": [
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},

141
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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.' },

View File

@@ -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-*": [

View File

@@ -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" }