diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml new file mode 100644 index 0000000000..ad6c575d1e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-25-client-settings-locale-theme.md: 87077b3fd3f0bd8a3375a71aebf947cbd9961799 +2026-07-25-client-settings-locale-theme.zh.md: a64a4afdf6565a527a25136694aa79305eeabb3c diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md new file mode 100644 index 0000000000..87077b3fd3 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -0,0 +1,130 @@ +# Agent Note: Client Settings, Locale, and Theme layering + +Status: proposed + +English | [中文](2026-07-25-client-settings-locale-theme.zh.md) + +## Problem + +The browser client's existing Settings is written directly inside the Sidebar, and language and theme are applied by component-local state mutating the DOM directly. As a result Settings cannot be extended by independent plugins, preference state has no stable cross-plugin service contract, and the theme registry carries both state and presentation responsibilities. + +## Proposal + +**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell is a pure composition surface: it only declares slots and renders the chrome structure — zero copy, no locale dependency, and neither importing nor enumerating any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). Content that belongs to no single feature (the trigger/title/close chrome copy, the General directory with its skeleton rows, the `settings` dictionary) is owned by `ui-settings-general` — the owner of the ownerless copy, not a feature satellite package. + +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares four slots: `settings.trigger` / `settings.header` / `settings.close` (chrome content seats, single) and `settings.section` (top-level pages, list). Accessible names all resolve from slot content: the trigger's accessible name is its text content, the dialog points at the header content node via aria-labelledby, and close is a visually hidden text seat. Each section is contributed by a feature plugin; the shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by `ui-settings-general` (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order. + +The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. + +`@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default). + +Each feature row's apply layer subscribes to its own change event (locale to `locale/change`, ui-theme to `theme/change`) and projects the snapshot into the slot store declared when that row registered. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. + +The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition. + +The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results. + +### First-phase registration surfaces + +| Registration surface | Owning plugin | First-phase content | +|---|---|---| +| chrome content (trigger/header/close) | `ui-settings-general` | Settings entry-row icon and copy, panel title, close hidden text | +| General section (order 0) | `ui-settings-general` | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration | +| Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch | +| Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) | +| Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package | +| Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) | + +The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — the chrome plus the General skeletons live in `ui-settings-general`'s `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`). + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings(壳,零文案) + ├─ settings.trigger single/root ui-settings-general 注册 + ├─ settings.header single/root ui-settings-general 注册 + ├─ settings.close single/root ui-settings-general 注册 + └─ settings.section list/root + ├─ general (order 0) ui-settings-general 注册 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 +``` + +Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. + +### Future work: promote slot declarations to first-class injectable waits + +`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form. + +### Service contracts + +```ts +export type ThemePreference = 'light' | 'dark' | 'system' + +export interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record +} + +export interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +export interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + +export interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +export interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the only write entry points, and an unknown id fails. + +## Alternatives considered + +**Having the app shell subscribe to preferences centrally and re-render the root slot tree.** A language or theme change only needs to update the actual consumers; a whole-tree refresh amplifies the blast radius and wires business preferences into the shell. + +**The theme service mutating the DOM directly.** The registry service would then depend on the presentation environment, with unclear lifecycle and global-style ownership; Layout already owns the page-root presentation boundary. + +**Resolving system in the Layout presenter.** The presenter would need its own matchMedia subscription and would pick the concrete definition out of the themes list, forcing the presentation layer to understand preference semantics; resolving on the service side gives every consumer the same resolved snapshot. + +**Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. + +**A per-feature `ui-settings-*` satellite package for each section.** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and the satellite packages depending back on the locale/theme services form an intermediate layer that exists purely for the package split. Under feature-owner self-registration that layer does not exist: preference rows ship with their feature packages, and `ui-settings-general` takes in only the ownerless copy (the chrome and the General skeletons), carrying no feature's settings surface. + +**Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. + +## Acceptance criteria + +- The Settings shell depends only on the slot ledger, never on any feature implementation; General's item list likewise depends only on the ledger. +- Adding a settings item = the feature package registering it itself (a section or a general item), with zero shell changes. +- Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events. +- Each feature row's store initializes from the getter and is thereafter updated by its own change event with local re-renders. +- Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter. +- 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately. +- Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes. +- The overlay closes via the close button, a mask click, and ESC. + +## Risks + +The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. 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..a64a4afdf6 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -0,0 +1,130 @@ +# Agent Note: Client Settings、Locale 与 Theme 分层 + +Status: proposed + +[English](2026-07-25-client-settings-locale-theme.md) | 中文 + +## Problem + +浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 + +## Proposal + +**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包。 + +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明四个坑:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 + +Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 + +`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 + +功能行的 apply 层各自订阅自家 change event(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 + +Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 + +Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 + +### 首期注册面 + +| 注册面 | 属主插件 | 首期内容 | +|---|---|---| +| chrome 内容(trigger/header/close)| `ui-settings-general` | 设置入口行图标+文案、面板标题、close 隐藏文本 | +| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | +| Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 | +| Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) | +| Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 | +| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | + +首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings(壳,零文案) + ├─ settings.trigger single/root ui-settings-general 注册 + ├─ settings.header single/root ui-settings-general 注册 + ├─ settings.close single/root ui-settings-general 注册 + └─ settings.section list/root + ├─ general (order 0) ui-settings-general 注册 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 +``` + +section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 + +### Future work:坑位声明升格为可 inject 的一等等待物 + +`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,stale-disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个坑位桥接成 `slot:` 服务(value 为坑位 spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式。 + +### Service contracts + +```ts +export type ThemePreference = 'light' | 'dark' | 'system' + +export interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record +} + +export interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +export interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + +export interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +export interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。 + +## Alternatives considered + +**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。 + +**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。 + +**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。 + +**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 + +**按功能为每个 section 单开 `ui-settings-*` 卫星包。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且卫星包反向依赖 locale/theme 服务,形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层:preference 行随功能包交付;`ui-settings-general` 只收无主文案(chrome 与 General 骨架),不承载任何功能的设置面。 + +**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 + +## Acceptance criteria + +- Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。 +- 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。 +- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 +- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。 +- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 +- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 +- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 +- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。 + +## Risks + +slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..a30d5735f8 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-models + name: '@deepseek-ai/dsh-client-ui-models' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/apps/cli/package.json b/apps/cli/package.json index 8799669e8d..571e71abcf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -23,12 +23,15 @@ "@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-models": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 4db4861b93..05947889b2 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-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 532c6866da..313573ea94 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'] }, + { 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', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a3d511df16..a268db107a 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-settings', 'ui-settings-general', 'ui-models', '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..2130d27306 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'] }, + { 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', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cc269754bf..e462b2d8d9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2038,12 +2038,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) -- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts)) +- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 562ab2f68e..027cd9cb72 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -63,6 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | +| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | | `slots/changed` | `runtime` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index abadead03f..0ccb4ffcc0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -138,13 +138,16 @@ flowchart TD subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] - pkg_client_i18n["client-i18n"] + pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] + pkg_client_ui_models["client-ui-models"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] + pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -228,13 +231,11 @@ flowchart TD pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants - pkg_client_ui_theme --> pkg_invariants pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants @@ -250,13 +251,21 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_runtime pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots @@ -291,6 +300,17 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -343,6 +363,10 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -810,13 +834,11 @@ flowchart TD | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | @@ -828,8 +850,10 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -842,6 +866,8 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -859,6 +885,7 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | 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 52% rename from packages/client/i18n/package.json rename to packages/client/locale/package.json index 3e617174bf..4d75496dc8 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); registers the Language settings row", "version": "0.0.1", "private": true, "type": "module", @@ -23,21 +23,29 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^" - }, "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -46,5 +54,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/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css new file mode 100644 index 0000000000..f17a67d279 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -0,0 +1,47 @@ +/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator; + * the section column removes the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.chevron { + flex: none; +} diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx new file mode 100644 index 0000000000..a824bc6752 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -0,0 +1,68 @@ +/** + * Language preference row registered into the General section item slot + * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale + * menu. Registered by this package — the locale feature owns its own + * settings surface. + */ +import { useState } from 'react' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from './settings-contract.ts' +import type { createLanguageRowStore } from './settings-store.ts' +import css from './LanguageRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface LanguageRowInjected { + /** Translate a `settings.locale` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type LanguageRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> & LanguageRowInjected + +/** + * Render the Language row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { + const active = useStore(s => s.active) + const options = useStore(s => s.options) + const [open, setOpen] = useState(false) + const activeLabel = options.find(o => o.id === active)?.label ?? active + + return ( +
+
+
{t('language.title')}
+
+ { setOpen(false) }} + items={options.map(o => ({ id: o.id, label: o.label }))} + selectedId={active} + onSelect={(id) => { + setLocale(id) + setOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts new file mode 100644 index 0000000000..cb6bb6f861 --- /dev/null +++ b/packages/client/locale/src/client/index.ts @@ -0,0 +1,248 @@ +/** + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. The plugin also registers the Language + * preference row into the settings General section — the locale feature owns + * its own settings surface. + */ +import type { Context } from 'cordis' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { en } from '../locales/en.ts' +import { zh } from '../locales/zh.ts' +import type { LanguageRowInjected } from './LanguageRow.tsx' +import { LanguageRow } from './LanguageRow.tsx' +import { createLanguageRowStore } from './settings-store.ts' + +export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' +export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' +export type { SettingsGeneralItemOwnerProps } from './settings-contract.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' + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.locale' + +/** localStorage key holding the persisted locale id. */ +export const STORAGE_KEY = 'dsh.locale' + +/** 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 { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return FALLBACK_LOCALE + try { + const stored = 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 { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(STORAGE_KEY, id) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. + } +} + +/** Required services: the slot registry (the feature registers its own settings row). */ +export const inject = ['slots'] + +/** + * Client plugin body: provide the locale service with base dictionaries and + * register the feature-owned Language preference row into the General + * section's item slot (a feature owns its settings surface). + * @param ctx - client cordis context. + */ +export function apply(ctx: ClientContext): void { + const locale = new LocaleService(ctx) + locale.register(COMMON_NS, 'zh', zh) + locale.register(COMMON_NS, 'en', en) + locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) + locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) + ctx.provide('locale', locale) + + const store = createLanguageRowStore() + let bound: BoundActions | undefined + const sync = (snapshot: LocaleSnapshot): void => { + bound?.sync( + snapshot.active, + snapshot.locales.map(l => ({ id: l.id, label: l.label })), + snapshot.revision, + ) + } + ctx.on('locale/change', sync) + const injected = (actions: BoundActions): LanguageRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(locale.getLocale()) + return { + t: locale.bind(SETTINGS_NS), + setLocale: (id) => { locale.setLocale(id) }, + } + } + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'language', + order: 0, + store, + inject: injected, + }, LanguageRow)) + return () => { deferred.dispose() } + }, 'locale: language settings row registration') +} diff --git a/packages/client/locale/src/client/settings-contract.ts b/packages/client/locale/src/client/settings-contract.ts new file mode 100644 index 0000000000..e032707645 --- /dev/null +++ b/packages/client/locale/src/client/settings-contract.ts @@ -0,0 +1,26 @@ +/** + * The `settings.general.item` slot type — one preference row inside the + * settings General section, contributed by the feature plugin that owns the + * preference (locale → Language, ui-theme → Appearance). Options: `id` (row + * key), `order` (row position). Rows draw their own internals (row layout, + * separators via CSS); the section column only stacks them. + * + * TYPE HOME RATIONALE: the slot is declared at runtime by + * ui-settings-general's General entry, but its type lives here — this + * package is the common dependency of every item registrant (any settings + * row carries copy, so every registrant already depends on locale), whereas + * the declarer's own contract is unreachable for locale/ui-theme without a + * reference cycle. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One preference row inside the settings General section (see module JSDoc). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps } + } +} + +/** Owner share of a General preference row (the section supplies nothing). */ +export interface SettingsGeneralItemOwnerProps { + /** Marker field: item owner props are intentionally empty. */ + children?: never +} diff --git a/packages/client/locale/src/client/settings-store.ts b/packages/client/locale/src/client/settings-store.ts new file mode 100644 index 0000000000..485fd409f0 --- /dev/null +++ b/packages/client/locale/src/client/settings-store.ts @@ -0,0 +1,47 @@ +/** + * Language row slot store: a mirror of the locale service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** One selectable locale row (id + self-described label). */ +export interface LanguageOptionRow { + /** Locale id (the setLocale argument). */ + id: string + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Store state mirrored from the locale snapshot. */ +export interface LanguageRowState { + /** Active locale id. */ + active: string + /** Selectable locales in display order. */ + options: LanguageOptionRow[] + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type LanguageRowActions = { + sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void +} + +/** + * Declares the Language row state and write surface. + * @returns the store handle. + */ +export function createLanguageRowStore(): EngineStoreHandle { + return defineStore({ + init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }), + actions: { + sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + if (revision <= d.revision) return + d.active = active + d.options = options + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/locale/src/css-modules.d.ts b/packages/client/locale/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/locale/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/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/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts new file mode 100644 index 0000000000..25dbdfe239 --- /dev/null +++ b/packages/client/locale/tests/apply.spec.ts @@ -0,0 +1,116 @@ +/** locale apply wiring: service + dictionaries provision, declaration-aware + * Language row registration, snapshot projection into the row store, and + * recovery after an HMR collapse of the declaring entry. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' +import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { createLanguageRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('locale apply', () => { + it('declares the slot service', () => { + expect(inject).toEqual(['slots']) + }) + + it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const locale = before.ctx.get('locale') as LocaleService + // Base dictionaries are registered: the (ns, locale) seats are occupied. + expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') + expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! + expect(entry.options).toMatchObject({ id: 'language', order: 0 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + // An event ahead of any inject hits the unbound-actions arm. + locale.setLocale('en') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().active).toBe('en') + expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) + expect(face.t('language.title')).toBe('Language') + + face.setLocale('zh') + expect(locale.getLocale().active).toBe('zh') + expect(instance.getSnapshot().active).toBe('zh') + expect(face.t('language.title')).toBe('语言') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('teardown removes the row; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts new file mode 100644 index 0000000000..fa62ca79f6 --- /dev/null +++ b/packages/client/locale/tests/invariant.spec.ts @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' +import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' +import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + 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 () => { + // The feature registers its own Language settings row, hence the slots edge. + expect(inject).toEqual(['slots']) + const ctx = new Context() + new SlotsService(ctx) + await ctx.plugin({ inject, apply: clientApply }).await() + const locale = ctx.get('locale') + expect(locale).toBeInstanceOf(LocaleService) + // 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/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx new file mode 100644 index 0000000000..af33038970 --- /dev/null +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** LanguageRow behavior: selector pill shows the active locale, the menu + * opens/closes, and selection drives setLocale. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(active = 'en') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createLanguageRowStore().create() + store.actions.sync(active, OPTIONS, 0) + const setLocale = vi.fn() + const props: LanguageRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => key === 'language.title' ? 'Language' : key, + setLocale, + } + render() + return { store, setLocale } +} + +describe('LanguageRow', () => { + it('shows the title and the active locale label on the selector pill', () => { + mount('en') + expect(screen.getByText('Language')).toBeDefined() + const trigger = screen.getByRole('button', { name: /English/ }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) + + it('opens the menu, selects a locale, and closes', () => { + const b = mount('en') + const trigger = screen.getByRole('button', { name: /English/ }) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) + expect(b.setLocale).toHaveBeenCalledWith('zh') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + }) + + it('closes on outside pointerdown without selecting', () => { + const b = mount('en') + fireEvent.click(screen.getByRole('button', { name: /English/ })) + expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + }) + + it('follows store changes; an unknown active id falls back to the id itself', () => { + const b = mount('en') + act(() => { b.store.actions.sync('zh', OPTIONS, 1) }) + expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() + act(() => { b.store.actions.sync('fr', OPTIONS, 2) }) + expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + }) +}) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts new file mode 100644 index 0000000000..3f9efaed19 --- /dev/null +++ b/packages/client/locale/tests/locale.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } 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('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { svc } = make() + expect(svc.getLocale().active).toBe('zh') + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + } finally { + vi.unstubAllGlobals() + } + }) + + 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/locale/tests/settings-store.spec.ts b/packages/client/locale/tests/settings-store.spec.ts new file mode 100644 index 0000000000..90e9487a96 --- /dev/null +++ b/packages/client/locale/tests/settings-store.spec.ts @@ -0,0 +1,30 @@ +/** Language row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +describe('createLanguageRowStore', () => { + it('init shape: empty mirror with revision at -1', () => { + const store = createLanguageRowStore().create() + expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 }) + }) + + it('sync mirrors the snapshot and advances the revision', () => { + const store = createLanguageRowStore().create() + store.actions.sync('zh', OPTIONS, 0) + expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 }) + store.actions.sync('en', OPTIONS, 1) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(1) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createLanguageRowStore().create() + store.actions.sync('en', OPTIONS, 5) + store.actions.sync('zh', OPTIONS, 4) + store.actions.sync('zh', OPTIONS, 5) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(5) + }) +}) diff --git a/packages/client/i18n/tsconfig.json b/packages/client/locale/tsconfig.json similarity index 79% rename from packages/client/i18n/tsconfig.json rename to packages/client/locale/tsconfig.json index 63eb9779ca..8585ba74ca 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -9,10 +9,16 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../runtime" }, { - "path": "../runtime" + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../../vendor/cordis" }, { "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..b816533474 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,13 +37,16 @@ "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" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@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..7474cd69c3 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..910b5a3132 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -9,6 +9,8 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply 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 +18,17 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) + // Theme now injects ['slots', 'locale'] (it registers its Appearance + // settings row); seat a real locale service so the theme fiber activates. + ctx.provide('locale', new LocaleService(ctx)) + 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 +59,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..731eec8012 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -11,9 +11,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-slots" }, + { + "path": "../ui-theme" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md new file mode 100644 index 0000000000..b5f6c9bea9 --- /dev/null +++ b/packages/client/ui-models/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-models + +Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase. + +## 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-models/package.json b/packages/client/ui-models/package.json new file mode 100644 index 0000000000..df8cba7c8b --- /dev/null +++ b/packages/client/ui-models/package.json @@ -0,0 +1,63 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-models", + "description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)", + "version": "0.0.1", + "private": true, + "type": "module", + "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-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx new file mode 100644 index 0000000000..ee33b916cb --- /dev/null +++ b/packages/client/ui-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-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts new file mode 100644 index 0000000000..5abcb65bcf --- /dev/null +++ b/packages/client/ui-models/src/client/index.ts @@ -0,0 +1,51 @@ +/** + * 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' +import { deferRegistration } 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: 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-models: nav copy dictionaries') + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () => + ctx.slots.register({ + name: 'settings.section', + id: 'models', + order: 10, + label: ctx.locale.bind('settings.models')('nav'), + }, ModelsSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) + return () => { + offLocale() + deferred.dispose() + } + }, 'ui-models: settings section registration') +} diff --git a/packages/client/ui-models/src/css-modules.d.ts b/packages/client/ui-models/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-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-models/src/index.ts b/packages/client/ui-models/src/index.ts new file mode 100644 index 0000000000..da3060fe59 --- /dev/null +++ b/packages/client/ui-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-models/src/invariant.ts b/packages/client/ui-models/src/invariant.ts new file mode 100644 index 0000000000..c7c4996748 --- /dev/null +++ b/packages/client/ui-models/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-models`. + * @module @deepseek-ai/dsh-client-ui-models/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-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-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts new file mode 100644 index 0000000000..1842000675 --- /dev/null +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -0,0 +1,95 @@ +/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +describe('ui-models apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('registers the models nav entry for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const entry = before.slots.entries('settings.section')[0]! + expect(entry.component).toBe(ModelsSection) + expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' }) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('settings.section')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('settings.section')).toHaveLength(1) + }) + + it('re-registers with fresh label text on locale change', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('模型') + }) + + it('locale change while the slot is undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + // Declarer unload: the cascade removes our entry while our local + // disposer variable goes stale. + redeclare() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The locale path also recovers through the same ledger re-check. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + }) + + it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings.models')('nav')).toBe('模型') + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow() + }) +}) diff --git a/packages/client/ui-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts new file mode 100644 index 0000000000..05fb52ee1b --- /dev/null +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +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(ModelsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-models') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) + + it('the section content column is intentionally empty this phase', () => { + expect(ModelsSection()).toBeNull() + }) +}) diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json new file mode 100644 index 0000000000..dde94c20af --- /dev/null +++ b/packages/client/ui-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-models/tsdown.config.ts b/packages/client/ui-models/tsdown.config.ts new file mode 100644 index 0000000000..fc044b07d6 --- /dev/null +++ b/packages/client/ui-models/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 28c3150335..6825731275 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..0e0025279b --- /dev/null +++ b/packages/client/ui-settings-general/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings-general + +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. + +## Model Experience + +None, as the plugin renders browser settings 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. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json new file mode 100644 index 0000000000..798a7710f0 --- /dev/null +++ b/packages/client/ui-settings-general/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-general", + "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "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-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^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-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..5e053a49c5 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -0,0 +1,111 @@ +/* General section rows (figma 501:29983 'Options'): stacked groups, 16px + * vertical padding each, hairline separator under all but the last child + * (feature-contributed rows carry their own row chrome and separators; the + * :last-child rule strips the trailing one wherever the column ends). */ + +.section { + display: flex; + flex-direction: column; + width: 100%; +} + +.section > :last-child { + border-bottom: none; +} + +/* 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); +} + +/* 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; +} + +/* Tool Call mode cubes share an 8px gap. */ +.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; +} + +/* 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..8c75e76022 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -0,0 +1,61 @@ +/** + * The General section (figma 501:29983 'Options'): Permission and Tool Call + * skeleton rows, then the feature-contributed preference rows from the + * `settings.general.item` slot (locale → Language, ui-theme → Appearance). + * The section column stacks rows; each row draws its own internals and + * separator. + */ +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './GeneralSection.module.css' + +/** Injected face of the General section: the settings-namespace translate. */ +export interface GeneralSectionInjected { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** Full component props: section owner share + item render share + inject face. */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected + +/** + * Render the General section content column. + * @param props - composed slot props (contract/slots.ts). + * @returns the section element tree. + */ +export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { + return ( +
+ {/* 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')}
+
+
+
+ + {/* Feature-owned preference rows (Language, Appearance, …). */} + {renderSlot('settings.general.item', {})} +
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/chrome.module.css b/packages/client/ui-settings-general/src/client/chrome.module.css new file mode 100644 index 0000000000..3291b3cd66 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/chrome.module.css @@ -0,0 +1,7 @@ +/* Trigger row label (the shell's button provides layout/colors; the label + * only guards against overflow during the sidebar collapse crossfade). */ + +.triggerLabel { + overflow: hidden; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/chrome.tsx b/packages/client/ui-settings-general/src/client/chrome.tsx new file mode 100644 index 0000000000..dc7ec94a29 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/chrome.tsx @@ -0,0 +1,56 @@ +/** + * Shell chrome content registered into the shell's trigger/header seats: the + * trigger row icon + label (figma sidebar foot) and the panel title text. + * The shell renders the surrounding chrome (button, nav heading row) and + * reads each entry's `label` option for aria text. + */ +import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './chrome.module.css' + +/** Injected face of both chrome seats: the settings-namespace translate. */ +export interface ChromeInjected { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** Trigger content props: the sidebar column state + translate. */ +export type TriggerContentProps = PropsRuntime<'settings.trigger'> & ChromeInjected + +/** Header content props: translate only. */ +export type HeaderContentProps = PropsRuntime<'settings.header'> & ChromeInjected + +/** + * Render the trigger row content (icon; label only in the wide column). + * @param props - composed slot props. + * @returns the trigger content fragment. + */ +export function TriggerContent({ wide, t }: TriggerContentProps) { + return ( + <> + + {wide && {t('trigger')}} + + ) +} + +/** + * Render the panel title text. + * @param props - composed slot props. + * @returns the title text node. + */ +export function HeaderContent({ t }: HeaderContentProps) { + return <>{t('title')} +} + +/** Close-button label text props: translate only. */ +export type CloseLabelProps = PropsRuntime<'settings.close'> & ChromeInjected + +/** + * Render the close button's visually-hidden label text. + * @param props - composed slot props. + * @returns the label text node. + */ +export function CloseLabel({ t }: CloseLabelProps) { + return <>{t('close')} +} 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..afb37d8b92 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -0,0 +1,87 @@ +/** + * Settings ownerless-copy plugin, browser half: registers everything on the + * Settings surface that belongs to no single feature — the trigger/header + * chrome content, the General section (skeleton rows + the + * `settings.general.item` slot declaration), and the `settings` + * dictionaries. Feature-owned rows and sections stay with their features. + * Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ChromeInjected } from './chrome.tsx' +import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' +import type { GeneralSectionInjected } from './GeneralSection.tsx' +import { GeneralSection } from './GeneralSection.tsx' +import { en, zh } from './locales.ts' + +export type { + ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps, +} from './chrome.tsx' +export type { + GeneralSectionComponentProps, GeneralSectionInjected, +} from './GeneralSection.tsx' + +/** Dictionary namespace owned by this plugin (shell chrome + General copy). */ +const NS = 'settings' + +/** + * Required services (cordis fiber inject). The target slots are 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 `settings` dictionaries, the chrome content, and the General + * section, each once its slot declaration is on the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register(NS, 'zh', zh), + ctx.locale.register(NS, 'en', en), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-general: dictionaries') + + const t = ctx.locale.bind(NS) + const chromeInjected = (): ChromeInjected => ({ t }) + const generalInjected = (): GeneralSectionInjected => ({ t }) + + // All four seats refresh on locale change: re-registration bumps each + // slot's ledger version, which re-renders the outlets through their own + // subscriptions (outlet memoization would swallow a parent-only render). + ctx.effect(() => { + const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () => + ctx.slots.register({ name: 'settings.trigger', inject: chromeInjected }, TriggerContent)) + const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => + ctx.slots.register({ name: 'settings.header', inject: chromeInjected }, HeaderContent)) + const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => + ctx.slots.register({ name: 'settings.close', inject: chromeInjected }, CloseLabel)) + const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => + ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: t('general.nav'), + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + inject: generalInjected, + }, GeneralSection)) + const offLocale = ctx.on('locale/change', () => { + trigger.refresh() + header.refresh() + close.refresh() + general.refresh() + }) + return () => { + offLocale() + trigger.dispose() + header.dispose() + close.dispose() + general.dispose() + } + }, 'ui-settings-general: chrome and section registrations') +} 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..6fc3295561 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -0,0 +1,40 @@ +/** + * `settings` namespace dictionaries: shell chrome plus the shell-owned + * General section (nav label, skeleton rows). Skeleton-row technical copy + * (Read only / Schema mode / Code mode and their descriptions) is shared + * verbatim across locales per the Figma design. Feature-owned rows + * (Language, Appearance) ship their copy in their own packages. + */ +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, + 'trigger': '设置', + 'title': '设置', + 'close': '关闭', + 'general.nav': '通用设置', + 'permission.title': '权限', + 'permission.desc': '选择默认权限模式', + 'toolcall.title': '工具调用', +} + +/** English dictionary. */ +export const en: LocaleDict = { + ...SHARED, + 'trigger': 'Settings', + 'title': 'Settings', + 'close': 'Close', + 'general.nav': 'General', + 'permission.title': 'Permission', + 'permission.desc': 'Choose default permission mode', + 'toolcall.title': 'Tool Call', +} 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..29f762834d --- /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 copy-owning registrant contributing chrome content + * and the General section into shell-declared slots — it emits no cordis + * events and owns no cross-plugin mutable relation; slot 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-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts new file mode 100644 index 0000000000..d01be576b7 --- /dev/null +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -0,0 +1,150 @@ +/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' + +/** The four seats this plugin fills (slot name → expected component). */ +const SEATS = [ + ['settings.trigger', TriggerContent], + ['settings.header', HeaderContent], + ['settings.close', CloseLabel], + ['settings.section', GeneralSection], +] as const + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +/** Declare the shell's four child slots the way ui-settings' entry does. */ +function declare(slots: SlotsService): () => void { + return slots.register( + { + name: 'root', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + }, + } as never, + () => null, + ) +} + +function generalEntry(slots: SlotsService) { + return slots.entries('settings.section').find(e => e.component === GeneralSection) +} + +describe('ui-settings-general apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('fills all four seats for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + for (const [name, component] of SEATS) { + expect(before.slots.entries(name)[0]!.component).toBe(component) + } + const entry = generalEntry(before.slots)! + expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' }) + expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const injected = (entry.inject as unknown as () => GeneralSectionInjected)() + expect(injected.t('permission.title')).toBe('权限') + // The chrome seats share one inject face: the settings-ns translate. + const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)() + expect(chrome.t('trigger')).toBe('设置') + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + for (const [name, component] of SEATS) { + expect(after.slots.entries(name)[0]!.component).toBe(component) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries(name)).toHaveLength(1) + } + }) + + it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings')('title')).toBe('设置') + b.locale.setLocale('en') + expect(b.locale.bind('settings')('close')).toBe('Close') + b.locale.setLocale('zh') + await fiber.dispose() + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + }) + + it('refreshes all four seats on locale change with fresh General label text', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name)) + b.locale.setLocale('en') + // Every seat re-registered (version moved) and the label re-resolved. + SEATS.forEach(([name], i) => { + expect(b.slots.getVersion(name)).toBeGreaterThan(zhVersions[i]!) + expect(b.slots.entries(name)).toHaveLength(1) + }) + expect(generalEntry(b.slots)!.options.label).toBe('General') + b.locale.setLocale('zh') + expect(generalEntry(b.slots)!.options.label).toBe('通用设置') + }) + + it('locale change while the slots are undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Declarer unload: the cascade removes every seat entry and the item + // declaration while our local disposers go stale. + redeclare() + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + declare(b.slots) + await Promise.resolve() + for (const [name, component] of SEATS) { + expect(b.slots.entries(name)[0]!.component).toBe(component) + } + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The recovered registrations still ride the locale path. + b.locale.setLocale('en') + expect(generalEntry(b.slots)!.options.label).toBe('General') + b.locale.setLocale('zh') + }) + + it('removes every seat and the item declaration on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.spec('settings.general.item')).toBeDefined() + await fiber.dispose() + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx new file mode 100644 index 0000000000..2a041c6cf4 --- /dev/null +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: string) => en[key] ?? key + +// Global standard kit stubs: none of these components consume the hooks. +const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never +const kit = { useSessions: unusedHook, useWorkspaces: unusedHook } + +describe('chrome content', () => { + it('TriggerContent renders the icon with the label in the wide column', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeTruthy() + expect(screen.getByText('Settings')).toBeTruthy() + }) + + it('TriggerContent drops the label in the rail state', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeTruthy() + expect(screen.queryByText('Settings')).toBeNull() + }) + + it('HeaderContent and CloseLabel render their translated text', () => { + render() + render() + expect(screen.getByText('Settings')).toBeTruthy() + expect(screen.getByText('Close')).toBeTruthy() + }) +}) + +describe('GeneralSection', () => { + function mount() { + const renderSlot = vi.fn( + ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], + ) + const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const view = render() + return { view, renderSlot } + } + + it('renders the Permission skeleton row with the disabled selector', () => { + mount() + expect(screen.getByText('Permission')).toBeTruthy() + expect(screen.getByText('Choose default permission mode')).toBeTruthy() + const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + expect(selector.disabled).toBe(true) + }) + + it('renders the Tool Call skeleton cubes with schema pinned selected', () => { + mount() + expect(screen.getByText('Tool Call')).toBeTruthy() + const schema = screen.getByText('Schema mode') + const code = screen.getByText('Code mode') + expect(schema.parentElement!.className).toContain('selected') + expect(code.parentElement!.className).not.toContain('selected') + expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() + expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() + }) + + it('renders the feature-contributed item slot after the skeleton rows', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts new file mode 100644 index 0000000000..7b0527c0ff --- /dev/null +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json new file mode 100644 index 0000000000..5ef01ba51c --- /dev/null +++ b/packages/client/ui-settings-general/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-settings" + }, + { + "path": "../locale" + }, + { + "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/README.md b/packages/client/ui-settings/README.md new file mode 100644 index 0000000000..49a56a8f80 --- /dev/null +++ b/packages/client/ui-settings/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings + +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger. + +## 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 surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general. diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json new file mode 100644 index 0000000000..efadf3190f --- /dev/null +++ b/packages/client/ui-settings/package.json @@ -0,0 +1,66 @@ +{ + "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" + ], + "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-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..e2b2c878df --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -0,0 +1,202 @@ +/* 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; +} + +/* Visually-hidden text seat (close button accessible name from slot content). */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} 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..04fa39a03c --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -0,0 +1,124 @@ +/** + * Settings shell root: the sidebar-foot trigger row plus the centered modal + * panel (figma 501:29947, 1080x700) with the section nav rail. The shell is + * a pure composition face — every piece of text (trigger label, panel title, + * close label, sections) arrives from registrants through slots; accessible + * names resolve to that content (trigger: its own text; dialog: + * aria-labelledby the title node; close: visually-hidden slot text). Modal + * open state and the active section id are component-local viewing state. + */ +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import clsx from 'clsx' +import { IconCloseOutline16, IconDataOutline16, 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 = { + 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({ 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 + const titleId = useId() + + 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, subscribeSections, sectionsVersion, sections, renderSlot } = props + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + + // The ledger tick keeps the nav rows fresh: registrants re-register with + // freshly localized text on locale change, and the trigger/header/close + // seats re-render through their own outlets' subscriptions. + // 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..1a263108bc --- /dev/null +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -0,0 +1,97 @@ +/** + * Settings shell slot contract — the canonical home of every settings slot + * type. The shell is a pure composition face with zero copy of its own: it + * occupies the sidebar-owned `sidebar.settings` hole and declares the slots + * below; ALL text (trigger label, panel title, close aria, section content) + * arrives from registrants. A feature owns its settings surface — adding a + * setting never means editing the shell; copy that belongs to no single + * feature (chrome, the General section) is owned by ui-settings-general. + */ +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 { + /** + * The sidebar-foot trigger row content: icon + label, supplied as slot + * content (the accessible name comes from the content — rail state + * renders the label visually hidden). The shell renders the button + * chrome and owns open state. Absent contribution degrades to an + * icon-only button without an accessible name (broken-composition state; + * the shipped composition always registers the seat). + */ + 'settings.trigger': { kind: 'single'; scope: 'root'; owner: SettingsTriggerOwnerProps } + /** + * The panel title text seat. Content renders inside the nav heading row; + * the dialog's accessible name points at that node via aria-labelledby. + * Absent contribution leaves the heading empty. + */ + 'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps } + /** + * The close button's visually-hidden label text (the button itself — + * icon, geometry, focus — is shell chrome). Absent contribution leaves + * the button without an accessible name (broken-composition state). + */ + 'settings.close': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps } + /** + * 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 state; the ledger bump doubles as the shell's + * re-render trigger). Sections render inside the panel content column. + * (`settings.general.item`, declared by ui-settings-general's General + * entry, is typed in the locale package — the common dependency of every + * item registrant; the shell neither declares nor renders it.) + */ + 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + } +} + +/** Owner share of the trigger content seat: the sidebar column state. */ +export interface SettingsTriggerOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail, icon only). */ + wide: boolean +} + +/** Owner share of the header title seat (the shell supplies nothing). */ +export interface SettingsHeaderOwnerProps { + /** Marker field: header owner props are intentionally empty. */ + children?: never +} + +/** + * 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): ledger projections only — the shell reads no locale state. + */ +export type SettingsRootInjected = { + /** 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 render shares 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.trigger' | 'settings.header' | 'settings.close' | '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..7cb3dfd6d4 --- /dev/null +++ b/packages/client/ui-settings/src/client/index.ts @@ -0,0 +1,61 @@ +/** + * Settings shell plugin, browser half. A pure composition face: occupies the + * sidebar-owned `sidebar.settings` hole with the trigger chrome + modal + * panel, declares the `settings.trigger` / `settings.header` / + * `settings.section` slots, and projects the section ledger into the panel + * navigation. The shell ships no copy and reads no locale state — all text + * arrives from registrants (ui-settings-general owns the chrome and General + * content; features own their rows and sections). Export discipline: + * packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { SettingsRootInjected } from './contract/slots.ts' +import { SettingsRoot } from './SettingsRoot.tsx' + +export type { + SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, + SettingsSectionOwnerProps, SettingsTriggerOwnerProps, +} 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'] + +/** + * 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 { + const injected = (): SettingsRootInjected => ({ + sectionsVersion: () => ctx.slots.getVersion('settings.section'), + subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), + sections: () => ctx.slots.entries('settings.section') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: e.options.label ?? '', + })) + .sort((a, b) => a.order - b.order), + }) + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => + ctx.slots.register({ + name: 'sidebar.settings', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + }, + inject: injected, + }, SettingsRoot)) + return () => { deferred.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/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts new file mode 100644 index 0000000000..d7fdfbd546 --- /dev/null +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -0,0 +1,111 @@ +/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, + () => null, + ) +} + +function injectedOf(slots: SlotsService): SettingsRootInjected { + const entry = slots.entries('sidebar.settings')[0]! + return (entry.inject as () => SettingsRootInjected)() +} + +/** The shell's four child declarations (chrome seats + the section list). */ +const CHILD_SPECS = { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, +} as const + +describe('ui-settings apply', () => { + it('declares only the slot registry (a pure composition face, no locale)', () => { + expect(inject).toEqual(['slots']) + }) + + it('registers the shell and declares the four child slots, before or after the declaration', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + for (const [name, spec] of Object.entries(CHILD_SPECS)) { + expect(before.slots.spec(name as never)).toEqual(spec) + } + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('sidebar.settings')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('sidebar.settings')).toHaveLength(1) + }) + + it('projects the section ledger into ordered nav rows with option defaults', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + // The shell ships no sections of its own — registrants fill the ledger. + expect(injected.sections()).toEqual([]) + b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) + // No order and no label: both projection defaults apply. + b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + expect(injected.sections()).toEqual([ + { id: 'a', order: 0, label: '' }, + { id: 'z', order: 20, label: 'Z' }, + ]) + expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) + const listener = vi.fn() + const off = injected.subscribeSections(listener) + b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + await Promise.resolve() + expect(listener).toHaveBeenCalled() + off() + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('sidebar.settings')).toHaveLength(1) + // Declarer unload: the cascade removes our entry and every child + // declaration while our local disposer variable goes stale. + redeclare() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + expect(b.slots.spec('settings.trigger')).toBeUndefined() + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + for (const [name, spec] of Object.entries(CHILD_SPECS)) { + expect(b.slots.spec(name as never)).toEqual(spec) + } + }) + + it('unregisters the shell and collapses all four child slots on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await fiber.dispose() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + for (const name of Object.keys(CHILD_SPECS)) { + expect(b.slots.spec(name as never)).toBeUndefined() + } + }) +}) diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..c3474d5bdd --- /dev/null +++ b/packages/client/ui-settings/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/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(SettingsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx new file mode 100644 index 0000000000..9584d500e5 --- /dev/null +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +afterEach(cleanup) + +type Row = { id: string; order: number; label: string } + +/** Slot-content stand-ins: the shell renders whatever the seats contribute. */ +const SEAT_CONTENT: Record = { + 'settings.trigger': 'Settings', + 'settings.header': 'Settings Title', + 'settings.close': 'Close', +} + +function mount({ + wide = true, + rows = [ + { id: 'general', order: 0, label: 'General' }, + { id: 'models', order: 10, label: 'Models' }, + ], +}: { wide?: boolean; rows?: Row[] } = {}) { + // Mutable row store standing in for the ledger; bump() plays a change. + let current = rows + let version = 0 + const listeners = new Set<() => void>() + const renderSlot = vi.fn( + ((key: string, _owner: unknown, opts?: { only?: string }) => { + if (key === 'settings.section') return
+ return SEAT_CONTENT[key] + }) as SettingsRootComponentProps['renderSlot'], + ) + // Global standard kit stubs: the shell consumes neither hook. + const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never + const props: SettingsRootComponentProps = { + useSessions: unusedHook, + useWorkspaces: unusedHook, + wide, + sectionsVersion: () => version, + subscribeSections: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + sections: () => current, + renderSlot, + } + const view = render() + const bump = (next: Row[]) => { + act(() => { + current = next + version += 1 + for (const fn of [...listeners]) fn() + }) + } + return { view, renderSlot, bump, listeners } +} + +function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) +} + +describe('SettingsRoot trigger', () => { + it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => { + const { renderSlot } = mount() + const trigger = screen.getByRole('button', { name: 'Settings' }) + expect(trigger.hasAttribute('aria-label')).toBe(false) + expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy() + }) + + it('hands the rail state to the trigger seat', () => { + const { renderSlot } = mount({ wide: false }) + expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false }) + }) +}) + +describe('SettingsPanel chrome seats', () => { + it('names the dialog via aria-labelledby pointing at the header seat node', () => { + mount() + openPanel() + const dialog = screen.getByRole('dialog') + const titleId = dialog.getAttribute('aria-labelledby')! + expect(titleId).toBeTruthy() + const title = document.getElementById(titleId)! + expect(title.textContent).toBe('Settings Title') + expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy() + }) + + it('names the close button through the visually-hidden close seat text', () => { + mount() + openPanel() + const close = screen.getByRole('button', { name: 'Close' }) + expect(close.hasAttribute('aria-label')).toBe(false) + expect(close.textContent).toContain('Close') + }) +}) + +describe('SettingsPanel close paths', () => { + it('closes via the header button', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via a mask click', () => { + mount() + openPanel() + const dialog = screen.getByRole('dialog') + fireEvent.click(dialog.parentElement!.firstElementChild!) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via document-level Escape and unhooks the listener with the panel', () => { + mount() + openPanel() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + // Ignored while closed (listener removed with the panel) and non-Escape + // keys are ignored while open. + fireEvent.keyDown(document, { key: 'Escape' }) + openPanel() + fireEvent.keyDown(document, { key: 'Enter' }) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('lands focus on the close button when the dialog opens', () => { + mount() + openPanel() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })) + }) +}) + +describe('SettingsPanel navigation', () => { + it('projects rows, marks the first active, and renders only that section', () => { + mount() + openPanel() + expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('switches the rendered section on nav click', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByTestId('section-models')).toBeTruthy() + expect(screen.queryByTestId('section-general')).toBeNull() + }) + + it('falls back to the first row when the active entry unregisters', () => { + const { bump } = mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + bump([{ id: 'general', order: 0, label: 'General' }]) + expect(screen.queryByRole('button', { name: 'Models' })).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('renders an empty content column when the ledger is empty', () => { + const { renderSlot } = mount({ rows: [] }) + openPanel() + expect(screen.getByRole('dialog')).toBeTruthy() + const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section') + expect(sectionCalls).toHaveLength(0) + }) + + it('drops the ledger subscription on unmount', () => { + const { view, listeners } = mount() + expect(listeners.size).toBe(1) + view.unmount() + expect(listeners.size).toBe(0) + }) +}) diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json new file mode 100644 index 0000000000..d94f0fab96 --- /dev/null +++ b/packages/client/ui-settings/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": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-sidebar" + }, + { + "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 729855cdde..fe73435df8 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -48,7 +48,7 @@ refresh straight into the collapsed state renders statically. */ .railIn .iconButton, .railIn .newSession, -.railIn .foot { +.railIn .footArea { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -193,45 +193,11 @@ overflow: hidden; } -/* 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: a pure layout socket pinned under the region; the ui-settings + trigger row inside owns its own geometry (49px wide row / 36px rail + circle) and hover chrome. */ +.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) { @@ -239,7 +205,7 @@ .fading > *, .railIn .iconButton, .railIn .newSession, - .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 bcb03780d5..f5f810cbbe 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -5,14 +5,15 @@ * mid-slide. At settle the wide-only content unmounts and the control rows * snap to the 56px rail (one icon each, same top-down order) fading in as the * slide ends. The workspace/session browsing region between the New Session - * button and the foot is the `sidebar.workspaces` registrant's; the shell - * hands it the wide flag and an expand request callback. + * button and the foot is the `sidebar.workspaces` registrant's, and the foot + * is the `sidebar.settings` registrant's; the shell hands them the wide flag + * (plus an expand request callback for the browser). */ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, - IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14, + IconNewChatOutline16, IconPanelLeftOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarRootComponentProps } from './contract/slots.ts' @@ -102,9 +103,9 @@ export function SidebarRoot({ })}
-
- - {wide && Settings} + {/* Foot seat: ui-settings registers the trigger row + panel here. */} +
+ {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 1fd84312fa..5e26f6defd 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,9 +1,10 @@ /** * Sidebar slot contract: the registrant-side props composition for the - * layout-owned `sidebar` slot, plus the workspace-browser hole this shell - * declares. The shell owns column geometry (fold state machine, brand row, - * New Session, Settings); everything between the section header and the list - * bottom is the `sidebar.workspaces` registrant's (ui-workspace). + * layout-owned `sidebar` slot, plus the holes this shell declares. The shell + * owns column geometry (fold state machine, brand row, New Session); + * everything between the section header and the list bottom is the + * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the + * `sidebar.settings` registrant's (ui-settings). */ import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every @@ -20,6 +21,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * registers the browser. */ 'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps } + /** + * 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 } } } @@ -34,6 +41,15 @@ export interface SidebarSectionOwnerProps { expandSidebar: () => void } +/** + * 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 +} + /** * Registrant-private injected share (arrives via the register inject * factory). The shell keeps only its own controls: starting a Session from @@ -47,8 +63,8 @@ export type SidebarRootInjected = { } /** - * Full component props: layout owner state/actions plus the browser hole's - * render share and this package's injected callbacks. No store is registered. + * Full component props: layout owner state/actions plus the declared holes' + * render shares and this package's injected callbacks. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0493ef2279..abd78bee12 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, SidebarSectionOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ export const inject = ['slots', 'layout', 'workspaces'] @@ -20,8 +20,12 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'sidebar', // The shell owns geometry; ui-workspace registers the whole browsing - // region (header, search, session list, workspace dialogs) here. - children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } }, + // region (header, search, session list, workspace dialogs), ui-settings + // registers the foot trigger + settings panel. + children: { + 'sidebar.workspaces': { 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 6bdba770eb..b15d079a3d 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' +import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' afterEach(() => { @@ -17,14 +17,19 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w const startSession = vi.fn() const toggleSidebar = vi.fn() let regionOwner: SidebarSectionOwnerProps | undefined + let settingsOwner: SidebarSettingsOwnerProps | undefined let current = { collapsed, width } const root = () => ( { - regionOwner = owner + renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => { + if (key === 'sidebar.settings') { + settingsOwner = owner as SidebarSettingsOwnerProps + return
+ } + regionOwner = owner as SidebarSectionOwnerProps return
}) as SidebarRootComponentProps['renderSlot']} /> @@ -37,6 +42,10 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w if (regionOwner === undefined) throw new Error('region owner not rendered') return regionOwner }, + settingsOwner: () => { + if (settingsOwner === undefined) throw new Error('settings owner not rendered') + return settingsOwner + }, rerender(next: Partial) { current = { ...current, ...next } view.rerender(root()) @@ -56,6 +65,8 @@ describe('SidebarRoot shell', () => { it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { const b = mountShell() expect(b.regionOwner().wide).toBe(true) + // The settings seat rides the same wide flag (ui-settings renders the row). + expect(b.settingsOwner().wide).toBe(true) // Expanded: the request is a no-op (no accidental collapse). b.regionOwner().expandSidebar() expect(b.toggleSidebar).not.toHaveBeenCalled() diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts new file mode 100644 index 0000000000..af85c27f97 --- /dev/null +++ b/packages/client/ui-slots/src/deferred.ts @@ -0,0 +1,66 @@ +/** + * Declaration-aware registration deferral: the shared timing machinery for + * registering into a slot whose declaring entry activates in unconstrained + * order (dshClient.inject edges never sequence apply). Presence is judged on + * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, + * the cascade has already removed the entry while the local disposer went + * stale, and a flag guard would block the re-registration. + */ + +/** Minimal registry face the deferral reads (SlotsService satisfies it). */ +export interface DeferralRegistry { + /** Declared spec lookup (undefined = not declared yet). */ + spec(name: string): unknown + /** Current entries of the slot (component identity is the presence judge). */ + entries(name: string): readonly { component: unknown }[] + /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ + subscribe(name: string, listener: () => void): () => void +} + +/** Handle over one deferred registration. */ +export interface DeferredRegistration { + /** + * Drop the current registration (stale disposers are harmless no-ops) and + * immediately re-attempt — the refresh path for registrants whose options + * carry localized text. + */ + refresh(): void + /** Unsubscribe and unregister (idempotent through the slot core). */ + dispose(): void +} + +/** + * Register into `name` as soon as its declaration is on the ledger, and + * re-register whenever the declaration reappears after a collapse. + * @param registry - the slot registry face. + * @param name - target slot name. + * @param component - the component whose ledger presence marks "registered". + * @param register - performs the actual registration; returns its disposer. + * @returns the deferral handle (dispose in the owning effect's disposer). + */ +export function deferRegistration( + registry: DeferralRegistry, + name: string, + component: unknown, + register: () => () => void, +): DeferredRegistration { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (registry.spec(name) === undefined) return + if (registry.entries(name).some(e => e.component === component)) return + dispose = register() + } + const unsubscribe = registry.subscribe(name, () => { tryRegister() }) + tryRegister() + return { + refresh() { + dispose?.() + dispose = undefined + tryRegister() + }, + dispose() { + unsubscribe() + dispose?.() + }, + } +} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 677bc4e0df..e3bc57797d 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -18,6 +18,7 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' +export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} 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..e60289b52d 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; registers the Appearance settings row", "version": "0.0.1", "private": true, "type": "module", @@ -24,18 +24,32 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -44,5 +58,12 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "dependencies": { + "clsx": "^2.0.0" + } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css new file mode 100644 index 0000000000..9619caa823 --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -0,0 +1,51 @@ +/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8, + * pad 16/0, hairline separator; the section column strips it when last). */ + +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered + * icon-over-label column, gap 4). */ +.themeCube { + box-sizing: border-box; + width: 276px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx new file mode 100644 index 0000000000..b4aad1725d --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -0,0 +1,63 @@ +/** + * Appearance preference row registered into the General section item slot + * (figma 501:30012 'Frame 2117131228'): title + three preference cubes. + * Registered by this package — the theme feature owns its own settings + * surface. Selection follows the persisted preference, never the resolved + * active theme. + */ +import clsx from 'clsx' +import { + IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { ThemePreference } from './index.ts' +import type {} from './settings-contract.ts' +import type { createAppearanceRowStore } from './settings-store.ts' +import css from './AppearanceRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface AppearanceRowInjected { + /** Translate a `settings.theme` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the theme preference. */ + setTheme: (id: ThemePreference) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type AppearanceRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> & AppearanceRowInjected + +/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +] + +/** + * Render the Appearance row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) { + const preference = useStore(s => s.preference) + return ( +
+
{t('appearance.title')}
+
+ {CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
+
+ ) +} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index d9f5b70c3b..dd11c98f06 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,81 +1,276 @@ /** - * 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. The plugin + * also registers the Appearance preference row into the settings General + * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { AppearanceRowInjected } from './AppearanceRow.tsx' +import { AppearanceRow } from './AppearanceRow.tsx' +import { createAppearanceRowStore } from './settings-store.ts' + +export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' +export type { AppearanceRowState } from './settings-store.ts' + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.theme' /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record +/** 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() + // Non-browser runs (node e2e booting the client tree) have no matchMedia. + this.media = typeof matchMedia === 'undefined' ? undefined : 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) + /* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */ + if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) + 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) } } -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] +/** Read the persisted preference; unknown or unreadable values fall back to the default. */ +function restorePreference(): ThemePreference { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE + try { + const stored = 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 { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(STORAGE_KEY, preference) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. + } +} + +/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ +export const inject = ['slots', 'locale'] /** - * Client plugin body: provide the theme service. + * Client plugin body: provide the theme service and register the + * feature-owned Appearance preference row into the General section's item + * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: Context): void { - ctx.provide('theme', new ThemeService()) +export function apply(ctx: ClientContext): void { + const theme = new ThemeService(ctx) + ctx.provide('theme', theme) + + ctx.effect(() => { + const disposers = [ + ctx.locale.register(SETTINGS_NS, 'zh', { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', + }), + ctx.locale.register(SETTINGS_NS, 'en', { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-theme: settings row dictionaries') + + const store = createAppearanceRowStore() + let bound: BoundActions | undefined + const sync = (snapshot: ThemeSnapshot): void => { + bound?.sync(snapshot.preference, snapshot.revision) + } + ctx.on('theme/change', sync) + const injected = (actions: BoundActions): AppearanceRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(theme.getTheme()) + return { + t: ctx.locale.bind(SETTINGS_NS), + setTheme: (id) => { theme.setTheme(id) }, + } + } + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'appearance', + order: 10, + store, + inject: injected, + }, AppearanceRow)) + return () => { deferred.dispose() } + }, 'ui-theme: appearance settings row registration') } diff --git a/packages/client/ui-theme/src/client/settings-contract.ts b/packages/client/ui-theme/src/client/settings-contract.ts new file mode 100644 index 0000000000..60bfffebb5 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-contract.ts @@ -0,0 +1,9 @@ +/** + * Re-export seam for the `settings.general.item` slot type consumed by this + * package's Appearance row. The canonical home is the locale package (the + * common dependency of every item registrant); this file exists so row + * modules import the type from within their own package. + */ +export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client' +// Side-effect type import: pulls the SlotMap merge into this program. +import type {} from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts new file mode 100644 index 0000000000..256b04a299 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -0,0 +1,37 @@ +/** + * Appearance row slot store: a mirror of the theme service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import type { ThemePreference } from './index.ts' + +/** Store state mirrored from the theme snapshot. */ +export interface AppearanceRowState { + /** Persisted preference (selection state reads this, never the resolved active theme). */ + preference: ThemePreference + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type AppearanceRowActions = { + sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void +} + +/** + * Declares the Appearance row state and write surface. + * @returns the store handle. + */ +export function createAppearanceRowStore(): EngineStoreHandle { + return defineStore({ + init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }), + actions: { + sync: (d, preference: ThemePreference, revision: number) => { + if (revision <= d.revision) return + d.preference = preference + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-theme/src/css-modules.d.ts b/packages/client/ui-theme/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-theme/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-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/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx new file mode 100644 index 0000000000..4782b674a8 --- /dev/null +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +/** AppearanceRow behavior: three cubes, selection follows the persisted + * preference, clicks drive setTheme. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' +import type { ThemePreference } from '../src/client/index.ts' + +afterEach(cleanup) + +const COPY: Record = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(preference: ThemePreference = 'system') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createAppearanceRowStore().create() + store.actions.sync(preference, 0) + const setTheme = vi.fn() + const props: AppearanceRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => COPY[key] ?? key, + setTheme, + } + render() + return { store, setTheme } +} + +const pressed = (name: RegExp): string | null => + screen.getByRole('button', { name }).getAttribute('aria-pressed') + +describe('AppearanceRow', () => { + it('renders the title and three cubes with the preference cube selected', () => { + mount('dark') + expect(screen.getByText('Appearance')).toBeDefined() + expect(pressed(/Dark/)).toBe('true') + expect(pressed(/Light/)).toBe('false') + expect(pressed(/System/)).toBe('false') + }) + + it('click drives setTheme; selection follows the store mirror, not the click echo', () => { + const b = mount('dark') + fireEvent.click(screen.getByRole('button', { name: /Light/ })) + expect(b.setTheme).toHaveBeenCalledWith('light') + // No store write yet: selection is unchanged. + expect(pressed(/Dark/)).toBe('true') + act(() => { b.store.actions.sync('light', 1) }) + expect(pressed(/Light/)).toBe('true') + expect(pressed(/Dark/)).toBe('false') + }) +}) diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts new file mode 100644 index 0000000000..9852b93e66 --- /dev/null +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -0,0 +1,117 @@ +/** ui-theme apply wiring: service provision, settings dictionaries riding the + * locale service, declaration-aware Appearance row registration, snapshot + * projection into the row store, and HMR collapse recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { createAppearanceRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('ui-theme apply', () => { + it('declares the slot and locale services', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观') + before.locale.setLocale('en') + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance') + const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)! + expect(entry.options).toMatchObject({ id: 'appearance', order: 10 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + // An event ahead of any inject hits the unbound-actions arm. + theme.setTheme('dark') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().preference).toBe('dark') + expect(face.t('appearance.dark')).toBe('深色') + + face.setTheme('system') + expect(theme.getTheme().preference).toBe('system') + expect(instance.getSnapshot().preference).toBe('system') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + // Dictionary disposal: translation falls back to the bare key. + expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title') + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 6f4e867c4d..640599ea43 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,6 +4,8 @@ import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' +import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' describe('invariant companion', () => { @@ -18,9 +20,13 @@ describe('invariant companion', () => { expect(true).toBe(true) // reaching here without throw is the contract }) - it('client apply provides ctx.theme with no service prerequisites', async () => { - expect(inject).toEqual([]) + it('client apply provides ctx.theme over the slots/locale edges', async () => { + // The feature registers its own Appearance settings row with localized + // copy, hence the slots + locale edges. + expect(inject).toEqual(['slots', 'locale']) const ctx = new Context() + new SlotsService(ctx) + await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.spec.ts new file mode 100644 index 0000000000..540d0f5b3b --- /dev/null +++ b/packages/client/ui-theme/tests/settings-store.spec.ts @@ -0,0 +1,28 @@ +/** Appearance row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' + +describe('createAppearanceRowStore', () => { + it('init shape: system preference with revision at -1', () => { + const store = createAppearanceRowStore().create() + expect(store.getSnapshot()).toEqual({ preference: 'system', revision: -1 }) + }) + + it('sync mirrors the preference and advances the revision', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 0) + expect(store.getSnapshot()).toEqual({ preference: 'dark', revision: 0 }) + store.actions.sync('light', 2) + expect(store.getSnapshot().preference).toBe('light') + expect(store.getSnapshot().revision).toBe(2) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 3) + store.actions.sync('system', 2) + store.actions.sync('system', 3) + expect(store.getSnapshot().preference).toBe('dark') + expect(store.getSnapshot().revision).toBe(3) + }) +}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 126957c5eb..c853c9fd67 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,61 +1,151 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' -import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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]) + }) + + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { theme } = make() + expect(theme.getTheme().preference).toBe('system') + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') + } finally { + vi.unstubAllGlobals() + } + }) + + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { + type Listener = () => void + const stubMedia = (initialMatches: boolean) => { + const listeners = new Set() + const media = { + matches: initialMatches, + addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) }, + removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) }, + flip() { + this.matches = !this.matches + for (const fn of listeners) fn() + }, + listenerCount: () => listeners.size, + } + vi.stubGlobal('matchMedia', () => media) + return media + } + + afterEach(() => { vi.unstubAllGlobals() }) + + it('system resolves against the media query and follows OS flips', () => { + const media = stubMedia(true) + const { theme, events } = make() + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().active.id).toBe('dark') + media.flip() + expect(theme.getTheme().active.id).toBe('light') + expect(events).toHaveLength(1) + }) + + it('OS flips do not republish while a concrete preference is set', () => { + const media = stubMedia(false) + const { theme, events } = make() + theme.setTheme('light') + expect(events).toHaveLength(1) + media.flip() + expect(events).toHaveLength(1) + expect(theme.getTheme().active.id).toBe('light') + }) + + it('context dispose releases the media listener', async () => { + const media = stubMedia(false) + const { ctx } = make() + expect(media.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(media.listenerCount()).toBe(0) + }) }) }) diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 51f9171643..7d5cc6f235 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,18 @@ "src" ], "references": [ + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, { "path": "../../../vendor/cordis" }, 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 2492b193a6..1946a841bb 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 @@ -140,9 +140,18 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../packages/client/ui-models '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../packages/client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../packages/client/ui-settings-general '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../packages/client/ui-sidebar @@ -746,18 +755,29 @@ 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: + packages/client/locale: + devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime - devDependencies: + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/modules: devDependencies: @@ -844,12 +864,45 @@ importers: packages/client/ui-layout: 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-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-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 @@ -943,6 +996,67 @@ 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-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: + 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-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: @@ -987,13 +1101,35 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/client/ui-theme: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-trajectory: devDependencies: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d68df7adcd..4824138f83 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,7 +59,10 @@ 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/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, '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..378b5fb437 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-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 17f34b601f..afadb08b14 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-models" }, + { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } ]