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