From 6e721b9fdd5524b31fc399bf7b72f0cba0f32cba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:16:05 +0800 Subject: [PATCH] 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. --- ...6-07-25-client-settings-locale-theme.zh.md | 99 +++++++++ apps/cli/cordis.yml | 13 +- apps/cli/package.json | 5 +- apps/cli/tsconfig.json | 11 +- apps/web/tests/session-title.snapshot.ts | 5 +- apps/web/tests/smoke-real.e2e.ts | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 5 +- packages/client/i18n/README.md | 16 -- packages/client/i18n/src/client/index.ts | 108 --------- packages/client/i18n/tests/i18n.spec.ts | 53 ----- packages/client/i18n/tests/invariant.spec.ts | 30 --- packages/client/i18n/tsdown.config.ts | 3 - packages/client/locale/README.md | 16 ++ packages/client/{i18n => locale}/package.json | 13 +- packages/client/locale/src/client/index.ts | 195 +++++++++++++++++ packages/client/{i18n => locale}/src/index.ts | 2 +- .../client/{i18n => locale}/src/invariant.ts | 8 +- .../client/{i18n => locale}/src/locales/en.ts | 0 .../client/{i18n => locale}/src/locales/zh.ts | 0 .../client/locale/tests/invariant.spec.ts | 30 +++ packages/client/locale/tests/locale.spec.ts | 90 ++++++++ .../client/{i18n => locale}/tsconfig.json | 3 - packages/client/locale/tsdown.config.ts | 3 + packages/client/tsdown.client.ts | 2 +- packages/client/ui-conversation/package.json | 2 +- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 4 +- packages/client/ui-conversation/tsconfig.json | 2 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/package.json | 5 +- packages/client/ui-layout/src/client/index.ts | 19 +- .../ui-layout/src/client/theme-presenter.ts | 43 ++++ packages/client/ui-layout/tests/apply.spec.ts | 21 +- .../ui-layout/tests/theme-presenter.spec.ts | 56 +++++ packages/client/ui-layout/tsconfig.json | 3 + .../client/ui-primitives/src/Menu.module.css | 4 +- .../client/ui-primitives/src/icons/index.tsx | 87 ++++++++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-settings-general/README.md | 15 ++ .../client/ui-settings-general/package.json | 70 ++++++ .../src/client/GeneralSection.module.css | 131 +++++++++++ .../src/client/GeneralSection.tsx | 118 ++++++++++ .../src/client/contract.ts | 66 ++++++ .../ui-settings-general/src/client/index.ts | 111 ++++++++++ .../ui-settings-general/src/client/locales.ts | 42 ++++ .../ui-settings-general/src/client/store.ts | 43 ++++ .../ui-settings-general/src/css-modules.d.ts | 6 + .../client/ui-settings-general/src/index.ts | 4 + .../ui-settings-general/src/invariant.ts | 32 +++ .../client/ui-settings-general/tsconfig.json | 36 +++ .../ui-settings-general/tsdown.config.ts | 3 + packages/client/ui-settings-models/README.md | 15 ++ .../client/ui-settings-models/package.json | 63 ++++++ .../src/client/ModelsSection.tsx | 13 ++ .../ui-settings-models/src/client/index.ts | 63 ++++++ .../ui-settings-models/src/css-modules.d.ts | 6 + .../client/ui-settings-models/src/index.ts | 4 + .../ui-settings-models/src/invariant.ts | 31 +++ .../client/ui-settings-models/tsconfig.json | 30 +++ .../ui-settings-models/tsdown.config.ts | 3 + packages/client/ui-settings/README.md | 15 ++ packages/client/ui-settings/package.json | 68 ++++++ .../src/client/SettingsRoot.module.css | 192 ++++++++++++++++ .../ui-settings/src/client/SettingsRoot.tsx | 125 +++++++++++ .../ui-settings/src/client/contract/slots.ts | 62 ++++++ .../client/ui-settings/src/client/index.ts | 66 ++++++ .../client/ui-settings/src/css-modules.d.ts | 6 + packages/client/ui-settings/src/index.ts | 4 + packages/client/ui-settings/src/invariant.ts | 32 +++ packages/client/ui-settings/tsconfig.json | 33 +++ packages/client/ui-settings/tsdown.config.ts | 3 + packages/client/ui-sidebar/README.md | 4 +- .../src/client/SidebarRoot.module.css | 46 +--- .../ui-sidebar/src/client/SidebarRoot.tsx | 9 +- .../ui-sidebar/src/client/contract/slots.ts | 22 +- .../client/ui-sidebar/src/client/index.ts | 11 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 16 +- packages/client/ui-theme/README.md | 5 +- packages/client/ui-theme/package.json | 8 +- packages/client/ui-theme/src/client/index.ts | 207 ++++++++++++++---- packages/client/ui-theme/src/invariant.ts | 7 +- packages/client/ui-theme/tests/theme.spec.ts | 109 +++++---- packages/client/web/src/boot.tsx | 2 +- pnpm-lock.yaml | 120 +++++++++- .../verify-package-readme-model-experience.ts | 2 +- tsconfig.base.json | 5 +- tsconfig.client.json | 5 +- 88 files changed, 2653 insertions(+), 404 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md delete mode 100644 packages/client/i18n/README.md delete mode 100644 packages/client/i18n/src/client/index.ts delete mode 100644 packages/client/i18n/tests/i18n.spec.ts delete mode 100644 packages/client/i18n/tests/invariant.spec.ts delete mode 100644 packages/client/i18n/tsdown.config.ts create mode 100644 packages/client/locale/README.md rename packages/client/{i18n => locale}/package.json (78%) create mode 100644 packages/client/locale/src/client/index.ts rename packages/client/{i18n => locale}/src/index.ts (61%) rename packages/client/{i18n => locale}/src/invariant.ts (86%) rename packages/client/{i18n => locale}/src/locales/en.ts (100%) rename packages/client/{i18n => locale}/src/locales/zh.ts (100%) create mode 100644 packages/client/locale/tests/invariant.spec.ts create mode 100644 packages/client/locale/tests/locale.spec.ts rename packages/client/{i18n => locale}/tsconfig.json (87%) create mode 100644 packages/client/locale/tsdown.config.ts create mode 100644 packages/client/ui-layout/src/client/theme-presenter.ts create mode 100644 packages/client/ui-layout/tests/theme-presenter.spec.ts create mode 100644 packages/client/ui-settings-general/README.md create mode 100644 packages/client/ui-settings-general/package.json create mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.module.css create mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.tsx create mode 100644 packages/client/ui-settings-general/src/client/contract.ts create mode 100644 packages/client/ui-settings-general/src/client/index.ts create mode 100644 packages/client/ui-settings-general/src/client/locales.ts create mode 100644 packages/client/ui-settings-general/src/client/store.ts create mode 100644 packages/client/ui-settings-general/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-general/src/index.ts create mode 100644 packages/client/ui-settings-general/src/invariant.ts create mode 100644 packages/client/ui-settings-general/tsconfig.json create mode 100644 packages/client/ui-settings-general/tsdown.config.ts create mode 100644 packages/client/ui-settings-models/README.md create mode 100644 packages/client/ui-settings-models/package.json create mode 100644 packages/client/ui-settings-models/src/client/ModelsSection.tsx create mode 100644 packages/client/ui-settings-models/src/client/index.ts create mode 100644 packages/client/ui-settings-models/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-models/src/index.ts create mode 100644 packages/client/ui-settings-models/src/invariant.ts create mode 100644 packages/client/ui-settings-models/tsconfig.json create mode 100644 packages/client/ui-settings-models/tsdown.config.ts create mode 100644 packages/client/ui-settings/README.md create mode 100644 packages/client/ui-settings/package.json create mode 100644 packages/client/ui-settings/src/client/SettingsRoot.module.css create mode 100644 packages/client/ui-settings/src/client/SettingsRoot.tsx create mode 100644 packages/client/ui-settings/src/client/contract/slots.ts create mode 100644 packages/client/ui-settings/src/client/index.ts create mode 100644 packages/client/ui-settings/src/css-modules.d.ts create mode 100644 packages/client/ui-settings/src/index.ts create mode 100644 packages/client/ui-settings/src/invariant.ts create mode 100644 packages/client/ui-settings/tsconfig.json create mode 100644 packages/client/ui-settings/tsdown.config.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md new file mode 100644 index 0000000000..d3c61794b9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -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 snapshot;service 自己持久化偏好(只存 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 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 + +Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 + +### 首期 section 范围 + +| section | 插件 | 首期内容 | +|---|---|---| +| General | `ui-settings-general` | Language(Selector 下拉)与 Appearance(Light/Dark/System 三 cube)真实可切;Permission、Tool Call 仅视觉骨架,无写操作 | +| Models | `ui-settings-models` | 仅导航项,内容区为空 | +| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现) | + +首期只翻译 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 snapshot,Theme service 不访问 DOM;presenter 不出现 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 后残留。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..8412a313b4 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -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' diff --git a/apps/cli/package.json b/apps/cli/package.json index e1c07f90b5..74a6e417e1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 4db4861b93..7614ae2e31 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -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" diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..b8ee3408a3 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -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'] }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a3d511df16..70d1d81523 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise { // 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') diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 78ac843a64..e232d8e123 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -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', diff --git a/packages/client/i18n/README.md b/packages/client/i18n/README.md deleted file mode 100644 index db6be0fe2a..0000000000 --- a/packages/client/i18n/README.md +++ /dev/null @@ -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. diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts deleted file mode 100644 index 9dea9c4bd4..0000000000 --- a/packages/client/i18n/src/client/index.ts +++ /dev/null @@ -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 - -/** Locale dictionary: flat key to template string ({name} placeholders). */ -export type LocaleDict = Record - -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>() - private bound = new Map() - private localeStore = createSnapshotStore(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 { - return this.localeStore - } - - private translate(ns: string, key: string, params?: Record): 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) -} diff --git a/packages/client/i18n/tests/i18n.spec.ts b/packages/client/i18n/tests/i18n.spec.ts deleted file mode 100644 index 12fef2c716..0000000000 --- a/packages/client/i18n/tests/i18n.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/client/i18n/tests/invariant.spec.ts b/packages/client/i18n/tests/invariant.spec.ts deleted file mode 100644 index b992e50d52..0000000000 --- a/packages/client/i18n/tests/invariant.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/packages/client/i18n/tsdown.config.ts b/packages/client/i18n/tsdown.config.ts deleted file mode 100644 index 1c0802be49..0000000000 --- a/packages/client/i18n/tsdown.config.ts +++ /dev/null @@ -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']) diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md new file mode 100644 index 0000000000..078967c2cc --- /dev/null +++ b/packages/client/locale/README.md @@ -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. diff --git a/packages/client/i18n/package.json b/packages/client/locale/package.json similarity index 78% rename from packages/client/i18n/package.json rename to packages/client/locale/package.json index 3e617174bf..340efe042e 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/locale/package.json @@ -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" + } } diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts new file mode 100644 index 0000000000..e01d69ea25 --- /dev/null +++ b/packages/client/locale/src/client/index.ts @@ -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 + +/** Locale dictionary: flat key to template string ({name} placeholders). */ +export type LocaleDict = Record + +/** 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>() + private bound = new Map() + 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 { + 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) +} diff --git a/packages/client/i18n/src/index.ts b/packages/client/locale/src/index.ts similarity index 61% rename from packages/client/i18n/src/index.ts rename to packages/client/locale/src/index.ts index e759f1edc1..c220373932 100644 --- a/packages/client/i18n/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -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 {} diff --git a/packages/client/i18n/src/invariant.ts b/packages/client/locale/src/invariant.ts similarity index 86% rename from packages/client/i18n/src/invariant.ts rename to packages/client/locale/src/invariant.ts index b2c196e93b..96c94018f0 100644 --- a/packages/client/i18n/src/invariant.ts +++ b/packages/client/locale/src/invariant.ts @@ -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'] diff --git a/packages/client/i18n/src/locales/en.ts b/packages/client/locale/src/locales/en.ts similarity index 100% rename from packages/client/i18n/src/locales/en.ts rename to packages/client/locale/src/locales/en.ts diff --git a/packages/client/i18n/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts similarity index 100% rename from packages/client/i18n/src/locales/zh.ts rename to packages/client/locale/src/locales/zh.ts diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts new file mode 100644 index 0000000000..e9f4fdee36 --- /dev/null +++ b/packages/client/locale/tests/invariant.spec.ts @@ -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') + }) +}) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts new file mode 100644 index 0000000000..750c2452e8 --- /dev/null +++ b/packages/client/locale/tests/locale.spec.ts @@ -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' }, + ]) + }) +}) diff --git a/packages/client/i18n/tsconfig.json b/packages/client/locale/tsconfig.json similarity index 87% rename from packages/client/i18n/tsconfig.json rename to packages/client/locale/tsconfig.json index 63eb9779ca..51f9171643 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../runtime" - }, { "path": "../../support/invariants" } diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts new file mode 100644 index 0000000000..2141970e5d --- /dev/null +++ b/packages/client/locale/tsdown.config.ts @@ -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']) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 56bdd29320..9b93feae8b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -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 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index d7306a6810..95fde30b5a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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" ], diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 21fe70499e..b040e847e0 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -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 diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index dcfe2d7de7..31b251843a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -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 diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index bfae743cff..959ee871d8 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -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: { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 7755337093..902c1c7f9f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -27,7 +27,7 @@ "path": "../ui-layout" }, { - "path": "../i18n" + "path": "../locale" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 4a9f5e9bd5..a8c3bc0bd6 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -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. diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index fd3e691375..17459ee5d2 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -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", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 6ecb6f6b1d..39fee1fb52 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -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') } diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts new file mode 100644 index 0000000000..958f2fd93e --- /dev/null +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -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 = [] + } +} diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index bed0987f62..29ad4b3058 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -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 }) diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts new file mode 100644 index 0000000000..ced83a379e --- /dev/null +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -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 = {}): 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') + }) +}) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index c82482294b..4401e8e4db 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-theme" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..3cabe258bc 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -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). */ diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 80bb3848dd..4c2083bae1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -583,3 +583,90 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => ( ) + +/** ic_ds_light_outline_16 */ +export const IconLightOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + +) + +/** ic_ds_dark_outline_16 */ +export const IconDarkOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) + +/** ic_ds_followsystem_outline_16 */ +export const IconFollowsystemOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** ic_ds_data_outline_16 */ +export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** ic_ds_List_Pen_outline_16 */ +export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + +) diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 74bf5a9678..281124b8d5 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -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 => { diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md new file mode 100644 index 0000000000..435dd8c1b4 --- /dev/null +++ b/packages/client/ui-settings-general/README.md @@ -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. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json new file mode 100644 index 0000000000..9b3d83e472 --- /dev/null +++ b/packages/client/ui-settings-general/package.json @@ -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" + ] +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css new file mode 100644 index 0000000000..dffafc419d --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -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); +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx new file mode 100644 index 0000000000..d2947c0435 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -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 ( +
+ {/* Permission (skeleton): disabled selector pill. */} +
+
+
{t('permission.title')}
+
{t('permission.desc')}
+
+ +
+ + {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} +
+
{t('toolcall.title')}
+
+
+
{t('toolcall.schema.title')}
+
{t('toolcall.schema.desc')}
+
+
+
{t('toolcall.code.title')}
+
{t('toolcall.code.desc')}
+
+
+
+ + {/* Language: selector pill opens the locale menu. */} +
+
+
{t('language.title')}
+
+ { setLanguageOpen(false) }} + items={localeOptions.map(l => ({ id: l.id, label: l.label }))} + selectedId={localeActive} + onSelect={(id) => { + setLocale(id) + setLanguageOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> +
+ + {/* Appearance: three preference cubes; selection follows the persisted + * preference, never the resolved active theme. */} +
+
{t('appearance.title')}
+
+ {THEME_CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
+
+
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/contract.ts b/packages/client/ui-settings-general/src/client/contract.ts new file mode 100644 index 0000000000..912d925615 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/contract.ts @@ -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 + +/** + * 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 & GeneralSectionInjected diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts new file mode 100644 index 0000000000..0e59ce2116 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -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 | 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): 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') +} diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts new file mode 100644 index 0000000000..ff10ce5c37 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -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', +} diff --git a/packages/client/ui-settings-general/src/client/store.ts b/packages/client/ui-settings-general/src/client/store.ts new file mode 100644 index 0000000000..121e3f52a9 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/store.ts @@ -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 { + 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 + }, + }, + }) +} diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/ui-settings-general/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-general/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts new file mode 100644 index 0000000000..94b9bdf674 --- /dev/null +++ b/packages/client/ui-settings-general/src/index.ts @@ -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 {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts new file mode 100644 index 0000000000..a40cc3cc00 --- /dev/null +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -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 */ diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json new file mode 100644 index 0000000000..9b2cc5b838 --- /dev/null +++ b/packages/client/ui-settings-general/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts new file mode 100644 index 0000000000..bf67c4f10f --- /dev/null +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -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']) diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md new file mode 100644 index 0000000000..c96e2843e4 --- /dev/null +++ b/packages/client/ui-settings-models/README.md @@ -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. diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json new file mode 100644 index 0000000000..da8fa8c18d --- /dev/null +++ b/packages/client/ui-settings-models/package.json @@ -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" + ] +} diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx new file mode 100644 index 0000000000..ee33b916cb --- /dev/null +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -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 +} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts new file mode 100644 index 0000000000..8ad2ebb750 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -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') +} diff --git a/packages/client/ui-settings-models/src/css-modules.d.ts b/packages/client/ui-settings-models/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-models/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-settings-models/src/index.ts new file mode 100644 index 0000000000..da3060fe59 --- /dev/null +++ b/packages/client/ui-settings-models/src/index.ts @@ -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 {} diff --git a/packages/client/ui-settings-models/src/invariant.ts b/packages/client/ui-settings-models/src/invariant.ts new file mode 100644 index 0000000000..9ffdc668d7 --- /dev/null +++ b/packages/client/ui-settings-models/src/invariant.ts @@ -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 */ diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json new file mode 100644 index 0000000000..dde94c20af --- /dev/null +++ b/packages/client/ui-settings-models/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts new file mode 100644 index 0000000000..7a2688a097 --- /dev/null +++ b/packages/client/ui-settings-models/tsdown.config.ts @@ -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']) diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md new file mode 100644 index 0000000000..64250c7917 --- /dev/null +++ b/packages/client/ui-settings/README.md @@ -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. diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json new file mode 100644 index 0000000000..8f71d90c8b --- /dev/null +++ b/packages/client/ui-settings/package.json @@ -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" + ] +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css new file mode 100644 index 0000000000..bf557c0a04 --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -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; +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx new file mode 100644 index 0000000000..3a27acdf6c --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -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 + return +} + +type PanelProps = { + translate: SettingsRootComponentProps['translate'] + rows: ReturnType + 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(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(null) + useEffect(() => { closeButton.current?.focus() }, []) + + return ( +
+ + ) +} + +/** + * 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 ( + <> + + {open && } + + ) +} diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts new file mode 100644 index 0000000000..10e4e0ecbe --- /dev/null +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -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 ":" 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 diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts new file mode 100644 index 0000000000..d5135f373c --- /dev/null +++ b/packages/client/ui-settings/src/client/index.ts @@ -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') +} diff --git a/packages/client/ui-settings/src/css-modules.d.ts b/packages/client/ui-settings/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings/src/index.ts b/packages/client/ui-settings/src/index.ts new file mode 100644 index 0000000000..52fe4c43e6 --- /dev/null +++ b/packages/client/ui-settings/src/index.ts @@ -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 {} diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts new file mode 100644 index 0000000000..53d7fb066a --- /dev/null +++ b/packages/client/ui-settings/src/invariant.ts @@ -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 */ diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json new file mode 100644 index 0000000000..db90b908bf --- /dev/null +++ b/packages/client/ui-settings/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts new file mode 100644 index 0000000000..ba06fdc7c9 --- /dev/null +++ b/packages/client/ui-settings/tsdown.config.ts @@ -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']) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index d8c2f61ee0..e42b226904 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -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). diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..20431e9d15 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -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; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..490c3e7b8b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -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({ )}
-
- - {wide && Settings} + {/* Foot seat: the flex slot pinning the settings entry to the column + bottom; ui-settings occupies it with the trigger row + panel. */} +
+ {renderSlot('sidebar.settings', { wide })}
) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..bb7c0caf07 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -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 diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..ad847bb5a5 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -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', diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..d4caa4e1be 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -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 = {} const view = render( { 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 = {} let current = { sessionState, workspaceState, collapsed, width } const root = () => ( { 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) { 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) }) }) diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2ce5d9b9e6..be0b83a0d8 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -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). diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4e601914e3..4c6ad92cd4 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -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" + } } diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index d9f5b70c3b..5ba5d40b35 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -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 +/** 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([['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)) } diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 292304cbaf..4ec3296cd6 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -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 = () => {} diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 126957c5eb..9ab0ab8f0f 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -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]) }) }) diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index bd755a5fc1..61dbbd850e 100644 --- a/packages/client/web/src/boot.tsx +++ b/packages/client/web/src/boot.tsx @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..2410252eb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 20d4195737..da5fe34dc4 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,7 +59,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..81e0fc989c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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", diff --git a/tsconfig.client.json b/tsconfig.client.json index 17f34b601f..abe80b8b12 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -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" } ]