Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
This commit is contained in:
@@ -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: 2d576cc16b0b8fef453ad54cacd9cb195dc75e7a
|
||||
README.zh.md: b1f17c65749a94f1f48b5f7af9c8b18a5d85eb76
|
||||
README.md: 7b81728b02c0dadcbd134a4bca20c49721a3dd52
|
||||
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
|
||||
|
||||
@@ -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).
|
||||
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 provides the service immediately with `system`, then loads `ui-theme.preference` in the background 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 with namespace revisions, 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; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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.
|
||||
|
||||
|
||||
@@ -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 内联变量)。
|
||||
主题插件:基于 --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 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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` 之后。
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-theme",
|
||||
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-theme"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -23,22 +30,26 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,7 +60,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
@@ -64,6 +75,8 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
/**
|
||||
* 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 scope 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 { Context } from '@deepseek-ai/cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
export type { AppearanceRowState } from './settings-store.ts'
|
||||
export type { ThemeKey } from './locales.ts'
|
||||
export type { ThemePreference, ThemeSettings } from '../theme-settings.ts'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.theme'
|
||||
@@ -33,9 +41,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). */
|
||||
@@ -61,7 +66,7 @@ export interface ThemeSnapshot {
|
||||
revision: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
theme: ThemeService
|
||||
}
|
||||
@@ -76,12 +81,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({}) }),
|
||||
@@ -98,6 +97,7 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
*/
|
||||
export class ThemeService {
|
||||
private readonly ctx: Context
|
||||
private readonly host: SettingsScope<ThemeSettings>
|
||||
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
|
||||
private preference: ThemePreference
|
||||
private revision = 0
|
||||
@@ -106,11 +106,13 @@ export class ThemeService {
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the
|
||||
* media-query listener is released through ctx.effect on dispose).
|
||||
* media-query and scope listeners are released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the same plugin.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, host: SettingsScope<ThemeSettings>) {
|
||||
this.ctx = ctx
|
||||
this.preference = restorePreference()
|
||||
this.host = host
|
||||
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()
|
||||
@@ -125,6 +127,8 @@ export class ThemeService {
|
||||
return () => { media.removeEventListener('change', onChange) }
|
||||
}, 'ui-theme: prefers-color-scheme listener')
|
||||
}
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption')
|
||||
this.adopt()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +140,9 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the theme preference — the only preference write entry. Persists
|
||||
* the preference and emits `theme/change`.
|
||||
* Switch the theme preference — the only user preference write entry.
|
||||
* Built-in preferences are written through the settings scope and every
|
||||
* accepted value emits `theme/change`.
|
||||
* @param id - a registered theme id or `system`; unknown ids throw.
|
||||
*/
|
||||
setTheme(id: string): void {
|
||||
@@ -146,7 +151,15 @@ export class ThemeService {
|
||||
}
|
||||
if (this.preference === id) return
|
||||
this.preference = id as ThemePreference
|
||||
persistPreference(this.preference)
|
||||
if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id)
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Adopt the scope's accepted durable preference without writing it back. */
|
||||
private adopt(): void {
|
||||
const section = this.host.getSnapshot().value
|
||||
if (section === undefined || this.preference === section.preference) return
|
||||
this.preference = section.preference
|
||||
this.publish()
|
||||
}
|
||||
|
||||
@@ -170,7 +183,6 @@ 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.publish()
|
||||
}
|
||||
@@ -200,32 +212,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
|
||||
@@ -234,7 +222,8 @@ export const inject = ['slots', 'locale']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
const host = bindSettingsScope<ThemeSettings>(ctx, { namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const theme = new ThemeService(ctx, host)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** 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 '@deepseek-ai/cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
/**
|
||||
* 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,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-theme'
|
||||
@@ -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 scope 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, scope, and service behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
38
packages/client/ui-theme/src/theme-settings.ts
Normal file
38
packages/client/ui-theme/src/theme-settings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Theme preferences stored in the Host user-settings document. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Built-in preferences accepted at the registry and settings boundaries. */
|
||||
export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const
|
||||
|
||||
/** 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 = typeof THEME_PREFERENCES[number]
|
||||
|
||||
/** Default preference when the user-settings document has no override. */
|
||||
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
|
||||
/** Durable theme section shared by the Host schema and the browser scope. */
|
||||
export interface ThemeSettings {
|
||||
/** Selected built-in preference. */
|
||||
preference: ThemePreference
|
||||
}
|
||||
|
||||
/** Durable theme schema; also the wire envelope the browser scope validates against. */
|
||||
export const ThemeSettingsSchema: z<ThemeSettings> = z.object({
|
||||
[THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE),
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 THEME_PREFERENCES.some(preference => preference === value)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/** ui-theme apply wiring: service provision, settings dictionaries riding the
|
||||
* locale service, declaration-aware Appearance row registration, snapshot
|
||||
* projection into the row store, and HMR collapse recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
|
||||
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
|
||||
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -17,12 +18,45 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const SLOT = 'settings.general.item'
|
||||
|
||||
async function bench() {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
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: ThemeSettingsSchema.toJSON(),
|
||||
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 +79,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 +118,57 @@ 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
|
||||
await vi.waitFor(() => { 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('activates before a slow initial settings read and converges when it settles', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('dark')
|
||||
const describe = b.describe.getMockImplementation()!
|
||||
const pending = deferred<Awaited<ReturnType<typeof describe>>>()
|
||||
b.describe.mockImplementationOnce(() => pending.promise)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
pending.resolve(await describe())
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores an invalid preference crossing the settings wire', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('sepia')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
30
packages/client/ui-theme/tests/host.spec.ts
Normal file
30
packages/client/ui-theme/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from '@deepseek-ai/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)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
|
||||
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -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: localeInject, apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
})
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ThemeSettings, ThemeSnapshot } 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 = (host = stubSettingsScope<ThemeSettings>()): {
|
||||
ctx: Context
|
||||
theme: ThemeService
|
||||
events: ThemeSnapshot[]
|
||||
host: StubSettingsScope<ThemeSettings>
|
||||
} => {
|
||||
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, host.scope), events, host }
|
||||
}
|
||||
|
||||
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 +28,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, writes through the scope, republishes, and keeps DOM untouched', () => {
|
||||
const { theme, events, host } = 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(host.set).toHaveBeenCalledWith('preference', 'dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(theme.getTheme())
|
||||
// The service never touches presentation state.
|
||||
@@ -39,13 +41,24 @@ describe('ThemeService', () => {
|
||||
// Same-value set is a no-op (no extra event).
|
||||
theme.setTheme('dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).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('adopts a published Host section without writing it back', () => {
|
||||
const { theme, events, host } = make()
|
||||
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'dark' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction', () => {
|
||||
const host = stubSettingsScope<ThemeSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
|
||||
const { theme } = make(host)
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
})
|
||||
|
||||
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
|
||||
@@ -56,7 +69,7 @@ describe('ThemeService', () => {
|
||||
})
|
||||
|
||||
it('registered themes join the snapshot; disposing the active one resets to default', () => {
|
||||
const { theme, events } = make()
|
||||
const { theme, events, host } = 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 +77,9 @@ 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(host.set).not.toHaveBeenCalled()
|
||||
// register + set + dispose = three publishes; disposer is idempotent.
|
||||
expect(events.length).toBe(3)
|
||||
dispose()
|
||||
@@ -88,16 +103,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('context dispose releases the scope subscription', async () => {
|
||||
const { ctx, host } = make()
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user