Procházet zdrojové kódy

fix(web): persist theme preference in settings

Yichen Jiang před 1 měsícem
rodič
revize
dd473870dd
30 změnil soubory, kde provedl 690 přidání a 119 odebrání
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml
  2. 39 0
      .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md
  3. 39 0
      .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md
  4. 3 1
      apps/web/tests/scaffold.ts
  5. 31 8
      apps/web/tests/settings-chrome.e2e.ts
  6. 2 2
      docs/event-producer-consumer.md
  7. 2 1
      docs/module-graph.md
  8. 2 2
      packages/client/ui-theme/README.i18n.yaml
  9. 1 1
      packages/client/ui-theme/README.md
  10. 1 1
      packages/client/ui-theme/README.zh.md
  11. 6 1
      packages/client/ui-theme/package.json
  12. 1 1
      packages/client/ui-theme/src/client/AppearanceRow.tsx
  13. 71 46
      packages/client/ui-theme/src/client/index.ts
  14. 1 1
      packages/client/ui-theme/src/client/settings-store.ts
  15. 100 0
      packages/client/ui-theme/src/client/theme-settings.ts
  16. 34 3
      packages/client/ui-theme/src/index.ts
  17. 4 4
      packages/client/ui-theme/src/invariant.ts
  18. 22 0
      packages/client/ui-theme/src/theme-settings.ts
  19. 61 5
      packages/client/ui-theme/tests/apply.spec.ts
  20. 30 0
      packages/client/ui-theme/tests/host.spec.ts
  21. 11 4
      packages/client/ui-theme/tests/invariant.spec.ts
  22. 149 0
      packages/client/ui-theme/tests/theme-settings.spec.ts
  23. 31 28
      packages/client/ui-theme/tests/theme.spec.ts
  24. 6 0
      packages/client/ui-theme/tsconfig.json
  25. 2 2
      packages/host/apiproxy/README.i18n.yaml
  26. 0 0
      packages/host/apiproxy/README.md
  27. 0 0
      packages/host/apiproxy/README.zh.md
  28. 1 1
      packages/host/apiproxy/src/api-proxy.ts
  29. 25 7
      packages/host/apiproxy/tests/api-proxy-config.spec.ts
  30. 9 0
      pnpm-lock.yaml

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.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 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md
+2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7
+2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md

@@ -0,0 +1,39 @@
+# Agent Note: Persist the Web theme through Host settings
+
+Status: implemented
+
+English | [中文](2026-08-06-host-backed-web-theme-preference.zh.md)
+
+## Problem
+
+The Web theme preference lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different storage partition and returned to the default system theme even though both processes used the same DSH home.
+
+The theme is a user-level product preference rather than page-local state. DSH already has a user-settings service with a file-backed provider, a loopback-only configuration wire, and invalidation frames for external edits and other tabs.
+
+## Decision
+
+The `@deepseek-ai/dsh-client-ui-theme` Host half registers `ui-theme.preference` with the built-in `light`, `dark`, and `system` values and a `system` default. The local settings provider stores an override in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home.
+
+The loopback client loads that namespace before it provides `ThemeService`, so the initial presenter snapshot reflects the durable preference without relying on an origin cache. `ThemeService.setTheme` still changes the live snapshot synchronously; its persistence callback sends a `settings.mutate` path operation. The controller serializes rapid selections in gesture order, ignores stale settlements, reloads after a rejected latest write, and refetches on `settings/changed` or `connection/reset`.
+
+The API proxy explicitly exposes `ui-theme` beside `permission` and `ui-onboarding`. Registration alone remains insufficient to cross the configuration boundary. Remote browsers cannot call the privileged settings API and retain only a process-local selection.
+
+Only the built-in product preferences cross the Host schema. Third-party registered theme ids remain an in-process extension because the Host cannot validate a browser plugin's dynamic registry during startup.
+
+## Alternatives considered
+
+**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host-side relay would recreate a settings service around a browser-specific format.
+
+**Use a cookie without an explicit port.** Cookies would couple preference durability to the served hostname, still split localhost aliases, and introduce HTTP state outside the user-settings ownership model.
+
+**Mirror Host settings into `localStorage`.** A second authority creates boot and invalidation conflict rules while retaining the origin partition that caused the defect. The Host document is the sole durable source.
+
+**Expose every registered settings namespace.** Automatic exposure would let an unrelated plugin become remotely configurable by registering with the general settings seam. The API proxy keeps an explicit allowlist.
+
+## Consequences
+
+Theme selections follow the DSH user home across reloads, ports, and loopback origins, and direct edits to `settings.yaml` converge through the existing invalidation stream. The settings document contains a readable section such as `ui-theme: { preference: dark }`; no theme value is written to `localStorage`.
+
+Startup performs one loopback settings read before publishing the theme service. A transient read failure keeps the system default or last good in-process value and reconnect can retry. A write rejection can visibly restore the durable preference after the immediate theme change.
+
+Unit coverage pins schema registration, ordered writes, stale-response containment, failure recovery, invalidation refresh, and remote memory mode. The real Web settings scenario writes dark through the UI, verifies the YAML document, reloads, and boots a second Host on another port against the same DSH home with an empty theme `localStorage` partition.

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 通过 Host settings 持久化 Web 主题
+
+Status: implemented
+
+[English](2026-08-06-host-backed-web-theme-preference.md) | 中文
+
+## 问题
+
+Web 主题偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区,并回到默认的系统主题,即使两个进程使用同一个 DSH home。
+
+主题是用户级产品偏好,而非页面局部状态。DSH 已有用户 settings 服务及其基于文件的提供方,也已有仅限回环请求的配置协议,并为外部编辑和其他标签页提供失效帧。
+
+## 决策
+
+`@deepseek-ai/dsh-client-ui-theme` 的 Host half 注册 `ui-theme.preference`,可取内置值 `light`、`dark` 与 `system`,默认值为 `system`。本地 settings 提供方将覆盖值存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。
+
+来自回环地址的客户端会在提供 `ThemeService` 之前加载该 namespace,因此初始呈现器快照会反映持久化偏好,无需依赖按 origin 划分的缓存。`ThemeService.setTheme` 仍会同步更新实时快照;它的持久化回调会发送一项 `settings.mutate` 路径操作。控制器按操作顺序串行处理连续快速选择,忽略陈旧操作的结算结果,在最新写入被拒后重新加载持久化值,并在发生 `settings/changed` 或 `connection/reset` 时重新拉取。
+
+API 代理会显式暴露 `ui-theme`,与 `permission` 和 `ui-onboarding` 并列。仅注册该设置,仍不足以跨越配置边界。远程浏览器无法调用特权 settings API,其主题选择仅保留在进程内。
+
+只有产品内置偏好才会跨越 Host schema。第三方注册的主题 id 仍是进程内扩展,因为 Host 无法在启动期间校验浏览器插件的动态注册表。
+
+## 曾考虑的替代方案
+
+**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 侧中继会围绕浏览器特有格式重新实现一套 settings 服务。
+
+**使用不显式包含端口的 cookie。** Cookie 会将偏好的持久性与提供服务的 hostname 耦合,localhost 的不同 alias 仍会各自分区,还会在用户 settings 的所有权模型之外引入 HTTP 状态。
+
+**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会导致启动与失效时需要另外定义冲突规则,同时依然保留造成该缺陷的 origin 分区。Host 侧 settings 文档是唯一的持久化真源。
+
+**暴露所有已注册的 settings namespace。** 自动暴露会让与本功能无关的插件仅凭向通用 settings seam 注册,就成为可远程配置的插件。API 代理保留一份显式 allowlist。
+
+## 后果
+
+主题选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin;直接编辑 `settings.yaml` 所产生的变更也会通过现有失效流收敛。settings 文档包含形如 `ui-theme: { preference: dark }` 的可读分节;不会向 `localStorage` 写入主题值。
+
+启动时会在发布主题服务之前执行一次回环 settings 读取。短暂的读取失败会保留系统默认值或上一个正确的进程内值,并可在重连时重试。写入被拒时,界面可能会在主题立即变化后明显恢复为持久化偏好。
+
+单元测试覆盖 schema 注册、有序写入、陈旧响应隔离、故障恢复、失效刷新与远程端仅内存模式。真实 Web settings 场景通过 UI 写入 dark,校验 YAML 文档,重新加载,再使用同一个 DSH home 在另一个端口上启动第二个 Host,此时主题 `localStorage` 分区为空。

+ 3 - 1
apps/web/tests/scaffold.ts

@@ -185,6 +185,8 @@ export interface LaunchOptions {
    * 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
    */
   remoteAuthority?: string
+  /** Reuse an existing harness home so a second Host can verify user settings across origins. */
+  harnessHome?: string
 }
 
 /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -231,7 +233,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   // Isolated harness home: the settings/credentials rows resolve $DSH_HOME
   // paths at load, and an in-process boot must NEVER touch the developer's
   // real ~/.dsh document or credential file.
-  const harnessHome = join(workspaceCwd, '.dsh-home')
+  const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
   let persistenceRoot: string
   try {
     persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))

+ 31 - 8
apps/web/tests/settings-chrome.e2e.ts

@@ -1,6 +1,6 @@
 // Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
 // section switching, both close paths), the Appearance preference row (the
-// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
+// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings
 // -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
 // the Language row (settings-scoped localization + persisted dsh.locale),
 // the busy-state Enter preference, plus Permission as the persisted default
@@ -152,13 +152,13 @@ describe('web e2e: settings modal and General preferences', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('flips the theme through the Appearance cubes and persists across reload', async () => {
+  it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
-    const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
-      await page.evaluate(() => ({
+    const readState = async (target: Page = page): Promise<{ attr: boolean; token: string; legacy: string | null }> =>
+      await target.evaluate(() => ({
         attr: document.body.hasAttribute('data-ds-dark-theme'),
         token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
-        stored: localStorage.getItem('dsh.theme'),
+        legacy: localStorage.getItem('dsh.theme'),
       }))
     // Pin the OS scheme to light so the default `system` preference resolves
     // light and the dark flip below is unambiguously the gesture's doing.
@@ -172,13 +172,15 @@ describe('web e2e: settings modal and General preferences', () => {
     const darkCube = dialog.getByRole('button', { name: '深色' })
     expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
     await darkCube.click()
-    // The full cascade: pressed state, persisted preference, body attribute,
+    // The full cascade: pressed state, Host-backed preference, body attribute,
     // alias token flip — all from one real user gesture.
     await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
     const dark = await readState()
     expect(dark.attr).toBe(true)
-    expect(dark.stored).toBe('dark')
+    expect(dark.legacy).toBeNull()
     expect(dark.token).not.toBe(light.token)
+    await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-theme:\n\s+preference: dark/)
     await page.keyboard.press('Escape')
 
     // Reload: the preference survives boot (restore + presenter initial apply).
@@ -189,7 +191,28 @@ describe('web e2e: settings modal and General preferences', () => {
     await page.emulateMedia({ colorScheme: 'light' })
     const reloaded = await readState()
     expect(reloaded.attr).toBe(true)
-    expect(reloaded.stored).toBe('dark')
+    expect(reloaded.legacy).toBeNull()
+
+    // A second live Host binds another ephemeral port but shares the same
+    // user-settings home. Its fresh origin has no theme localStorage and must
+    // still render dark before the settings dialog opens.
+    const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
+    const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    const secondTripwire = watchConsole(secondPage)
+    try {
+      expect(second.baseUrl).not.toBe(scaffold.baseUrl)
+      await secondPage.emulateMedia({ colorScheme: 'light' })
+      await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
+      await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      const crossPort = await readState(secondPage)
+      expect(crossPort.attr).toBe(true)
+      expect(crossPort.legacy).toBeNull()
+      expect(secondTripwire.pageErrors).toEqual([])
+      expect(secondTripwire.warnings).toEqual([])
+    } finally {
+      await secondPage.close()
+      await second.close()
+    }
 
     // `system` follows the emulated OS scheme (dark stays dark, light clears).
     await page.getByRole('button', { name: '设置', exact: true }).click()

+ 2 - 2
docs/event-producer-consumer.md

@@ -62,14 +62,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | `commands/changed` | `runtime` (`emit`) | `ui-command` |
-| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
+| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` |
 | `credentials/changed` | `runtime` (`emit`) | `ui-models` |
 | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`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), [`session-title`](../packages/session-title/session-title), [`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`, `loader`, `modules`, `webserver` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 | `locale/change` | `locale` (`emit`) | `locale` |
 | `models/changed` | `runtime` (`emit`) | `ui-models` |
-| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
+| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` |
 | `slash/input-begin-command` | - | `ui-conversation` |
 | `slash/input-consume-token` | - | `ui-conversation` |
 | `slash/input-insert-reference` | - | `ui-conversation` |

+ 2 - 1
docs/module-graph.md

@@ -399,6 +399,7 @@ flowchart TD
   pkg_client_ui_slash --> pkg_client_ui_primitives
   pkg_client_ui_slash --> pkg_client_ui_slots
   pkg_client_ui_slash --> pkg_invariants
+  pkg_client_ui_theme --> pkg_client_connection
   pkg_client_ui_theme --> pkg_client_locale
   pkg_client_ui_theme --> pkg_client_runtime
   pkg_client_ui_theme --> pkg_client_ui_primitives
@@ -1150,7 +1151,7 @@ flowchart TD
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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) |
 | [`client-ui-slash`](../packages/client/ui-slash) | `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) |
-| [`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) |
+| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`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) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `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) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
 | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |

+ 2 - 2
packages/client/ui-theme/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/client/ui-theme/README.md
-README.md: 88e21fe214ec806b101050949690283d811be36d
-README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50
+README.md: 32868bcac4313a3badfe92dbf41c84e793f09709
+README.zh.md: a38765b8004826133875c38deeb66128d52ec986

+ 1 - 1
packages/client/ui-theme/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-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 (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
+Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), 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 (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser loads `ui-theme.preference` before providing the service and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary.
 
 `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
 

+ 1 - 1
packages/client/ui-theme/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。
+主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。
 
 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
 

+ 6 - 1
packages/client/ui-theme/package.json

@@ -25,6 +25,7 @@
   },
   "dshClient": {
     "inject": [
+      "@deepseek-ai/dsh-client-connection",
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-locale"
     ],
@@ -33,6 +34,7 @@
   },
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@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",
@@ -42,6 +44,7 @@
     "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -64,6 +67,8 @@
     "watch": "tsdown --watch"
   },
   "dependencies": {
-    "clsx": "^2.0.0"
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "clsx": "^2.0.0",
+    "schemastery": "^3.18.0"
   }
 }

+ 1 - 1
packages/client/ui-theme/src/client/AppearanceRow.tsx

@@ -10,7 +10,7 @@ import {
   IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ThemePreference } from './index.ts'
+import type { ThemePreference } from '../theme-settings.ts'
 import type { ThemeKey } from './locales.ts'
 import type {} from './settings-contract.ts'
 import type { createAppearanceRowStore } from './settings-store.ts'

+ 71 - 46
packages/client/ui-theme/src/client/index.ts

@@ -1,12 +1,14 @@
 /**
  * Browser theme registry over the `--dsw-*` token stylesheets. The service
- * owns the theme preference (light/dark/system), resolves `system` through
+ * owns the live 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.
+ * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host
+ * settings controller loads and stores the preference in the user-settings
+ * document. The plugin also registers the Appearance preference row into the
+ * settings General section — the theme feature owns its own settings surface.
  */
 import type { Context } from 'cordis'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -14,11 +16,22 @@ 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'
+import { ThemeSettingsController } from './theme-settings.ts'
 import { en, zh, type ThemeKey } from './locales.ts'
+import {
+  DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE,
+  type ThemePreference,
+} from '../theme-settings.ts'
 
 export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
 export type { AppearanceRowState } from './settings-store.ts'
+export type { ThemePreferenceTarget } from './theme-settings.ts'
+export { ThemeSettingsController } from './theme-settings.ts'
 export type { ThemeKey } from './locales.ts'
+export {
+  DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
+  type ThemePreference,
+} from '../theme-settings.ts'
 
 /** Namespace owning this feature's settings-row copy. */
 export const SETTINGS_NS = 'settings.theme'
@@ -33,9 +46,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
 export type ThemeTokens = Record<string, string>
 
-/** Theme preference: a concrete theme id or follow-the-OS. */
-export type ThemePreference = 'light' | 'dark' | 'system'
-
 /** One selectable theme: id, dark/light semantics, and alias-token overrides. */
 export interface ThemeDefinition {
   /** Theme id (the setTheme argument for concrete themes). */
@@ -76,12 +86,6 @@ declare module 'cordis' {
   }
 }
 
-/** 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({}) }),
@@ -103,14 +107,17 @@ export class ThemeService {
   private revision = 0
   private snapshot: ThemeSnapshot
   private readonly media: MediaQueryList | undefined
+  private persist: (preference: ThemePreference) => void
 
   /**
    * @param ctx - owning context (change events are emitted on it; the
    * media-query listener is released through ctx.effect on dispose).
+   * @param persist - durable write callback for built-in preferences.
    */
-  constructor(ctx: Context) {
+  constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) {
     this.ctx = ctx
-    this.preference = restorePreference()
+    this.persist = persist
+    this.preference = DEFAULT_PREFERENCE
     // 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()
@@ -136,8 +143,17 @@ export class ThemeService {
   }
 
   /**
-   * Switch the theme preference — the only preference write entry. Persists
-   * the preference and emits `theme/change`.
+   * Bind the owning plugin's durable writer before the service is provided.
+   * @param persist - callback accepting built-in preference changes.
+   */
+  bindPersistence(persist: (preference: ThemePreference) => void): void {
+    this.persist = persist
+  }
+
+  /**
+   * Switch the theme preference — the only user preference write entry.
+   * Built-in preferences are persisted and every accepted value emits
+   * `theme/change`.
    * @param id - a registered theme id or `system`; unknown ids throw.
    */
   setTheme(id: string): void {
@@ -146,7 +162,17 @@ export class ThemeService {
     }
     if (this.preference === id) return
     this.preference = id as ThemePreference
-    persistPreference(this.preference)
+    if (isThemePreference(id)) this.persist(id)
+    this.publish()
+  }
+
+  /**
+   * Apply a preference read from Host settings without writing it back.
+   * @param preference - validated durable preference.
+   */
+  syncPreference(preference: ThemePreference): void {
+    if (this.preference === preference) return
+    this.preference = preference
     this.publish()
   }
 
@@ -170,7 +196,7 @@ export class ThemeService {
       this.themes = this.themes.filter(t => t.id !== definition.id)
       if (this.preference === definition.id) {
         this.preference = DEFAULT_PREFERENCE
-        persistPreference(this.preference)
+        this.persist(this.preference)
       }
       this.publish()
     }
@@ -200,32 +226,8 @@ export class ThemeService {
   }
 }
 
-/** 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']
+/** Required services: settings transport plus slots/locale for the Appearance row. */
+export const inject = ['slots', 'locale', 'connection']
 
 /**
  * Client plugin body: provide the theme service and register the
@@ -233,10 +235,33 @@ export const inject = ['slots', 'locale']
  * slot (a feature owns its settings surface).
  * @param ctx - client cordis context.
  */
-export function apply(ctx: ClientContext): void {
+export async function apply(ctx: ClientContext): Promise<void> {
+  const connection = ctx.get('connection') as ConnectionHandle
   const theme = new ThemeService(ctx)
+  const controller = new ThemeSettingsController(
+    connection.api,
+    theme,
+    connection.isLoopback ? 'host' : 'memory',
+  )
+  theme.bindPersistence((preference) => { void controller.persist(preference) })
+  await controller.load()
   ctx.provide('theme', theme)
 
+  ctx.effect(() => {
+    const refresh = (ns?: string): void => {
+      if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return
+      void controller.load()
+    }
+    const disposers = [
+      ctx.on('settings/changed', refresh),
+      ctx.on('connection/reset', () => { refresh() }),
+    ]
+    return () => {
+      controller.dispose()
+      for (const dispose of disposers) dispose()
+    }
+  }, 'ui-theme: settings invalidations')
+
   ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
 
   const store = createAppearanceRowStore()

+ 1 - 1
packages/client/ui-theme/src/client/settings-store.ts

@@ -4,7 +4,7 @@
  * reads via props.useStore.
  */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
-import type { ThemePreference } from './index.ts'
+import type { ThemePreference } from '../theme-settings.ts'
 
 /** Store state mirrored from the theme snapshot. */
 export interface AppearanceRowState {

+ 100 - 0
packages/client/ui-theme/src/client/theme-settings.ts

@@ -0,0 +1,100 @@
+/** Host-backed persistence controller for the browser theme preference. */
+
+import type {
+  IApiClient, SettingsNamespaceView,
+} from '@deepseek-ai/dsh-client-connection/client'
+import {
+  THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference,
+  type ThemePreference,
+} from '../theme-settings.ts'
+
+/** Preference target implemented by {@link ThemeService}. */
+export interface ThemePreferenceTarget {
+  /**
+   * Apply a Host value without writing it back.
+   * @param preference - validated durable preference.
+   */
+  syncPreference(preference: ThemePreference): void
+}
+
+function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined {
+  if (typeof view.value !== 'object' || view.value === null) return undefined
+  const preference = (view.value as Record<string, unknown>)[THEME_PREFERENCE_FIELD]
+  return isThemePreference(preference) ? preference : undefined
+}
+
+/** Coordinates startup reads, ordered writes, and pushed invalidations. */
+export class ThemeSettingsController {
+  private generation = 0
+  private writeTail: Promise<void> = Promise.resolve()
+
+  /**
+   * @param api - settings wire face.
+   * @param target - live theme service receiving durable values.
+   * @param persistence - remote browsers stay process-local because the settings API is loopback-only.
+   */
+  constructor(
+    private readonly api: Pick<IApiClient, 'settings'>,
+    private readonly target: ThemePreferenceTarget,
+    private readonly persistence: 'host' | 'memory' = 'host',
+  ) {}
+
+  /**
+   * Load the durable preference after earlier writes settle; the latest operation wins.
+   * @returns nothing; an unavailable or invalid descriptor leaves the last good value active.
+   */
+  async load(): Promise<void> {
+    const generation = ++this.generation
+    if (this.persistence === 'memory') return
+    await this.writeTail
+    if (generation !== this.generation) return
+    let response: Awaited<ReturnType<Pick<IApiClient, 'settings'>['settings']['describe']>>
+    try {
+      response = await this.api.settings.describe({})
+    } catch (_settingsReadFailure) {
+      // A transport failure leaves the last good in-process theme active. A
+      // connection/reset or settings/changed notification retries the read.
+      return
+    }
+    if (!response.result.ok || generation !== this.generation) return
+    const view = response.result.value.namespaces.find(
+      candidate => candidate.ns === THEME_SETTINGS_NAMESPACE,
+    )
+    if (view === undefined) return
+    const preference = preferenceOf(view)
+    if (preference !== undefined) this.target.syncPreference(preference)
+  }
+
+  /**
+   * Persist one user selection. Writes are serialized so rapid picks land in
+   * gesture order; a rejected latest write reloads the durable value.
+   * @param preference - selected built-in preference.
+   * @returns nothing after the write or recovery read settles.
+   */
+  async persist(preference: ThemePreference): Promise<void> {
+    const generation = ++this.generation
+    if (this.persistence === 'memory') return
+    const write = this.writeTail.then(async () => {
+      const response = await this.api.settings.mutate({
+        ns: THEME_SETTINGS_NAMESPACE,
+        ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }],
+      })
+      if (!response.result.ok) throw new Error(response.result.error.message)
+      if (generation === this.generation) {
+        const accepted = preferenceOf(response.result.value)
+        if (accepted !== undefined) this.target.syncPreference(accepted)
+      }
+    })
+    this.writeTail = write.catch(() => {})
+    try {
+      await write
+    } catch {
+      if (generation === this.generation) await this.load()
+    }
+  }
+
+  /** Prevent in-flight reads and writes from publishing after plugin disposal. */
+  dispose(): void {
+    this.generation += 1
+  }
+}

+ 34 - 3
packages/client/ui-theme/src/index.ts

@@ -1,4 +1,35 @@
-/** Host loader entry for the browser implementation exported from `./client`. */
+/** Host registration for the browser theme preference. */
 
-/** Host plugin body — no host-side behavior for the theme plugin. */
-export function apply(): void {}
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import {
+  DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
+  type ThemePreference,
+} from './theme-settings.ts'
+
+export {
+  DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
+  type ThemePreference,
+} from './theme-settings.ts'
+
+interface ThemeSettings {
+  preference: ThemePreference
+}
+
+const ThemeSettingsSchema: z<ThemeSettings> = z.object({
+  [THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE),
+})
+
+/**
+ * Register the durable theme section when a settings provider exists.
+ * @param ctx - Host context whose optional settings service owns the section.
+ */
+export function apply(ctx: Context): void {
+  ctx.inject(['settings'], (settingsCtx) => {
+    settingsCtx.settings.register(
+      settingsNamespace(THEME_SETTINGS_NAMESPACE),
+      ThemeSettingsSchema,
+    )
+  })
+}

+ 4 - 4
packages/client/ui-theme/src/invariant.ts

@@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant'
 export const inject = ['invariants']
 
 /**
- * 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.
+ * No runtime invariant: the settings seam validates and publishes the durable
+ * theme section, while the registry emits `theme/change` synchronously with
+ * its own mutations. Store/registry agreement is covered directly by this
+ * package's Host, controller, and service behavior specs.
  */
 const install: InvariantInstaller = () => {}
 

+ 22 - 0
packages/client/ui-theme/src/theme-settings.ts

@@ -0,0 +1,22 @@
+/** Theme preferences stored in the Host user-settings document. */
+
+/** Settings namespace owned by the theme plugin. */
+export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
+
+/** Field carrying the selected built-in theme preference. */
+export const THEME_PREFERENCE_FIELD = 'preference'
+
+/** Theme preference persisted by the product Appearance row. */
+export type ThemePreference = 'light' | 'dark' | 'system'
+
+/** Default preference when the user-settings document has no override. */
+export const DEFAULT_PREFERENCE: ThemePreference = 'system'
+
+/**
+ * Narrow one wire or registry value to a persistable preference.
+ * @param value - value crossing the settings or registry boundary.
+ * @returns whether the value is a built-in preference.
+ */
+export function isThemePreference(value: unknown): value is ThemePreference {
+  return value === 'light' || value === 'dark' || value === 'system'
+}

+ 61 - 5
packages/client/ui-theme/tests/apply.spec.ts

@@ -2,11 +2,13 @@
  * 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 { 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 { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
-import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
+import {
+  apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE,
+} 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'
@@ -17,12 +19,39 @@ usePinnedBrowserLanguages('zh-CN')
 
 const SLOT = 'settings.general.item'
 
-async function bench() {
+async function bench(isLoopback = true) {
   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 }
+  let preference = 'system'
+  const namespace = () => ({
+    ns: THEME_SETTINGS_NAMESPACE,
+    schema: {},
+    value: { preference },
+    applies: 'live' as const,
+    secrets: [],
+    revision: 0,
+  })
+  const describe = vi.fn(() => Promise.resolve({
+    rpcId: 'theme-describe' as never,
+    result: {
+      ok: true as const,
+      value: { writable: true, hasDocument: true, namespaces: [namespace()] },
+    },
+  }))
+  const mutate = vi.fn((request: { ops: { value: string }[] }) => {
+    preference = request.ops[0]!.value
+    return Promise.resolve({
+      rpcId: 'theme-mutate' as never,
+      result: { ok: true as const, value: namespace() },
+    })
+  })
+  ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
+  return {
+    ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
+    setHostPreference: (next: string) => { preference = next },
+  }
 }
 
 /** Stand in for the settings shell: declare the General item slot from root. */
@@ -45,7 +74,7 @@ function faceOf(slots: SlotsService) {
 
 describe('ui-theme apply', () => {
   it('declares the slot and locale services', () => {
-    expect(inject).toEqual(['slots', 'locale'])
+    expect(inject).toEqual(['slots', 'locale', 'connection'])
   })
 
   it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
@@ -84,6 +113,33 @@ describe('ui-theme apply', () => {
     face.setTheme('system')
     expect(theme.getTheme().preference).toBe('system')
     expect(instance.getSnapshot().preference).toBe('system')
+    await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
+  })
+
+  it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => {
+    const b = await bench()
+    b.setHostPreference('dark')
+    declareItems(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const theme = b.ctx.get('theme') as ThemeService
+    expect(theme.getTheme().preference).toBe('dark')
+    b.ctx.emit('settings/changed', 'unrelated')
+    expect(b.describe).toHaveBeenCalledOnce()
+    b.setHostPreference('light')
+    b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE)
+    await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
+    b.setHostPreference('dark')
+    b.ctx.emit('connection/reset')
+    await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
+
+    const remote = await bench(false)
+    declareItems(remote.slots)
+    await remote.ctx.plugin({ inject: [...inject], apply }).await()
+    const remoteTheme = remote.ctx.get('theme') as ThemeService
+    remoteTheme.setTheme('dark')
+    await Promise.resolve()
+    expect(remote.describe).not.toHaveBeenCalled()
+    expect(remote.mutate).not.toHaveBeenCalled()
   })
 
   it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

+ 30 - 0
packages/client/ui-theme/tests/host.spec.ts

@@ -0,0 +1,30 @@
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import {
+  DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
+} from '@deepseek-ai/dsh-client-ui-theme'
+
+class MemorySettings extends Settings {
+  readonly writable = true
+  protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
+  protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
+    return Promise.resolve()
+  }
+}
+
+describe('ui-theme host', () => {
+  it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
+    const ctx = new Context()
+    await ctx.plugin(MemorySettings).await()
+    const fiber = ctx.plugin({ apply })
+    await fiber.await()
+    const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE)
+    expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE })
+    await ctx.settings.update(ns, { preference: 'dark' })
+    expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' })
+    await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow()
+    await fiber.dispose()
+    expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
+  })
+})

+ 11 - 4
packages/client/ui-theme/tests/invariant.spec.ts

@@ -15,18 +15,25 @@ describe('invariant companion', () => {
     await expect(ctx.plugin(ThemeInvariant).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('node-half waits for an optional settings provider', () => {
+    nodeApply(new Context())
+    expect(true).toBe(true)
   })
 
   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'])
+    expect(inject).toEqual(['slots', 'locale', 'connection'])
     const ctx = new Context()
     new SlotsService(ctx)
     await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
+    ctx.provide('connection', {
+      api: { settings: { describe: () => Promise.resolve({
+        rpcId: 'theme-invariant' as never,
+        result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } },
+      }) } },
+      isLoopback: true,
+    } as never)
     await ctx.plugin({ inject, apply: clientApply }).await()
     expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
   })

+ 149 - 0
packages/client/ui-theme/tests/theme-settings.spec.ts

@@ -0,0 +1,149 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
+import {
+  THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController,
+  type ThemePreference,
+} from '@deepseek-ai/dsh-client-ui-theme/client'
+
+let rpc = 0
+
+function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } }
+}
+
+function view(preference: unknown = 'system'): SettingsNamespaceView {
+  return {
+    ns: THEME_SETTINGS_NAMESPACE,
+    schema: {},
+    value: { [THEME_PREFERENCE_FIELD]: preference },
+    applies: 'live',
+    secrets: [],
+    revision: 0,
+  }
+}
+
+function described(preference: unknown = 'system') {
+  return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] })
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
+  return { promise, resolve, reject }
+}
+
+function target() {
+  const values: ThemePreference[] = []
+  return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } }
+}
+
+describe('ThemeSettingsController', () => {
+  it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => {
+    const receiver = target()
+    const describe = vi.fn()
+      .mockResolvedValueOnce(described('dark'))
+      .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
+      .mockResolvedValueOnce(described('sepia'))
+      .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] }))
+      .mockResolvedValueOnce({
+        rpcId: 'failed' as never,
+        result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } },
+      })
+      .mockRejectedValueOnce(new Error('transport offline'))
+    const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver)
+    for (let i = 0; i < 6; i++) await controller.load()
+    expect(receiver.values).toEqual(['dark'])
+  })
+
+  it('persists ordered rapid selections and publishes only the latest settlement', async () => {
+    const first = deferred<ReturnType<typeof ok<SettingsNamespaceView>>>()
+    const calls: string[] = []
+    const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
+      const preference = request.ops[0]!.value
+      calls.push(preference)
+      if (preference === 'dark') return first.promise
+      return ok(view(preference))
+    })
+    const receiver = target()
+    const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver)
+    const dark = controller.persist('dark')
+    const light = controller.persist('light')
+    await Promise.resolve()
+    expect(calls).toEqual(['dark'])
+    first.resolve(ok(view('dark')))
+    await Promise.all([dark, light])
+    expect(calls).toEqual(['dark', 'light'])
+    expect(receiver.values).toEqual(['light'])
+    expect(mutate).toHaveBeenNthCalledWith(1, {
+      ns: THEME_SETTINGS_NAMESPACE,
+      ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }],
+    })
+  })
+
+  it('reloads after a rejected latest write and contains stale reads and disposal', async () => {
+    const stale = deferred<ReturnType<typeof described>>()
+    const describe = vi.fn()
+      .mockImplementationOnce(() => stale.promise)
+      .mockResolvedValueOnce(described('system'))
+    const mutate = vi.fn().mockResolvedValue({
+      rpcId: 'rejected' as never,
+      result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } },
+    })
+    const receiver = target()
+    const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver)
+    const oldLoad = controller.load()
+    await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() })
+    await controller.persist('dark')
+    stale.resolve(described('light'))
+    await oldLoad
+    expect(receiver.values).toEqual(['system'])
+
+    const disposedRead = deferred<ReturnType<typeof described>>()
+    describe.mockImplementationOnce(() => disposedRead.promise)
+    const pending = controller.load()
+    controller.dispose()
+    disposedRead.resolve(described('dark'))
+    await pending
+    expect(receiver.values).toEqual(['system'])
+  })
+
+  it('keeps remote-browser persistence in memory without calling Host settings', async () => {
+    const describe = vi.fn()
+    const mutate = vi.fn()
+    const receiver = target()
+    const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory')
+    await controller.load()
+    await controller.persist('dark')
+    expect(describe).not.toHaveBeenCalled()
+    expect(mutate).not.toHaveBeenCalled()
+    expect(receiver.values).toEqual([])
+  })
+
+  it('reloads after a thrown write and ignores a malformed success response', async () => {
+    const receiver = target()
+    const describe = vi.fn().mockResolvedValue(described('light'))
+    const mutate = vi.fn()
+      .mockRejectedValueOnce(new Error('offline'))
+      .mockResolvedValueOnce(ok(view('sepia')))
+    const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver)
+    await controller.persist('dark')
+    await controller.persist('system')
+    expect(receiver.values).toEqual(['light'])
+  })
+
+  it('lets an explicit refresh supersede a stale rejected write', async () => {
+    const rejected = deferred<never>()
+    const receiver = target()
+    const describe = vi.fn().mockResolvedValue(described('system'))
+    const mutate = vi.fn().mockReturnValue(rejected.promise)
+    const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver)
+    const write = controller.persist('dark')
+    await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
+    const refresh = controller.load()
+    rejected.reject(new Error('stale rejection'))
+    await Promise.all([write, refresh])
+    expect(receiver.values).toEqual(['system'])
+    expect(describe).toHaveBeenCalledOnce()
+  })
+})

+ 31 - 28
packages/client/ui-theme/tests/theme.spec.ts

@@ -1,21 +1,22 @@
 // @vitest-environment jsdom
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, 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'
+import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
 
-const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
+const make = (persist = vi.fn()): {
+  ctx: Context
+  theme: ThemeService
+  events: ThemeSnapshot[]
+  persist: typeof persist
+} => {
   const ctx = new Context()
   const events: ThemeSnapshot[] = []
   ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
-  return { ctx, theme: new ThemeService(ctx), events }
+  return { ctx, theme: new ThemeService(ctx, persist), events, persist }
 }
 
 describe('ThemeService', () => {
-  beforeEach(() => {
-    localStorage.clear()
-  })
-
   it('defaults to the system preference resolved against prefers-color-scheme', () => {
     const { theme } = make()
     const snapshot = theme.getTheme()
@@ -26,12 +27,12 @@ describe('ThemeService', () => {
     expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
   })
 
-  it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
-    const { theme, events } = make()
+  it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => {
+    const { theme, events, persist } = 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(persist).toHaveBeenCalledWith('dark')
     expect(events).toHaveLength(1)
     expect(events[0]).toBe(theme.getTheme())
     // The service never touches presentation state.
@@ -39,13 +40,17 @@ describe('ThemeService', () => {
     // Same-value set is a no-op (no extra event).
     theme.setTheme('dark')
     expect(events).toHaveLength(1)
+    expect(persist).toHaveBeenCalledOnce()
   })
 
-  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('syncs a Host preference without writing it back', () => {
+    const { theme, events, persist } = make()
+    theme.syncPreference('dark')
+    expect(theme.getTheme().preference).toBe('dark')
+    expect(events).toHaveLength(1)
+    expect(persist).not.toHaveBeenCalled()
+    theme.syncPreference('dark')
+    expect(events).toHaveLength(1)
   })
 
   it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
@@ -56,7 +61,7 @@ describe('ThemeService', () => {
   })
 
   it('registered themes join the snapshot; disposing the active one resets to default', () => {
-    const { theme, events } = make()
+    const { theme, events, persist } = 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')
@@ -64,7 +69,10 @@ describe('ThemeService', () => {
     dispose()
     expect(theme.getTheme().preference).toBe('system')
     expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
-    expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
+    // Custom ids are in-process extension themes; only the built-in product
+    // preferences cross the Host settings schema.
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith('system')
     // register + set + dispose = three publishes; disposer is idempotent.
     expect(events.length).toBe(3)
     dispose()
@@ -88,16 +96,11 @@ describe('ThemeService', () => {
     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()
-    }
+  it('uses a no-op persistence callback when constructed directly', () => {
+    const ctx = new Context()
+    const theme = new ThemeService(ctx)
+    theme.setTheme('dark')
+    expect(theme.getTheme().preference).toBe('dark')
   })
 
   describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {

+ 6 - 0
packages/client/ui-theme/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../connection"
+    },
     {
       "path": "../locale"
     },
@@ -23,6 +26,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../settings/settings"
+    },
     {
       "path": "../../support/invariants"
     }

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/host/apiproxy/README.md
-README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
-README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
+README.md: c1e818fa8ff52b10e722d9fd450073a6aede85da
+README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/host/apiproxy/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/host/apiproxy/README.zh.md


+ 1 - 1
packages/host/apiproxy/src/api-proxy.ts

@@ -74,7 +74,7 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
 const DEFAULT_MAX_MESSAGES = 50
 
 /** Non-model settings namespaces intentionally served to the Web client. */
-const WEB_SETTINGS_NAMESPACES = ['permission'] as const
+const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] as const
 
 /** Provider work budget: at most 100 calls and 2,000 inspected hits. */
 const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100

+ 25 - 7
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -308,8 +308,8 @@ describe('settings domain', () => {
     // The settings seam is general: any plugin may register a namespace for
     // its own configuration. The Web configuration plane remains opt-in, so a
     // future internal plugin cannot become remotely configurable just by
-    // registering; permission and the product onboarding namespace are the
-    // non-model namespaces intentionally admitted by this surface.
+    // registering; permission, theme, and the product onboarding namespace
+    // are the non-model namespaces intentionally admitted by this surface.
     const ctx = await harness()
     ctx.settings.register(NS, AdapterConfig)
     ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
@@ -318,15 +318,23 @@ describe('settings domain', () => {
     }), {
       base: { defaultPreset: 'read-only' },
     })
+    ctx.settings.register(settingsNamespace('ui-theme'), z.object({
+      preference: z.union(['light', 'dark', 'system']).default('system'),
+    }))
     const api = createApiProxy(ctx, DEFAULTS)
 
     const value = expectOk(await api.settings.describe(request({})))
-    expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
+    expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission', 'ui-theme'])
     const permission = expectOk(await api.settings.mutate(request({
       ns: 'permission',
       ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
     })))
     expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
+    const theme = expectOk(await api.settings.mutate(request({
+      ns: 'ui-theme',
+      ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
+    })))
+    expect(theme.value).toEqual({ preference: 'dark' })
 
     for (const response of [
       await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
@@ -340,19 +348,29 @@ describe('settings domain', () => {
     expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
   })
 
-  it('serves the product onboarding namespace without invalidating the model catalog', async () => {
+  it('serves product preference namespaces without invalidating the model catalog', async () => {
     const ctx = await harness()
     ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
+    ctx.settings.register(settingsNamespace('ui-theme'), z.object({
+      preference: z.union(['light', 'dark', 'system']).default('system'),
+    }))
     const api = createApiProxy(ctx, DEFAULTS)
     expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
-      .toEqual(['ui-onboarding'])
-    const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
+      .toEqual(['ui-onboarding', 'ui-theme'])
+    const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
       expectOk(await api.settings.mutate(request({
         ns: 'ui-onboarding',
         ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
       })))
+      expectOk(await api.settings.mutate(request({
+        ns: 'ui-theme',
+        ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
+      })))
     })
-    expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
+    expect(frames).toEqual([
+      { type: 'host/settings-changed', ns: 'ui-onboarding' },
+      { type: 'host/settings-changed', ns: 'ui-theme' },
+    ])
   })
 
   it('refuses even a model-provider namespace once its directory entry is gone', async () => {

+ 9 - 0
pnpm-lock.yaml

@@ -2130,10 +2130,19 @@ importers:
 
   packages/client/ui-theme:
     dependencies:
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
+      schemastery:
+        specifier: ^3.18.0
+        version: link:../../../vendor/schemastery
     devDependencies:
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů