feat(gui): settings panel with locale and theme preferences

Add the browser Settings surface as slot-composed plugins over new
preference services:

- Rename dsh-client-i18n to dsh-client-locale (locale is the domain
  name); LocaleService adds getLocale()/setLocale(id), immutable
  snapshots, a locale/change event, and dsh.locale persistence.
- ThemeService owns the light/dark/system preference (default system),
  resolves system via prefers-color-scheme, publishes theme/change
  snapshots, persists dsh.theme, and no longer touches the DOM;
  ui-layout's ThemePresenter applies resolved snapshots
  (body[data-ds-dark-theme] + alias tokens) and cleans up on dispose.
- ui-sidebar drops the phase-1 settings dropdown/modal; the foot renders
  the new sidebar.settings slot with the column state.
- New ui-settings shell occupies sidebar.settings: foot trigger row and
  the centered 1080x700 panel (figma 501:29947) with 24% mask, close
  button / mask click / Escape all closing, and a 188px nav projected
  from the settings.section list slot it declares. Nav labels are
  registrant-localized; sections re-register on locale change, so the
  ledger version is the shell's only subscription.
- ui-settings-general registers the General section: Permission and
  Tool Call skeletons, live Language (locale menu) and Appearance
  (Light/Dark/System cubes following the persisted preference); its
  slot store mirrors both service snapshots via apply-side listeners.
- ui-settings-models registers the Models nav entry with an empty
  content column.
- Portaled menus pin z-index above modal overlays (a menu anchored
  inside the settings dialog rendered underneath it and was
  unclickable).
- theme/data/list-pen icons in ui-primitives; settings copy ships as
  zh/en dictionaries; fixture manifests gain the settings rows.
This commit is contained in:
imccyu
2026-07-26 00:28:43 +08:00
parent 84be7cc622
commit 6e721b9fdd
88 changed files with 2653 additions and 404 deletions
@@ -0,0 +1,99 @@
# Agent Note: Client Settings、Locale 与 Theme 分层
Status: proposed
## Problem
浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。
## Proposal
Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由独立插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。
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。
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 即自动出现) |
首期只翻译 Settings 浮层内文案(General 各行 + 导航);其他页面文案不动。
### Slot topology
```text
root
└─ sidebar
└─ sidebar.settings single/root
└─ ui-settings
└─ settings.section list/root
├─ general ui-settings-general
└─ models ui-settings-models
```
section contribution 使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。
### Service contracts
```ts
type ThemePreference = 'light' | 'dark' | 'system'
interface ThemeSnapshot {
preference: ThemePreference
active: ThemeDefinition // system 已解析为具体 light/dark 定义
themes: readonly ThemeDefinition[]
revision: number
}
interface LocaleSnapshot {
active: 'zh' | 'en'
locales: readonly LocaleDefinition[]
revision: number
}
interface Events {
/** @param snapshot - Current locale registry snapshot. @mode emit */
'locale/change'(snapshot: LocaleSnapshot): void
/** @param snapshot - Current theme registry snapshot. @mode emit */
'theme/change'(snapshot: ThemeSnapshot): void
}
```
Locale 内置中文和 English`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。
## Alternatives considered
**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。
**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。
**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。
**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。
**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。
## Acceptance criteria
- Settings 壳只依赖 slot ledger,不依赖任一 section 实现。
- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。
- General store 初始化走 getter,后续由两个 event 更新并局部重渲染。
- Layout 独立应用 Theme snapshotTheme service 不访问 DOMpresenter 不出现 system 分支。
- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。
- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。
- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。
## Risks
slot 声明与 contribution 的 apply 顺序不固定,所有新 section 必须保留 declaration-aware registration 和幂等防护。service event 可能早于 section 首次渲染,General store 的 init 与 controller attach 都必须从 getter 对齐当前 snapshot。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。
+11 -2
View File
@@ -221,8 +221,8 @@
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
- id: i18n
name: '@deepseek-ai/dsh-client-i18n'
- id: locale
name: '@deepseek-ai/dsh-client-locale'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
@@ -230,6 +230,15 @@
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
- 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-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
+4 -1
View File
@@ -23,13 +23,16 @@
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@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-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
+10 -1
View File
@@ -42,7 +42,16 @@
"path": "../../packages/client/ui-theme"
},
{
"path": "../../packages/client/i18n"
"path": "../../packages/client/ui-settings"
},
{
"path": "../../packages/client/ui-settings-general"
},
{
"path": "../../packages/client/ui-settings-models"
},
{
"path": "../../packages/client/locale"
},
{
"path": "../../packages/client/ui-layout"
+4 -1
View File
@@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ 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-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'] },
+1 -1
View File
@@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise<number> {
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
+4 -1
View File
@@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ 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-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',
-16
View File
@@ -1,16 +0,0 @@
# @deepseek-ai/dsh-client-i18n
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
## Model Experience
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.
-108
View File
@@ -1,108 +0,0 @@
/**
* Browser-side locale registry. Bound translation functions retain stable
* identity for injected consumers.
*/
import type { Context } from 'cordis'
// Snapshot stores are framework-neutral; React consumers bind hooks at their
// rendering boundary.
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
declare module 'cordis' {
interface Context {
i18n: I18nService
}
}
/** Fallback locale consulted after the active locale misses. */
export const FALLBACK_LOCALE = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/**
* Dictionary registry plus locale switch. Lookup chain per key: active locale
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
* the UI rather than blank).
*/
export class I18nService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the locale store at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
/** Active locale store (switching re-renders the tree; low frequency). */
get locale(): SnapshotStore<string> {
return this.localeStore
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/**
* Client plugin body: provide the i18n service with base dictionaries.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const i18n = new I18nService()
i18n.register(COMMON_NS, 'zh', zh)
i18n.register(COMMON_NS, 'en', en)
ctx.provide('i18n', i18n)
}
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest'
import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
describe('I18nService', () => {
it('translates from the active locale with zh fallback then key passthrough', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
i18n.register('ns', 'en', { hello: 'Hello' })
const t = i18n.bind('ns')
expect(i18n.locale.getSnapshot()).toBe('zh')
expect(t('hello')).toBe('你好')
i18n.locale.set('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = i18n.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
expect(t('greet')).toBe('你好,{name}!第 {n} 次')
})
it('bind returns a stable reference per namespace', () => {
const i18n = new I18nService()
expect(i18n.bind('a')).toBe(i18n.bind('a'))
expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
})
it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
const i18n = new I18nService()
const dispose = i18n.register('ns', 'zh', { k: 'v1' })
expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
dispose()
const t = i18n.bind('ns')
expect(t('k')).toBe('k')
i18n.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
})
it('locale store is subscribable (snapshot store contract)', () => {
const i18n = new I18nService()
let notified = 0
i18n.locale.subscribe(() => { notified += 1 })
i18n.locale.set('en')
expect(i18n.locale.getSnapshot()).toBe('en')
expect(notified).toBe(1)
})
})
@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/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(I18nInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
expect(inject).toEqual([])
const ctx = new Context()
await ctx.plugin({ inject, apply: clientApply }).await()
const i18n = ctx.get('i18n')
expect(i18n).toBeInstanceOf(I18nService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})
-3
View File
@@ -1,3 +0,0 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])
+16
View File
@@ -0,0 +1,16 @@
# @deepseek-ai/dsh-client-locale
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
## Model Experience
None, as the locale registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-i18n",
"description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton",
"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)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -28,9 +28,6 @@
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -46,5 +43,9 @@
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Browser-side locale registry. Bound translation functions retain stable
* identity for injected consumers.
*/
import type { Context } from 'cordis'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
/** Locale identifier: the two shipped locales. */
export type LocaleId = 'zh' | 'en'
/** One selectable locale: id plus its self-described display name. */
export interface LocaleDefinition {
/** Locale id (persisted; the setLocale argument). */
id: LocaleId
/** Display name in its own language (中文 / English). */
label: string
}
/** Immutable locale state published on every change. */
export interface LocaleSnapshot {
/** Active locale id. */
active: LocaleId
/** Selectable locales in display order. */
locales: readonly LocaleDefinition[]
/** Monotonic change counter (registry or active changes). */
revision: number
}
declare module 'cordis' {
interface Context {
locale: LocaleService
}
interface Events {
/**
* Locale state changed (active locale switched or registry updated).
* @param snapshot - Current immutable locale snapshot.
* @mode emit
*/
'locale/change'(snapshot: LocaleSnapshot): void
}
}
/** Fallback locale consulted after the active locale misses (also the default). */
export const FALLBACK_LOCALE: LocaleId = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
/** The two shipped locales. */
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
{ id: 'zh', label: '中文' },
{ id: 'en', label: 'English' },
])
/**
* Dictionary registry plus locale preference. Lookup chain per key: active
* locale -> zh fallback -> the key itself (missing text stays visible, fail
* loud in the UI rather than blank). Reads go through {@link getLocale};
* writes only through {@link setLocale}; continuous sync only through the
* `locale/change` event.
*/
export class LocaleService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private readonly ctx: Context
/**
* @param ctx - owning context (change events are emitted on it).
*/
constructor(ctx: Context) {
this.ctx = ctx
this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 })
}
/**
* Read the current immutable locale snapshot.
* @returns the current snapshot (stable reference until the next change).
*/
getLocale(): LocaleSnapshot {
return this.snapshot
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
* @param id - a registered locale id; unknown ids throw.
*/
setLocale(id: string): void {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
this.snapshot = Object.freeze({
active: match.id,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
persistPreference(match.id)
this.ctx.emit('locale/change', this.snapshot)
}
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the active locale at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.snapshot.active)?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
function restorePreference(): LocaleId {
try {
const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
if (stored === 'zh' || stored === 'en') return stored
} catch {
// Storage access can throw (privacy mode); the default below covers it.
}
return FALLBACK_LOCALE
}
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
function persistPreference(id: LocaleId): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, id)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/**
* Client plugin body: provide the locale service with base dictionaries.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const locale = new LocaleService(ctx)
locale.register(COMMON_NS, 'zh', zh)
locale.register(COMMON_NS, 'en', en)
ctx.provide('locale', locale)
}
@@ -1,4 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the i18n plugin. */
/** Host plugin body — no host-side behavior for the locale plugin. */
export function apply(): void {}
@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
* @module @deepseek-ai/dsh-client-i18n/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`.
* @module @deepseek-ai/dsh-client-locale/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'
/** Cordis companion plugin name. */
export const name = 'client-i18n-invariant'
export const name = 'client-locale-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
@@ -0,0 +1,30 @@
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 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(LocaleInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
expect(inject).toEqual([])
const ctx = new Context()
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
const ctx = new Context()
const events: LocaleSnapshot[] = []
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
return { ctx, svc: new LocaleService(ctx), events }
}
describe('LocaleService', () => {
beforeEach(() => {
localStorage.clear()
})
it('translates through the active-locale -> zh -> key chain', () => {
const { svc } = make()
svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
svc.register('ns', 'en', { hello: 'Hello' })
const t = svc.bind('ns')
expect(svc.getLocale().active).toBe('zh')
expect(t('hello')).toBe('你好')
svc.setLocale('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = svc.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
})
it('bind returns a stable per-namespace function identity', () => {
const { svc } = make()
expect(svc.bind('a')).toBe(svc.bind('a'))
expect(svc.bind('a')).not.toBe(svc.bind('b'))
})
it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v1' })
expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
const t = svc.bind('ns')
expect(t('k')).toBe('k')
svc.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
dispose()
expect(t('k')).toBe('v2')
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
expect(events).toHaveLength(1)
expect(events[0]).toBe(svc.getLocale())
expect(events[0]!.revision).toBe(1)
svc.setLocale('en')
expect(events).toHaveLength(1)
})
it('throws on unknown locale ids', () => {
const { svc } = make()
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
})
it('restores a persisted locale and falls back to zh on garbage', () => {
localStorage.setItem(STORAGE_KEY, 'en')
expect(make().svc.getLocale().active).toBe('en')
localStorage.setItem(STORAGE_KEY, 'fr')
expect(make().svc.getLocale().active).toBe('zh')
})
it('exposes the two shipped locales with self-described labels', () => {
const { svc } = make()
expect(svc.getLocale().locales).toEqual([
{ id: 'zh', label: '中文' },
{ id: 'en', label: 'English' },
])
})
})
@@ -11,9 +11,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../../support/invariants"
}
+3
View File
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js'])
+1 -1
View File
@@ -33,7 +33,7 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
* five importers (locale, ui-layout, ui-conversation ×3) ride this single
* exemption. At runtime the lazy CJS table answers the require natively:
* runtime is an immediately-tier row, its factory is registered before any
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
+1 -1
View File
@@ -24,7 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-i18n",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
@@ -99,7 +99,7 @@ async function bench() {
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
ctx.provide('locale', { bind: () => (key: string) => key })
// The AppFrame role: the three conversation-package slots must be declared
// by a live entry before apply can contribute into them (the stand-in
@@ -48,7 +48,7 @@ async function bench() {
sendSession: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
ctx.provide('locale', { bind: () => (key: string) => key })
// Declared by ui-layout's root entry in production; a stand-in root
// occupant declares them here so the contributions land (it consumes
@@ -93,7 +93,7 @@ async function bench(nodes: ToolResultNode[]) {
sendSession: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
ctx.provide('locale', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
@@ -212,7 +212,7 @@ describe('registrant load-order seam', () => {
sendSession: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
ctx.provide('locale', { bind: () => (key: string) => key })
slots.register({
name: 'root',
children: {
@@ -27,7 +27,7 @@
"path": "../ui-layout"
},
{
"path": "../i18n"
"path": "../locale"
},
{
"path": "../../support/invariants"
+1 -1
View File
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width.
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables).
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
+4 -1
View File
@@ -24,7 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
},
@@ -36,6 +37,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-ui-theme": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
@@ -43,6 +45,7 @@
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "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",
+17 -2
View File
@@ -4,13 +4,16 @@
* four child slots (declaration = exclusive render authority), seats the
* layout store (panel geometry), and wires the panel-action service face.
* ctx.layout is the cross-plugin panel-action seam; navigation state lives
* with the runtime sessions service.
* with the runtime sessions service. A second effect seats the theme
* presenter, which projects ctx.theme snapshots onto document.body.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type { PanelActions } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutService } from './service.ts'
import { ThemePresenter } from './theme-presenter.ts'
// Contract surface only (export-convergence rule: cross-package consumers
// keep a symbol exported; test-only/package-internal symbols live off /src).
@@ -62,7 +65,7 @@ export interface DetailsOwnerProps {}
export interface EmptyOwnerProps { children?: never }
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots']
export const inject = ['slots', 'theme']
/**
* Client plugin body: provide ctx.layout, then one register() call — AppFrame
@@ -98,4 +101,16 @@ export function apply(ctx: ClientContext): void {
void disposeService()
}
}, 'ui-layout: service + root registration')
// Theme presentation: pure DOM writes from resolved snapshots — initial
// state through the getter once, then event-driven only; no React path.
ctx.effect(() => {
const presenter = new ThemePresenter()
presenter.apply(ctx.theme.getTheme())
const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) })
return () => {
off()
presenter.dispose()
}
}, 'ui-layout: theme presenter')
}
@@ -0,0 +1,43 @@
/**
* Global theme DOM applier: projects the resolved ThemeSnapshot onto
* document.body — the `data-ds-dark-theme` palette switch plus the active
* theme's alias-token overrides as inline CSS variables. Pure DOM writes, no
* React involvement; the presenter only ever retracts what it wrote itself,
* so foreign body attributes and inline styles survive apply/dispose.
*/
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
/** Body attribute selecting the dark base palette in the token stylesheets. */
export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
/** Applies theme snapshots to document.body; one instance per plugin fiber. */
export class ThemePresenter {
/** Token names this presenter wrote in the last apply (its retraction set). */
private appliedTokens: string[] = []
/**
* Project a snapshot onto the body: switch the palette attribute from
* `active.colorScheme` (never the id — `system` is resolved upstream) and
* replace the previously applied token variables with `active.tokens`.
* @param snapshot - resolved theme snapshot from ctx.theme.
*/
apply(snapshot: ThemeSnapshot): void {
const body = document.body
if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
else body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)
this.appliedTokens = []
for (const [name, value] of Object.entries(snapshot.active.tokens)) {
body.style.setProperty(name, value)
this.appliedTokens.push(name)
}
}
/** Retract everything this presenter wrote: the palette attribute and all applied token variables. */
dispose(): void {
const body = document.body
body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)
this.appliedTokens = []
}
}
+20 -1
View File
@@ -9,6 +9,7 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
@@ -16,13 +17,14 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
await slotsFiber.await()
return { ctx, slots: ctx.get('slots') as SlotsService }
}
describe('ui-layout client apply', () => {
it('declares its service dependencies', () => {
expect(inject).toEqual(['slots'])
expect(inject).toEqual(['slots', 'theme'])
})
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
@@ -53,6 +55,23 @@ describe('ui-layout client apply', () => {
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
})
it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
const { ctx } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
// Initial getter application: jsdom has no matchMedia, system resolves light.
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
const theme = ctx.get('theme') as ThemeService
theme.setTheme('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
await fiber.dispose()
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
// Listener is off: further theme changes no longer reach the body.
theme.setTheme('light')
theme.setTheme('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
})
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
// ThemePresenter behavior account: the palette attribute follows
// active.colorScheme only, token variables replace the previous apply's set,
// and dispose retracts everything the presenter wrote.
import { beforeEach, describe, expect, it } from 'vitest'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts'
function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string> = {}): ThemeSnapshot {
// The presenter must key off colorScheme, not the id — keep them distinct.
const active = { id: `${colorScheme}-test`, colorScheme, tokens }
return { preference: colorScheme, active, themes: [active], revision: 1 }
}
beforeEach(() => {
document.body.removeAttribute(DARK_ATTRIBUTE)
document.body.removeAttribute('style')
})
describe('ThemePresenter', () => {
it('light scheme leaves the dark attribute absent', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('light'))
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('dark scheme sets the attribute; switching back to light removes it', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark'))
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
presenter.apply(snapshot('light'))
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('applies tokens as inline variables and clears the previous set on theme change', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111', '--dsw-alias-fg': '#eee' }))
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#111')
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('#eee')
presenter.apply(snapshot('light', { '--dsw-alias-bg': '#fff' }))
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#fff')
// The old theme's extra variable is gone, not merged.
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('')
})
it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => {
document.body.style.setProperty('--foreign', 'kept')
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
presenter.dispose()
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
})
})
+3
View File
@@ -14,6 +14,9 @@
{
"path": "../ui-slots"
},
{
"path": "../ui-theme"
},
{
"path": "../ui-primitives"
},
@@ -30,11 +30,13 @@
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
* anchor rect (side/align resolved in JS, the in-place offset rules above
* don't apply). */
* don't apply). Portaled lists must layer above modal overlays (z 1000) —
* an anchor inside a dialog still expects its menu on top. */
.portal {
position: fixed;
top: auto;
left: auto;
z-index: 1100;
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
@@ -583,3 +583,90 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => (
<path d="M0 0L-0.5 0L-0.5 7L0 7L0.5 7L0.5 0L0 0ZM3 10L3 10.5L8 10.5L8 10L8 9.5L3 9.5L3 10ZM0 7L-0.5 7C-0.5 8.933 1.067 10.5 3 10.5L3 10L3 9.5C1.61929 9.5 0.5 8.38071 0.5 7L0 7Z" fill="currentColor"/>
</svg>
)
/** ic_ds_light_outline_16 */
export const IconLightOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.3496 8C11.3496 6.14985 9.85015 4.65039 8 4.65039C6.14985 4.65039 4.65039 6.14985 4.65039 8C4.65039 9.85015 6.14985 11.3496 8 11.3496C9.85015 11.3496 11.3496 9.85015 11.3496 8ZM12.6504 8C12.6504 10.5681 10.5681 12.6504 8 12.6504C5.43188 12.6504 3.34961 10.5681 3.34961 8C3.34961 5.43188 5.43188 3.34961 8 3.34961C10.5681 3.34961 12.6504 5.43188 12.6504 8Z"
fill="currentColor"
/>
<path d="M8.65039 0.5V2.5H7.34961V0.5H8.65039Z" fill="currentColor" />
<path d="M8.65039 13.5V15.5H7.34961V13.5H8.65039Z" fill="currentColor" />
<path
d="M3.15808 2.24035L4.57229 3.65456L3.6525 4.57435L2.23829 3.16014L3.15808 2.24035Z"
fill="currentColor"
/>
<path
d="M12.3505 11.4327L13.7647 12.8469L12.8449 13.7667L11.4307 12.3525L12.3505 11.4327Z"
fill="currentColor"
/>
<path
d="M2.24537 12.8469L3.65958 11.4327L4.57937 12.3525L3.16516 13.7667L2.24537 12.8469Z"
fill="currentColor"
/>
<path
d="M11.4377 3.65455L12.852 2.24033L13.7718 3.16012L12.3575 4.57434L11.4377 3.65455Z"
fill="currentColor"
/>
<path d="M0.5 7.35461H2.5V8.6554H0.5L0.5 7.35461Z" fill="currentColor" />
<path d="M13.5 7.35461H15.5V8.6554H13.5V7.35461Z" fill="currentColor" />
</svg>
)
/** ic_ds_dark_outline_16 */
export const IconDarkOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.2764 9.52324C12.5607 9.97754 11.7177 10.242 10.7812 10.242C8.11386 10.2419 5.95042 8.07997 5.9502 5.41289C5.9502 4.48128 6.21453 3.61071 6.67188 2.87285C4.30332 3.4658 2.54992 5.60845 2.5498 8.16093C2.5498 11.1712 4.99103 13.6102 8 13.6102C10.5383 13.6102 12.6709 11.8724 13.2764 9.52324ZM7.05078 5.41289C7.051 7.47224 8.72116 9.1423 10.7812 9.14238C11.9248 9.14238 12.887 8.63397 13.5781 7.8084C13.7266 7.63106 13.9701 7.56547 14.1875 7.64433C14.4049 7.72329 14.5497 7.9297 14.5498 8.16093C14.5498 11.7766 11.6161 14.7098 8 14.7098C4.38402 14.7098 1.4502 11.7792 1.4502 8.16093C1.45033 4.54322 4.3812 1.61015 8 1.61015C8.23027 1.61015 8.43585 1.75352 8.51562 1.96953C8.59536 2.18554 8.53241 2.42829 8.35742 2.57793C7.55573 3.26311 7.05078 4.27876 7.05078 5.41289Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_followsystem_outline_16 */
export const IconFollowsystemOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.1665 13.5811V14.7803H3.66651V13.5811H12.1665Z" fill="currentColor" />
<path
d="M13.4453 7.02379C13.4453 6.04702 13.4452 5.3616 13.3887 4.83434C13.3333 4.31828 13.2302 4.02378 13.0723 3.80309C12.9446 3.62475 12.7877 3.46883 12.6094 3.34117C12.3887 3.18328 12.0942 3.08007 11.5781 3.02477C11.0508 2.96829 10.3655 2.96715 9.38867 2.96715H6.61035C5.63359 2.96715 4.94816 2.96827 4.4209 3.02477C3.90486 3.0801 3.61034 3.18321 3.38965 3.34117C3.21143 3.46878 3.05534 3.62487 2.92774 3.80309C2.76977 4.02377 2.66667 4.3183 2.61133 4.83434C2.55483 5.3616 2.55371 6.04702 2.55371 7.02379C2.55371 8.0006 2.55485 8.68596 2.61133 9.21324C2.66663 9.72936 2.76983 10.0238 2.92774 10.2445C3.0554 10.4228 3.21131 10.5797 3.38965 10.7074C3.61034 10.8654 3.90484 10.9685 4.4209 11.0238C4.94816 11.0803 5.63359 11.0804 6.61035 11.0804H9.38867C10.3654 11.0804 11.0508 11.0803 11.5781 11.0238C12.0941 10.9685 12.3887 10.8652 12.6094 10.7074C12.7877 10.5797 12.9446 10.4229 13.0723 10.2445C13.2301 10.0238 13.3334 9.72927 13.3887 9.21324C13.4452 8.68596 13.4453 8.00058 13.4453 7.02379ZM14.6455 7.02379C14.6455 7.97428 14.646 8.73509 14.5811 9.34117C14.5149 9.95828 14.3756 10.4858 14.0479 10.9437C13.8436 11.229 13.5938 11.4788 13.3086 11.683C12.8507 12.0108 12.3232 12.15 11.7061 12.2162C11.1 12.2811 10.3391 12.2806 9.38867 12.2806H6.61035C5.66018 12.2806 4.89991 12.2811 4.29395 12.2162C3.67684 12.15 3.14935 12.0108 2.69141 11.683C2.40613 11.4788 2.15639 11.229 1.95215 10.9437C1.62436 10.4858 1.4841 9.95828 1.41797 9.34117C1.35305 8.73511 1.35449 7.97424 1.35449 7.02379C1.35449 6.07366 1.35308 5.31333 1.41797 4.70738C1.4841 4.09028 1.62436 3.56279 1.95215 3.10485C2.15638 2.81956 2.40613 2.56982 2.69141 2.36559C3.14935 2.03779 3.67684 1.89753 4.29395 1.83141C4.8999 1.76652 5.66022 1.76793 6.61035 1.76793H9.38867C10.3391 1.76793 11.1 1.76649 11.7061 1.83141C12.3232 1.89753 12.8507 2.03779 13.3086 2.36559C13.5939 2.56982 13.8436 2.81957 14.0479 3.10485C14.3756 3.56279 14.5149 4.09028 14.5811 4.70738C14.646 5.31335 14.6455 6.07362 14.6455 7.02379Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_data_outline_16 */
export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0997 8.54554C12.2905 8.54989 12.3541 8.58056 12.4535 8.74614L12.8849 9.46387C12.9851 9.63071 13.0464 9.66013 13.2388 9.66447H14.1138C14.3417 9.66448 14.3512 9.66937 14.4686 9.86507L14.892 10.5717C14.9942 10.7422 14.9948 10.8247 14.892 10.9961L14.4756 11.6906C14.3741 11.8677 14.3694 11.9379 14.4756 12.115L14.892 12.8096C14.9942 12.9801 14.9947 13.0625 14.892 13.234L14.4686 13.9406C14.3643 14.1028 14.3063 14.1354 14.1138 14.1412H13.2388C13.0465 14.1456 12.985 14.1752 12.8849 14.3418L12.4535 15.0595C12.353 15.2195 12.2895 15.2558 12.0997 15.2601H11.2237C10.9962 15.2601 10.9871 15.2548 10.8699 15.0595L10.4384 14.3418C10.3383 14.175 10.2767 14.1456 10.0846 14.1412H9.2096C9.01854 14.1355 8.95761 14.1006 8.85477 13.9406L8.43139 13.234C8.32562 13.0576 8.33148 12.9862 8.43139 12.8096L8.84771 12.115C8.95165 11.9416 8.94659 11.863 8.84771 11.6906L8.43139 10.9961C8.32767 10.8232 8.33411 10.7437 8.43139 10.5717L8.85477 9.86507C8.95447 9.69891 9.01875 9.67017 9.2096 9.66447H10.0846C10.2741 9.66441 10.3414 9.62547 10.4384 9.46387L10.8699 8.74614C10.987 8.55106 10.9963 8.54554 11.2237 8.54554H12.0997ZM11.6612 10.232C11.3326 10.7798 10.8155 11.0948 10.1743 11.106C10.4443 11.61 10.4425 12.1976 10.1743 12.6987C10.803 12.7096 11.3391 13.0359 11.6612 13.5727C11.9855 13.0323 12.5131 12.7098 13.148 12.6987C12.879 12.196 12.8789 11.6086 13.148 11.106C12.5076 11.0948 11.9894 10.7794 11.6612 10.232Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.51205 0.790627C9.19055 0.790649 10.7401 1.0691 11.892 1.54364C12.4664 1.78029 12.9719 2.07885 13.3436 2.4408C13.7171 2.80467 13.9916 3.27253 13.9918 3.82384V7.90442C13.6067 7.69532 13.1907 7.53597 12.7529 7.43366V5.66454C12.4928 5.82898 12.2028 5.97601 11.892 6.10405C10.74 6.57865 9.19071 6.85706 7.51205 6.85706C5.8337 6.85703 4.285 6.57852 3.13309 6.10405C2.82215 5.97593 2.53164 5.8291 2.27121 5.66454V7.4135C2.27134 7.75678 2.6066 8.27106 3.62502 8.73405C4.58641 9.17097 5.95762 9.45591 7.50499 9.45681C7.24582 9.83133 7.03684 10.2434 6.88706 10.6826C5.44388 10.6162 4.12516 10.3216 3.11192 9.86104C2.81708 9.72698 2.53185 9.56866 2.27121 9.38928V11.2542C2.27158 11.5974 2.60697 12.1109 3.62502 12.5737C4.41933 12.9347 5.4937 13.1898 6.71569 13.2693C6.80349 13.7128 6.9513 14.1345 7.14814 14.5273C5.60324 14.4862 4.18593 14.1889 3.11192 13.7007C2.01039 13.1998 1.03366 12.3814 1.03333 11.2542V3.82384C1.03352 3.27273 1.30721 2.80461 1.68049 2.4408C2.05211 2.07893 2.55887 1.78026 3.13309 1.54364C4.28492 1.06926 5.83393 0.790683 7.51205 0.790627ZM7.51205 2.02851C5.95492 2.02857 4.57354 2.29079 3.60486 2.68979C3.11958 2.88977 2.76667 3.11253 2.5454 3.32788C2.32671 3.54101 2.2714 3.7089 2.27121 3.82384C2.27121 3.93882 2.32624 4.10625 2.5454 4.3198C2.76667 4.53527 3.11927 4.75781 3.60486 4.9579C4.5736 5.35699 5.95467 5.61914 7.51205 5.61918C9.06942 5.61918 10.4505 5.35695 11.4192 4.9579C11.9051 4.75773 12.2584 4.53536 12.4797 4.3198C12.6988 4.10627 12.7529 3.93882 12.7529 3.82384C12.7527 3.70889 12.6984 3.54104 12.4797 3.32788C12.2584 3.11239 11.9049 2.88989 11.4192 2.68979C10.4505 2.29079 9.06925 2.02853 7.51205 2.02851Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_List_Pen_outline_16 */
export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.8239 3.54733V4.78443H4.63437V3.54733H10.8239Z" fill="currentColor" />
<path d="M10.8239 6.12629V7.36338H4.63437V6.12629H10.8239Z" fill="currentColor" />
<path d="M9.073 8.70524V9.94234H4.63437V8.70524H9.073Z" fill="currentColor" />
<path
d="M9.13321 0.573526C10.0076 0.573525 10.7179 0.572522 11.285 0.63397C11.8645 0.696791 12.3743 0.831648 12.8193 1.1548C13.0776 1.34246 13.3056 1.57047 13.4933 1.82875C13.8164 2.2737 13.9513 2.7836 14.0141 3.36303C14.0755 3.93015 14.0745 4.64049 14.0745 5.51485V6.1757L12.7327 7.5629V5.51485C12.7327 4.61092 12.732 3.9862 12.6803 3.5081C12.6298 3.0427 12.5379 2.79497 12.4083 2.61654C12.3033 2.47211 12.176 2.34472 12.0315 2.23977C11.8531 2.11016 11.6054 2.01823 11.14 1.96777C10.6618 1.91601 10.0372 1.91539 9.13321 1.91539H6.32658C5.42262 1.91539 4.79796 1.91604 4.31983 1.96777C3.85451 2.01819 3.60672 2.11029 3.42827 2.23977C3.28392 2.34465 3.15643 2.47223 3.0515 2.61654C2.9219 2.79496 2.82997 3.04274 2.7795 3.5081C2.72774 3.9862 2.72712 4.61092 2.72712 5.51485V10.023C2.72712 10.9273 2.72773 11.5525 2.7795 12.0307C2.82992 12.4959 2.92205 12.7429 3.0515 12.9213C3.15645 13.0657 3.28384 13.1931 3.42827 13.2981C3.60676 13.4277 3.85408 13.5206 4.31983 13.5711C4.79797 13.6228 5.42259 13.6234 6.32658 13.6234H6.87057L5.57707 14.9593C5.03527 14.9556 4.57031 14.9467 4.17476 14.9039C3.59508 14.841 3.08558 14.7063 2.64048 14.383C2.38215 14.1953 2.15422 13.9684 1.96653 13.7101C1.64319 13.2649 1.50851 12.7546 1.4457 12.1748C1.38432 11.6076 1.38525 10.8974 1.38525 10.023V5.51485C1.38525 4.64049 1.38426 3.93015 1.4457 3.36303C1.50853 2.78363 1.64341 2.27368 1.96653 1.82875C2.15417 1.57059 2.38228 1.34239 2.64048 1.1548C3.08544 0.831805 3.59533 0.696762 4.17476 0.63397C4.74193 0.572552 5.45218 0.573525 6.32658 0.573526H9.13321Z"
fill="currentColor"
/>
<path d="M14.2193 14.9553H10.0124L11.3744 13.6134H14.2193V14.9553Z" fill="currentColor" />
<path
d="M8.24493 13.3711L7.49015 14.8806C7.40148 15.058 7.58961 15.2461 7.76695 15.1574L9.27651 14.4027L14.6147 9.09934L13.5832 8.06775L8.24493 13.3711Z"
fill="currentColor"
/>
</svg>
)
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => {
expect(iconNames.length).toBe(50)
it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => {
expect(iconNames.length).toBe(55)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {
@@ -0,0 +1,15 @@
# @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.
@@ -0,0 +1,70 @@
{
"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"
]
}
@@ -0,0 +1,131 @@
/* 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. */
.section {
display: flex;
flex-direction: column;
width: 100%;
}
/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */
.group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 0;
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;
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);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* 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;
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}
/* Cube rows share an 8px gap; cubes stretch to equal height. */
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset =
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */
.modeCube {
box-sizing: border-box;
width: 418px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 8px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: transparent;
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 {
background: var(--dsw-alias-bg-module-platform);
border-color: var(--dsw-static-neutral-bluish-400);
}
@@ -0,0 +1,118 @@
/**
* 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>
)
}
@@ -0,0 +1,66 @@
/**
* 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
@@ -0,0 +1,111 @@
/**
* 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
const register = (): void => {
dispose = ctx.slots.register({
name: 'settings.section',
id: 'general',
order: 0,
label: ctx.locale.bind(NS)('nav'),
store,
inject: injected,
}, GeneralSection)
}
const tryRegister = (): void => {
if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return
register()
}
// Nav labels are registrant-localized: re-register on locale change so
// the ledger carries fresh text (the version bump re-renders the shell).
const offLocale = ctx.on('locale/change', () => {
if (dispose === undefined) return
dispose()
register()
})
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
tryRegister()
return () => {
offLocale()
unsubscribe()
dispose?.()
}
}, 'ui-settings-general: section registration')
}
@@ -0,0 +1,42 @@
/**
* `settings.general` namespace dictionaries. Skeleton-row technical copy
* (Read only / Schema mode / Code mode and their descriptions) is shared
* verbatim across locales per the Figma design.
*/
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
const SHARED = {
'permission.value': 'Read only',
'toolcall.schema.title': 'Schema mode',
'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time',
'toolcall.code.title': 'Code mode',
'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration',
} satisfies LocaleDict
/** Simplified Chinese dictionary. */
export const zh: LocaleDict = {
...SHARED,
'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',
'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',
}
@@ -0,0 +1,43 @@
/**
* 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
},
},
})
}
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
@@ -0,0 +1,4 @@
/** 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 {}
@@ -0,0 +1,32 @@
/**
* 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 */
@@ -0,0 +1,36 @@
{
"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"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js'])
@@ -0,0 +1,15 @@
# @deepseek-ai/dsh-client-ui-settings-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.
## Model Experience
None, as the section renders an empty browser UI column; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Content column is empty by design** — provider list, editing form, and activation flow are deferred until the model-management service exists.
@@ -0,0 +1,63 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-models",
"description": "Models settings section plugin: nav entry with an empty content column (model management lands later)",
"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"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^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-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "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"
]
}
@@ -0,0 +1,13 @@
/**
* Models settings section: an intentionally empty content column — the nav
* entry exists so the section slot composition is visible; model management
* lands in a later phase.
*/
/**
* Render the (empty) Models section content column.
* @returns null — no content this phase.
*/
export function ModelsSection() {
return null
}
@@ -0,0 +1,63 @@
/**
* Models settings section plugin, browser half. Registers the `models` nav
* entry into the shell-declared `settings.section` list slot; the content
* column is intentionally empty until model management lands. Export
* discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ModelsSection } from './ModelsSection.tsx'
/**
* 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']
/**
* Register the Models section once the `settings.section` declaration is on
* the ledger.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register('settings.models', 'zh', { nav: '模型' }),
ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-models: nav copy dictionaries')
ctx.effect(() => {
let dispose: (() => void) | undefined
const register = (): void => {
dispose = ctx.slots.register({
name: 'settings.section',
id: 'models',
order: 10,
label: ctx.locale.bind('settings.models')('nav'),
}, ModelsSection)
}
const tryRegister = (): void => {
if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return
register()
}
// Nav labels are registrant-localized: re-register on locale change so
// the ledger carries fresh text (the version bump re-renders the shell).
const offLocale = ctx.on('locale/change', () => {
if (dispose === undefined) return
dispose()
register()
})
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
tryRegister()
return () => {
offLocale()
unsubscribe()
dispose?.()
}
}, 'ui-settings-models: section registration')
}
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the models settings plugin. */
export function apply(): void {}
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`.
* @module @deepseek-ai/dsh-client-ui-settings-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'
/** Cordis companion plugin name. */
export const name = 'client-ui-settings-models-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a nav-entry-only section plugin rendering a fixed
* empty content column — it emits no cordis events and owns no cross-plugin
* mutable relation.
*/
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 */
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../runtime"
},
{
"path": "../ui-settings"
},
{
"path": "../locale"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js'])
+15
View File
@@ -0,0 +1,15 @@
# @deepseek-ai/dsh-client-ui-settings
Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering).
## Model Experience
None, as the settings shell serves browser UI composition; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet.
+68
View File
@@ -0,0 +1,68 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings",
"description": "Settings shell plugin: sidebar trigger + modal panel occupying sidebar.settings; declares the settings.section list slot",
"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-sidebar",
"@deepseek-ai/dsh-client-locale"
],
"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-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "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"
]
}
@@ -0,0 +1,192 @@
/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
foot trigger row + centered 1080x700 modal panel. The trigger reproduces
the former sidebar foot geometry (49px wide row / 36px rail circle); the
panel is a two-column layout — 188px nav rail + content column with a
54px header and the 24px-padded options area. */
/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */
.trigger {
flex: none;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 49px;
margin: 8px 0 0;
padding: 0 2px 0 6px;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-family: inherit;
font-size: 14px;
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Rail trigger: the same 36x36 circle box as the other rail controls. */
.trigger.rail {
width: 36px;
height: 36px;
margin: 18px 0 10px;
justify-content: center;
gap: 0;
padding: 0;
border-radius: 50%;
}
.triggerLabel {
overflow: hidden;
white-space: nowrap;
}
/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
}
/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow
(figma effects match --dsw-shadow-lv3 exactly). */
.panel {
position: relative;
z-index: 1;
display: flex;
width: 1080px;
height: 700px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
border-radius: 24px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
box-shadow: var(--dsw-shadow-lv3);
}
/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0),
gap 18, no own fill — the panel white shows through. */
.nav {
flex: none;
display: flex;
flex-direction: column;
gap: 18px;
width: 188px;
padding: 22px 12px 0;
box-sizing: border-box;
}
/* Title row (figma 501:29959): 16/500 lh24, 12px side padding. */
.navTitle {
padding: 0 12px;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* Cell stack (figma 501:29961): gap 4. */
.navList {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad
(12,9,16,9), gap 8; label 14/400 lh22; selected fill #EBEEF2. */
.navCell {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 9px 16px 9px 12px;
box-sizing: border-box;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: 14px;
line-height: 22px;
font-weight: 400;
color: var(--dsw-alias-label-primary);
text-align: left;
}
.navCell:hover {
background: var(--dsw-specific-sidebar-nav-item-hover);
}
.navCell.active {
background: var(--dsw-specific-sidebar-nav-item-active);
}
.navIcon {
flex: none;
}
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Content column (figma Content 501:29980): header + options. */
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Header (figma .Header 501:29981): h54, pad (10,20,14,8), close right. */
.header {
flex: none;
display: flex;
align-items: flex-start;
justify-content: flex-end;
height: 54px;
padding: 20px 14px 8px 10px;
box-sizing: border-box;
}
/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 28px;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */
.options {
flex: 1;
min-height: 0;
padding: 0 24px 8px;
overflow-y: auto;
}
@@ -0,0 +1,125 @@
/**
* Settings shell root: the sidebar-foot trigger row plus the centered modal
* panel (figma 501:29947, 1080x700) with the section nav rail. Modal open
* state and the active section id are component-local viewing state; the
* section ledger arrives through the injected face (nav labels are
* registrant-localized — the shell owns no locale/theme subscription).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}
type PanelProps = {
translate: SettingsRootComponentProps['translate']
rows: ReturnType<SettingsRootComponentProps['sections']>
renderSlot: SettingsRootComponentProps['renderSlot']
onClose: () => void
}
/**
* The modal layer: full-viewport mask + centered panel. Close paths: the
* header button, a mask click, and document-level Escape (mounted only while
* open, so the listener lifetime is the panel's).
*/
function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
// Local selection; entries can unmount underneath it, so the render-time
// projection falls back to the first row when the id is gone.
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [onClose])
// Baseline focus management: entering the dialog lands on the close button.
const closeButton = useRef<HTMLButtonElement | null>(null)
useEffect(() => { closeButton.current?.focus() }, [])
return (
<div className={css.overlay} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div className={css.panel} role="dialog" aria-modal="true" aria-label={translate('settings:title')}>
<nav className={css.nav} aria-label={translate('settings:title')}>
<div className={css.navTitle}>{translate('settings:title')}</div>
<div className={css.navList}>
{rows.map((row) => (
<button
key={row.id}
type="button"
className={clsx(css.navCell, row.id === active && css.active)}
aria-current={row.id === active ? 'true' : undefined}
onClick={() => { setActiveId(row.id) }}
>
{navIcon(row.id)}
<span className={css.navLabel}>{row.label}</span>
</button>
))}
</div>
</nav>
<div className={css.content}>
<div className={css.header}>
<button ref={closeButton} type="button" className={css.close} aria-label={translate('settings:close')} onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
<div className={css.options}>
{active !== undefined && renderSlot('settings.section', {}, { only: active })}
</div>
</div>
</div>
</div>
)
}
/**
* Render the settings trigger and panel.
* @param props - composed slot props (contract/slots.ts).
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, translate, subscribeSections, sectionsVersion, sections, renderSlot } = props
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
// The ledger tick is the shell's only subscription: sections re-register
// with freshly localized labels on locale change, so the version bump also
// re-renders the shell's own translate()-read chrome copy.
// State = ledger version: same-version notifications dedupe to no render.
const [, setSectionsRev] = useState(() => sectionsVersion())
useEffect(
() => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
[subscribeSections, sectionsVersion],
)
const rows = sections()
return (
<>
<button
type="button"
className={clsx(css.trigger, !wide && css.rail)}
aria-label={translate('settings:trigger')}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(true) }}
>
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={css.triggerLabel}>{translate('settings:trigger')}</span>}
</button>
{open && <SettingsPanel translate={translate} rows={rows} renderSlot={renderSlot} onClose={close} />}
</>
)
}
@@ -0,0 +1,62 @@
/**
* 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.
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* One settings page per list entry. Registrant options carry the nav
* identity: `id` (section key, drives `only` filtering), `order` (nav
* position), `label` (registrant-localized display text — the registrant
* re-registers with fresh text on locale change, so the shell never
* subscribes locale/theme state; the ledger bump doubles as the shell's
* re-render trigger). Sections render inside the panel content column.
*/
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
}
}
/**
* Owner share of a settings section entry. The shell owns modal visibility
* and navigation; sections receive nothing but the render site (their data
* arrives through their own inject faces and stores).
*/
export interface SettingsSectionOwnerProps {
/** Marker field: section owner props are intentionally empty for now. */
children?: never
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): locale-resolved nav labels come through `translate`.
*/
export type SettingsRootInjected = {
/**
* Resolve a "<ns>:<key>" locale reference to the active-locale text —
* shell chrome copy only (trigger/title/close); nav labels arrive already
* localized. Read at render time; the locale-change re-render rides the
* section ledger bump, not a shell-owned subscription.
*/
translate: (ref: string) => string
/** Read the settings.section ledger version (nav invalidation). */
sectionsVersion: () => number
/** Subscribe to settings.section ledger changes. */
subscribeSections: (listener: () => void) => () => void
/** Project the settings.section ledger into nav rows (id/order/label). */
sections: () => readonly { id: string; order: number; label: string }[]
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared section render share and the injected
* face. No store is registered — modal open state and active section id are
* component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected
@@ -0,0 +1,66 @@
/**
* 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.
*/
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 { SettingsRoot } from './SettingsRoot.tsx'
export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-sidebar's apply, whose activation order relative to this one is NOT
* constrained (dshClient.inject edges are informational); registration goes
* through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
/**
* Register the settings shell into `sidebar.settings` once the 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' }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings: shell copy dictionaries')
const injected = (): SettingsRootInjected => ({
translate: (ref) => {
const colon = ref.indexOf(':')
if (colon === -1) return ref
return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1))
},
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener),
sections: () => ctx.slots.entries('settings.section')
.map(e => ({ id: e.options.id ?? '', order: e.options.order ?? 0, label: e.options.label ?? '' }))
.sort((a, b) => a.order - b.order),
})
ctx.effect(() => {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
if (ctx.slots.spec('sidebar.settings') === undefined || dispose !== undefined) return
dispose = ctx.slots.register({
name: 'sidebar.settings',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
inject: injected,
}, SettingsRoot)
}
const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() })
tryRegister()
return () => {
unsubscribe()
dispose?.()
}
}, 'ui-settings: shell registration')
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+4
View File
@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the settings shell plugin. */
export function apply(): void {}
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings`.
* @module @deepseek-ai/dsh-client-ui-settings/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'
/** Cordis companion plugin name. */
export const name = 'client-ui-settings-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a presentation shell projecting the settings.section
* ledger into navigation — it emits no cordis events and owns no cross-plugin
* mutable relation; slot declaration/registration conflicts already fail loud
* in the slot core at load time.
*/
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 */
+33
View File
@@ -0,0 +1,33 @@
{
"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-sidebar"
},
{
"path": "../locale"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js', 'lib/types/invariant.js'])
+3 -1
View File
@@ -4,7 +4,9 @@ Sidebar plugin: real Host Workspaces in stable Host order, each containing its `
New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar.
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).
@@ -49,7 +49,7 @@
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
.railIn .footArea {
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
}
@@ -372,45 +372,11 @@
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
margins fold into the row so the hover pill spans the full 49px. */
.foot {
/* Foot seat: pure layout — the flex slot pinning the sidebar.settings slot
content to the column bottom. Row visuals belong to the slot occupant
(ui-settings). */
.footArea {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 49px;
margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */
padding: 0 2px 0 6px;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
}
.foot:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Rail settings: the same 36x36 circle box as the other rail controls. */
.collapsed .foot {
width: 36px;
height: 36px;
margin: 18px 0 10px;
justify-content: center;
gap: 0;
padding: 0;
border-radius: 50%;
}
.footLabel {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
}
.collapsed .footLabel {
max-width: 0;
}
@media (prefers-reduced-motion: reduce) {
@@ -419,7 +385,7 @@
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
.railIn .footArea {
transition: none;
animation: none;
}
@@ -12,7 +12,7 @@ import clsx from 'clsx'
import {
BrandWordmark, FishLogo,
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
IconProjectAddOutline16, IconSearchOutline16,
Menu, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
@@ -314,9 +314,10 @@ export function SidebarRoot({
)}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
{/* Foot seat: the flex slot pinning the settings entry to the column
bottom; ui-settings occupies it with the trigger row + panel. */}
<div className={css.footArea}>
{renderSlot('sidebar.settings', { wide })}
</div>
</div>
)
@@ -20,9 +20,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* is claiming); ui-workspace registers the picker.
*/
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
/**
* The settings seat at the sidebar foot. Declared by this package's
* 'sidebar' entry; ui-settings registers its trigger row + modal panel.
* The sidebar passes only its column state — it holds no settings state.
*/
'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps }
}
}
/**
* Owner share of the sidebar settings seat: the column display state the
* occupant's trigger row must render against (wide row vs rail icon).
*/
export interface SidebarSettingsOwnerProps {
/** Whether the sidebar renders wide content (false = 56px rail). */
wide: boolean
}
/**
* Owner share of the sidebar workspace hole: popover geometry plus the
* sidebar's pick semantics. The picked Host Workspace is already real; the
@@ -62,8 +77,9 @@ export type SidebarRootInjected = {
/**
* Full component props: layout owner state/actions plus global useSessions
* and useWorkspaces, the declared Workspace picker render share, and this
* package's injected callback. No store is registered.
* and useWorkspaces, the declared child-slot render shares (Workspace picker
* and settings seat), and this package's injected callback. No store is
* registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace' | 'sidebar.settings'> & SidebarRootInjected
@@ -3,7 +3,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSettingsOwnerProps, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
@@ -20,9 +20,12 @@ export function apply(ctx: ClientContext): void {
ctx.effect(
() => ctx.slots.register({
name: 'sidebar',
// SidebarRoot owns this picker site; ui-workspace registers the shared
// picker that selects a Host Workspace for a frontend Session Intent.
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
// SidebarRoot owns these sites; ui-workspace registers the shared
// picker, ui-settings registers the settings trigger + panel.
children: {
'sidebar.workspace': { kind: 'single', scope: 'root' },
'sidebar.settings': { kind: 'single', scope: 'root' },
},
inject: injectProps,
}, SidebarRoot),
'ui-sidebar: slot registration',
@@ -32,16 +32,16 @@ const workspaces: WorkspaceListState = {
function mount(sessionState: SessionListState = sessions) {
const startSession = vi.fn()
const open = vi.fn()
let pickerOwner: unknown
const owners: Record<string, unknown> = {}
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
startSession={startSession} open={open} toggleSidebar={vi.fn()}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>,
)
return { view, startSession, open, pickerOwner: () => pickerOwner }
return { view, startSession, open, pickerOwner: () => owners['sidebar.workspace'], settingsOwner: () => owners['sidebar.settings'] }
}
function mountSidebar({
@@ -58,14 +58,14 @@ function mountSidebar({
const startSession = vi.fn()
const open = vi.fn()
const toggleSidebar = vi.fn()
let pickerOwner: unknown
const owners: Record<string, unknown> = {}
let current = { sessionState, workspaceState, collapsed, width }
const root = () => (
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>
)
const view = render(root())
@@ -73,7 +73,8 @@ function mountSidebar({
startSession,
open,
toggleSidebar,
pickerOwner: () => pickerOwner,
pickerOwner: () => owners['sidebar.workspace'],
settingsOwner: () => owners['sidebar.settings'],
rerender(next: Partial<typeof current>) {
current = { ...current, ...next }
view.rerender(root())
@@ -246,6 +247,7 @@ describe('SidebarRoot', () => {
it('keeps wide content during live collapse, then settles to the rail', () => {
vi.useFakeTimers()
const b = mountSidebar({ width: 320 })
expect((b.settingsOwner() as { wide: boolean }).wide).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
b.rerender({ collapsed: true, width: 56 })
@@ -253,6 +255,8 @@ describe('SidebarRoot', () => {
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
// The settings seat share tracks the settled column state.
expect((b.settingsOwner() as { wide: boolean }).wide).toBe(false)
})
})
+2 -3
View File
@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-ui-theme
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers); apply(id) toggles the `body[data-ds-dark-theme]` attribute, so theme switches are pure CSS cascade. Contract: api-contracts v3 §8.
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8.
## Model Experience
None, as the theme service toggles browser CSS; nothing here reaches a model request.
None, as the theme service manages a browser preference; nothing here reaches a model request.
#### KV Cache effect
@@ -12,6 +12,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No theme-switch control ships in P-I** — the service surface (register/apply/current) is complete but no UI owner mounts a toggle; switching happens programmatically.
- **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22).
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-theme",
"description": "Theme plugin: ThemeService (apply = toggle body[data-ds-dark-theme]), --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",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -44,5 +44,9 @@
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
}
}
+166 -41
View File
@@ -1,71 +1,196 @@
/**
* Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
* update CSS variables and `body[data-ds-dark-theme]` without React renders.
* 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.
*/
import type { Context } from 'cordis'
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
export type ThemeTokens = Record<string, string>
/** Theme preference: a concrete theme id or follow-the-OS. */
export type ThemePreference = 'light' | 'dark' | 'system'
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
export interface ThemeDefinition {
/** Theme id (the setTheme argument for concrete themes). */
id: string
/**
* Which base palette this theme builds on. The presenter switches
* `body[data-ds-dark-theme]` from this field — never from the id.
*/
colorScheme: 'light' | 'dark'
/** Alias-layer overrides applied as inline CSS variables over the base palette. */
tokens: ThemeTokens
}
/** Immutable theme state published on every change. */
export interface ThemeSnapshot {
/** The persisted preference (may be `system`). */
preference: ThemePreference
/** The resolved active theme (`system` resolved via prefers-color-scheme). */
active: ThemeDefinition
/** Registered themes in registration order. */
themes: readonly ThemeDefinition[]
/** Monotonic change counter (registry or active changes). */
revision: number
}
declare module 'cordis' {
interface Context {
theme: ThemeService
}
interface Events {
/**
* Theme state changed (preference switched, registry updated, or the OS
* color scheme changed while the preference is `system`).
* @param snapshot - Current immutable theme snapshot.
* @mode emit
*/
'theme/change'(snapshot: ThemeSnapshot): void
}
}
/** localStorage key holding the persisted theme preference. */
export const STORAGE_KEY = 'dsh.theme'
/** Default preference when nothing (or garbage) is persisted. */
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }),
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
])
/**
* Theme registry and switcher. `light`/`dark` are built in (the base
* stylesheets carry both palettes; the dark palette activates via the
* body[data-ds-dark-theme] attribute). Third-party themes register alias-layer
* overrides applied as inline CSS variables on body, cascading over whichever
* base palette the attribute selects.
* Theme registry and preference owner. `light`/`dark` are built in (the base
* stylesheets carry both palettes); third-party themes register alias-layer
* overrides. Reads go through {@link getTheme}; writes only through
* {@link setTheme}; continuous sync only through the `theme/change` event.
* The service holds the `prefers-color-scheme` media query (environment
* sensing, not presentation) and re-emits when the OS scheme flips while the
* preference is `system`.
*/
export class ThemeService {
private themes = new Map<string, ThemeTokens>([['light', {}], ['dark', {}]])
private appliedTokens: ThemeTokens = {}
private active = 'light'
private readonly ctx: Context
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
private preference: ThemePreference
private revision = 0
private snapshot: ThemeSnapshot
private readonly media: MediaQueryList | undefined
/**
* Register a theme. Duplicate id throws (single occupant per id; the
* built-in pair counts).
* @param id - theme id.
* @param tokens - alias-layer overrides (variable name to value).
* @returns disposer. Disposing the active theme reverts to `light` so the
* UI never keeps tokens of an unregistered theme.
* @param ctx - owning context (change events are emitted on it; the
* media-query listener is released through ctx.effect on dispose).
*/
register(id: string, tokens: ThemeTokens): () => void {
if (this.themes.has(id)) throw new Error(`theme "${id}" is already registered`)
this.themes.set(id, tokens)
return () => {
if (!this.themes.delete(id)) return
if (this.active === id) this.apply('light')
constructor(ctx: Context) {
this.ctx = ctx
this.preference = restorePreference()
this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
this.snapshot = this.buildSnapshot()
if (this.media !== undefined) {
const media = this.media
const onChange = (): void => {
if (this.preference !== 'system') return
this.publish()
}
ctx.effect(() => {
media.addEventListener('change', onChange)
return () => { media.removeEventListener('change', onChange) }
}, 'ui-theme: prefers-color-scheme listener')
}
}
/**
* Activate a theme: toggle body[data-ds-dark-theme] (set only for `dark`)
* and swap the previous theme's inline token overrides for this one's.
* Unregistered id throws.
* @param id - registered theme id.
* Read the current immutable theme snapshot.
* @returns the current snapshot (stable reference until the next change).
*/
apply(id: string): void {
const tokens = this.themes.get(id)
if (!tokens) throw new Error(`theme "${id}" is not registered`)
const body = document.body
for (const name of Object.keys(this.appliedTokens)) body.style.removeProperty(name)
if (id === 'dark') body.setAttribute('data-ds-dark-theme', '')
else body.removeAttribute('data-ds-dark-theme')
for (const [name, value] of Object.entries(tokens)) body.style.setProperty(name, value)
this.appliedTokens = tokens
this.active = id
getTheme(): ThemeSnapshot {
return this.snapshot
}
/**
* Report the active theme id (initially `light`).
* @returns the active theme id.
* Switch the theme preference — the only preference write entry. Persists
* the preference and emits `theme/change`.
* @param id - a registered theme id or `system`; unknown ids throw.
*/
current(): string {
return this.active
setTheme(id: string): void {
if (id !== 'system' && !this.themes.some(t => t.id === id)) {
throw new Error(`theme "${id}" is not registered`)
}
if (this.preference === id) return
this.preference = id as ThemePreference
persistPreference(this.preference)
this.publish()
}
/**
* Register a theme. Duplicate id throws (single occupant per id; the
* built-in pair counts; `system` is a preference, not a registrable id).
* @param definition - theme id, colorScheme, and alias-token overrides.
* @returns disposer. Disposing the theme backing the active preference
* resets the preference to the default so the UI never keeps tokens of an
* unregistered theme.
*/
register(definition: ThemeDefinition): () => void {
if (definition.id === 'system') throw new Error('"system" is a preference, not a registrable theme id')
if (this.themes.some(t => t.id === definition.id)) {
throw new Error(`theme "${definition.id}" is already registered`)
}
this.themes = [...this.themes, definition]
this.publish()
return () => {
if (!this.themes.some(t => t.id === definition.id)) return
this.themes = this.themes.filter(t => t.id !== definition.id)
if (this.preference === definition.id) {
this.preference = DEFAULT_PREFERENCE
persistPreference(this.preference)
}
this.publish()
}
}
private buildSnapshot(): ThemeSnapshot {
const resolvedId = this.preference === 'system'
? (this.media?.matches === true ? 'dark' : 'light')
: this.preference
// Both built-ins always exist; a registered preference id resolves or has
// been reset by its disposer, so the lookup cannot miss.
const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]!
return Object.freeze({
preference: this.preference,
active,
themes: Object.freeze([...this.themes]),
revision: this.revision,
})
}
private publish(): void {
this.revision += 1
this.snapshot = this.buildSnapshot()
this.ctx.emit('theme/change', this.snapshot)
}
}
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
function restorePreference(): ThemePreference {
try {
const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
} catch {
// Storage access can throw (privacy mode); the default below covers it.
}
return DEFAULT_PREFERENCE
}
/** Persist the preference; storage failures are non-fatal (preference resets next boot). */
function persistPreference(preference: ThemePreference): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, preference)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
@@ -77,5 +202,5 @@ export const inject: string[] = []
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
ctx.provide('theme', new ThemeService())
ctx.provide('theme', new ThemeService(ctx))
}
+4 -3
View File
@@ -15,9 +15,10 @@ export const name = 'client-ui-theme-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a token-sheet registry whose apply() flips one body
* attribute — it emits no cordis events; registration/apply/current behavior
* is asserted directly by this package's behavior specs.
* No runtime invariant: the theme registry publishes immutable snapshots on
* its own `theme/change` event synchronously with the setter/registry
* mutation in the same service — snapshot/event agreement is asserted
* directly by this package's behavior specs.
*/
const install: InvariantInstaller = () => {}
+69 -40
View File
@@ -1,61 +1,90 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { Context } from 'cordis'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
const ctx = new Context()
const events: ThemeSnapshot[] = []
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
return { ctx, theme: new ThemeService(ctx), events }
}
describe('ThemeService', () => {
beforeEach(() => {
document.body.removeAttribute('data-ds-dark-theme')
document.body.removeAttribute('style')
localStorage.clear()
})
it('starts on light; apply toggles the dark body attribute both ways', () => {
const theme = new ThemeService()
expect(theme.current()).toBe('light')
theme.apply('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
expect(theme.current()).toBe('dark')
theme.apply('light')
it('defaults to the system preference resolved against prefers-color-scheme', () => {
const { theme } = make()
const snapshot = theme.getTheme()
expect(snapshot.preference).toBe('system')
// jsdom matchMedia is absent; system resolves to light.
expect(snapshot.active.id).toBe('light')
expect(snapshot.active.colorScheme).toBe('light')
expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
})
it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
const { theme, events } = make()
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
expect(theme.getTheme().active.colorScheme).toBe('dark')
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
expect(events).toHaveLength(1)
expect(events[0]).toBe(theme.getTheme())
// The service never touches presentation state.
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
expect(theme.current()).toBe('light')
// Same-value set is a no-op (no extra event).
theme.setTheme('dark')
expect(events).toHaveLength(1)
})
it('throws on unregistered apply and duplicate register (built-ins included)', () => {
const theme = new ThemeService()
expect(() => { theme.apply('sepia') }).toThrow('not registered')
expect(() => theme.register('light', {})).toThrow('already registered')
theme.register('sepia', {})
expect(() => theme.register('sepia', {})).toThrow('already registered')
it('restores a persisted preference and falls back on garbage', () => {
localStorage.setItem(STORAGE_KEY, 'dark')
expect(make().theme.getTheme().preference).toBe('dark')
localStorage.setItem(STORAGE_KEY, 'sepia')
expect(make().theme.getTheme().preference).toBe('system')
})
it('applies third-party token overrides as body inline vars and swaps them on switch', () => {
const theme = new ThemeService()
theme.register('sepia', { '--dsw-alias-bg-base': 'rgb(1, 2, 3)' })
theme.apply('sepia')
expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('rgb(1, 2, 3)')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
theme.apply('dark')
expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
const { theme } = make()
expect(() => { theme.setTheme('sepia') }).toThrow('not registered')
expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered')
expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference')
})
it('disposing the active theme reverts to light; disposer is idempotent', () => {
const theme = new ThemeService()
const dispose = theme.register('sepia', { '--dsw-alias-bg-base': 'red' })
theme.apply('sepia')
it('registered themes join the snapshot; disposing the active one resets to default', () => {
const { theme, events } = make()
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
theme.setTheme('sepia')
expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red')
dispose()
expect(theme.current()).toBe('light')
expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('')
expect(() => { theme.apply('sepia') }).toThrow('not registered')
expect(theme.getTheme().preference).toBe('system')
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
// register + set + dispose = three publishes; disposer is idempotent.
expect(events.length).toBe(3)
dispose()
expect(theme.current()).toBe('light')
expect(events.length).toBe(3)
})
it('disposing an inactive theme leaves the active selection untouched', () => {
const theme = new ThemeService()
const dispose = theme.register('sepia', {})
theme.apply('dark')
it('disposing an inactive theme keeps the active preference', () => {
const { theme } = make()
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} })
theme.setTheme('dark')
dispose()
expect(theme.current()).toBe('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
expect(theme.getTheme().preference).toBe('dark')
})
it('revision increases monotonically across every publish', () => {
const { theme, events } = make()
theme.setTheme('dark')
theme.setTheme('light')
const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} })
dispose()
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
})
})
+1 -1
View File
@@ -21,7 +21,7 @@
* switches to the real UI in one pass.
*
* Entry creation waits for the whole immediately tier: materialization runs
* synchronous cross-package require edges (e.g. i18n → runtime/client) that
* synchronous cross-package require edges (e.g. locale → runtime/client) that
* fiber inject waiting cannot protect — a bundle's factory must be
* registered before any dependent entry materializes. Per-row prefetch
* failures still resolve silently (the create-side import refetches and
+113 -7
View File
@@ -125,9 +125,9 @@ importers:
'@deepseek-ai/dsh-client-hmr':
specifier: workspace:^
version: link:../../packages/client/hmr
'@deepseek-ai/dsh-client-i18n':
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../../packages/client/i18n
version: link:../../packages/client/locale
'@deepseek-ai/dsh-client-modules':
specifier: workspace:^
version: link:../../packages/client/modules
@@ -143,6 +143,15 @@ importers:
'@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
@@ -740,11 +749,7 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
packages/client/i18n:
dependencies:
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
packages/client/locale:
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
@@ -844,6 +849,9 @@ importers:
'@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
@@ -937,6 +945,104 @@ importers:
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)
packages/client/ui-settings:
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-sidebar':
specifier: workspace:^
version: link:../ui-sidebar
'@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-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:
@@ -59,7 +59,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'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/i18n': { 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.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
+4 -1
View File
@@ -115,7 +115,10 @@
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
"@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-i18n": ["./packages/client/i18n/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-locale": ["./packages/client/locale/src"],
"@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],
"@deepseek-ai/dsh-*": [
"./packages/core/*/src",
+4 -1
View File
@@ -38,7 +38,10 @@
{ "path": "./packages/client/ui-question" },
{ "path": "./packages/client/ui-trajectory" },
{ "path": "./packages/client/ui-theme" },
{ "path": "./packages/client/i18n" },
{ "path": "./packages/client/ui-settings" },
{ "path": "./packages/client/ui-settings-general" },
{ "path": "./packages/client/ui-settings-models" },
{ "path": "./packages/client/locale" },
{ "path": "./packages/client/web" },
{ "path": "./apps/web" }
]