fix(web): persist theme preference in settings

This commit is contained in:
Yichen Jiang
2026-08-06 20:27:31 +08:00
parent 6a32047e77
commit dd473870dd
30 changed files with 692 additions and 121 deletions

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好`light``dark``system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-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` 事件通知变化;它绝不接触 DOMui-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` 之后。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

@@ -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)', () => {

View File

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