Merge remote-tracking branch 'origin/master' into dshw/pr-2423
# Conflicts: # packages/client/ui-sidebar/src/client/SidebarRoot.tsx
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: c24f2ae5692fdd05134ce780d21569b87570d1c7
|
||||
README.zh.md: 518b8930027c88bd85139a8a7325cea1fd40b40c
|
||||
README.md: e84a21f085eaef7546321edc90847a9a3f9bb2b8
|
||||
README.zh.md: 758b6f52d330f4518a2df5267117b61446102ea7
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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.
|
||||
Theme plugin: ThemeRuntime 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.
|
||||
|
||||
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeService and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
|
||||
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeRuntime and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
|
||||
|
||||
`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,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
主题插件:基于 --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)拥有。
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`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)拥有。
|
||||
|
||||
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeService 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
|
||||
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
|
||||
|
||||
`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,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-theme",
|
||||
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row",
|
||||
"version": "0.0.1-rc.2",
|
||||
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row",
|
||||
"version": "0.0.1-rc.3",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -42,6 +42,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
||||
export type ThemeTokens = Record<string, string>
|
||||
|
||||
/**
|
||||
* One override-layer token value: both palette modes are mandatory (repeat
|
||||
* the same value when the token is scheme-invariant) so an override never
|
||||
* goes illegible when the user switches to the other scheme.
|
||||
*/
|
||||
export interface ThemeTokenModes {
|
||||
/** Value applied while the light base palette is active. */
|
||||
light: string
|
||||
/** Value applied while the dark base palette is active. */
|
||||
dark: string
|
||||
}
|
||||
|
||||
/** Override-layer dictionary: token names to per-mode value pairs. */
|
||||
export type ThemeTokenOverrides = Record<string, ThemeTokenModes>
|
||||
|
||||
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
|
||||
export interface ThemeDefinition {
|
||||
/** Theme id (the setTheme argument for concrete themes). */
|
||||
@@ -59,7 +74,11 @@ export interface ThemeDefinition {
|
||||
export interface ThemeSnapshot {
|
||||
/** The persisted preference (may be `system`). */
|
||||
preference: ThemePreference
|
||||
/** The resolved active theme (`system` resolved via prefers-color-scheme). */
|
||||
/**
|
||||
* The resolved active theme (`system` resolved via prefers-color-scheme)
|
||||
* with override layers folded into its tokens (seq order, later layers win
|
||||
* per-token; each value picked for the active color scheme).
|
||||
*/
|
||||
active: ThemeDefinition
|
||||
/** Registered themes in registration order. */
|
||||
themes: readonly ThemeDefinition[]
|
||||
@@ -67,9 +86,23 @@ export interface ThemeSnapshot {
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** One theme token exposed to pre-definition Cordis inspection. */
|
||||
export interface ThemeTokenInspection {
|
||||
/** Token name accepted by {@link ThemeService.overrideTokens}. */
|
||||
name: string
|
||||
/** Intended visual role. */
|
||||
description: string
|
||||
/** CSS value category. */
|
||||
valueType: string
|
||||
/** Whether override layers must supply both palette modes. */
|
||||
requiresLightAndDark: boolean
|
||||
/** CSS custom property consumed by UI styles. */
|
||||
cssVariable?: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
theme: ThemeService
|
||||
theme: ThemeRuntime
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
@@ -87,16 +120,34 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
|
||||
])
|
||||
|
||||
const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([
|
||||
{ name: '--dsw-alias-bg-base', description: 'Application base background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-base' },
|
||||
{ name: '--dsw-alias-bg-layer-1', description: 'Primary raised surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-1' },
|
||||
{ name: '--dsw-alias-bg-layer-2', description: 'Secondary nested surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-2' },
|
||||
{ name: '--dsw-alias-bg-overlay', description: 'Overlay and popover background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-overlay' },
|
||||
{ name: '--dsw-alias-border-l1', description: 'Primary subtle border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l1' },
|
||||
{ name: '--dsw-alias-border-l2', description: 'Secondary stronger border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l2' },
|
||||
{ name: '--dsw-alias-brand-primary', description: 'Primary brand accent.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-brand-primary' },
|
||||
{ name: '--dsw-alias-label-primary', description: 'Primary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-primary' },
|
||||
{ name: '--dsw-alias-label-secondary', description: 'Secondary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-secondary' },
|
||||
{ name: '--dsw-alias-state-error-primary', description: 'Primary error state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-error-primary' },
|
||||
{ name: '--dsw-alias-state-success-primary', description: 'Primary success state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-success-primary' },
|
||||
{ name: '--dsw-alias-state-warn-primary', description: 'Primary warning state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-warn-primary' },
|
||||
{ name: '--dsw-specific-sidebar-fill', description: 'Sidebar column and title-row background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-specific-sidebar-fill' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Theme registry and preference owner. `light`/`dark` are built in (the base
|
||||
* stylesheets carry both palettes); third-party themes register alias-layer
|
||||
* overrides. Reads go through {@link getTheme}; writes only through
|
||||
* {@link setTheme}; continuous sync only through the `theme/change` event.
|
||||
* overrides. Reads go through {@link getTheme}; preference writes only
|
||||
* through {@link setTheme}; continuous sync only through the `theme/change`
|
||||
* event. {@link overrideTokens} stacks partial token layers over the active
|
||||
* theme without touching the registry.
|
||||
* The service holds the `prefers-color-scheme` media query (environment
|
||||
* sensing, not presentation) and re-emits when the OS scheme flips while the
|
||||
* preference is `system`.
|
||||
*/
|
||||
export class ThemeService {
|
||||
export class ThemeRuntime {
|
||||
private readonly ctx: Context
|
||||
private readonly host: SettingsScope<ThemeSettings>
|
||||
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
|
||||
@@ -104,6 +155,9 @@ export class ThemeService {
|
||||
private revision = 0
|
||||
private snapshot: ThemeSnapshot
|
||||
private readonly media: MediaQueryList | undefined
|
||||
/** Override layers by source; seq (monotonic) is the stacking order. */
|
||||
private readonly overrides = new Map<string, { seq: number; tokens: ThemeTokenOverrides }>()
|
||||
private overrideSeq = 0
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the
|
||||
@@ -140,6 +194,25 @@ export class ThemeService {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current token directory without reading DOM or computed styles.
|
||||
* @returns stable JSON-safe token descriptions, including registered and override-only names.
|
||||
*/
|
||||
exportInspectTokens(): ThemeTokenInspection[] {
|
||||
const tokens = new Map(BUILTIN_INSPECT_TOKENS.map(token => [token.name, token]))
|
||||
for (const theme of this.themes) {
|
||||
for (const name of Object.keys(theme.tokens)) {
|
||||
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
|
||||
}
|
||||
}
|
||||
for (const layer of this.overrides.values()) {
|
||||
for (const name of Object.keys(layer.tokens)) {
|
||||
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
|
||||
}
|
||||
}
|
||||
return [...tokens.values()].map(token => ({ ...token })).sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the theme preference — the only user preference write entry.
|
||||
* Built-in preferences are written through the settings scope and every
|
||||
@@ -189,6 +262,33 @@ export class ThemeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack a token override layer on top of the active theme — the token-level
|
||||
* analogue of slot shading: the base theme stays untouched, layers compose
|
||||
* in seq order with later layers winning per-token, and removing a layer
|
||||
* restores whatever it covered. Calling again with the same source replaces
|
||||
* that source's whole layer and restacks it on top (effect re-registration
|
||||
* semantics). Emits `theme/change` with the recomposed snapshot.
|
||||
* @param source - layer identity; one layer per source (dynamic packages
|
||||
* pass their package id — the façade pins it, so it also names the layer's
|
||||
* origin for inspection).
|
||||
* @param tokens - token-name → `{ light, dark }` value pairs. Validated at
|
||||
* runtime (model-authored callers reach this boundary with untyped JS);
|
||||
* a bare string value throws a teaching error.
|
||||
* @returns disposer removing exactly the layer this call created; a no-op
|
||||
* once the source has re-overridden (the newer layer is not torn down).
|
||||
*/
|
||||
overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void {
|
||||
const layer = { seq: this.overrideSeq++, tokens: validateOverrides(source, tokens) }
|
||||
this.overrides.set(source, layer)
|
||||
this.publish()
|
||||
return () => {
|
||||
if (this.overrides.get(source) !== layer) return
|
||||
this.overrides.delete(source)
|
||||
this.publish()
|
||||
}
|
||||
}
|
||||
|
||||
private buildSnapshot(): ThemeSnapshot {
|
||||
const resolvedId = this.preference === 'system'
|
||||
? (this.media?.matches === true ? 'dark' : 'light')
|
||||
@@ -200,12 +300,29 @@ export class ThemeService {
|
||||
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
|
||||
return Object.freeze({
|
||||
preference: this.preference,
|
||||
active,
|
||||
active: this.composeActive(active),
|
||||
themes: Object.freeze([...this.themes]),
|
||||
revision: this.revision,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the override layers into the active definition: seq order, later
|
||||
* layers win per-token, each value picked for the active color scheme (the
|
||||
* presenter consumes the composed snapshot and needs no override awareness).
|
||||
* Without layers the registered definition passes through by identity.
|
||||
*/
|
||||
private composeActive(active: ThemeDefinition): ThemeDefinition {
|
||||
if (this.overrides.size === 0) return active
|
||||
const tokens: ThemeTokens = { ...active.tokens }
|
||||
for (const layer of [...this.overrides.values()].sort((a, b) => a.seq - b.seq)) {
|
||||
for (const [name, modes] of Object.entries(layer.tokens)) {
|
||||
tokens[name] = modes[active.colorScheme]
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...active, tokens: Object.freeze(tokens) })
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.revision += 1
|
||||
this.snapshot = this.buildSnapshot()
|
||||
@@ -213,6 +330,44 @@ export class ThemeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime shape check for one override layer (model-authored callers pass
|
||||
* untyped JS through the dynamic-package façade, so the static type cannot
|
||||
* enforce the pair shape there). Returns a defensive per-token copy so later
|
||||
* caller mutation cannot reach the stored layer.
|
||||
*/
|
||||
function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides {
|
||||
const validated: ThemeTokenOverrides = {}
|
||||
for (const [name, value] of Object.entries<unknown>(tokens)) {
|
||||
if (typeof value === 'string') {
|
||||
throw new TypeError(
|
||||
`theme override "${name}" from "${source}" is a bare string — pass { light: ${JSON.stringify(value)}, dark: ${JSON.stringify(value)} } `
|
||||
+ '(repeat the value when it is the same in both palettes); a single value goes illegible when the user switches color scheme',
|
||||
)
|
||||
}
|
||||
if (typeof value !== 'object' || value === null
|
||||
|| typeof (value as { light?: unknown }).light !== 'string'
|
||||
|| typeof (value as { dark?: unknown }).dark !== 'string') {
|
||||
throw new TypeError(
|
||||
`theme override "${name}" from "${source}" must map to a { light, dark } pair of strings — one value per color scheme`,
|
||||
)
|
||||
}
|
||||
const modes = value as ThemeTokenModes
|
||||
validated[name] = { light: modes.light, dark: modes.dark }
|
||||
}
|
||||
return validated
|
||||
}
|
||||
|
||||
function dynamicToken(name: string): ThemeTokenInspection {
|
||||
return {
|
||||
name,
|
||||
description: 'Theme token registered by the current Client composition.',
|
||||
valueType: 'CSS value',
|
||||
requiresLightAndDark: true,
|
||||
...(name.startsWith('--') ? { cssVariable: name } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required services: settings transport plus slots/locale for the Appearance
|
||||
* row. `remote` carries the forwarded settings invalidation that
|
||||
@@ -228,7 +383,7 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const host = ctx.settingsScope.bind<ThemeSettings>({ namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const theme = new ThemeService(ctx, host)
|
||||
const theme = new ThemeRuntime(ctx, host)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
@@ -34,9 +34,9 @@ export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
|
||||
})
|
||||
ctx.inject(['httpServer'], (httpCtx) => {
|
||||
ctx.inject(['webServer'], (httpCtx) => {
|
||||
httpCtx.effect(
|
||||
() => httpCtx.httpServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
|
||||
() => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
|
||||
'client-ui-theme: initial theme bootstrap',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* projection into the row store, and HMR collapse recovery. */
|
||||
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 { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeRuntime } 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'
|
||||
@@ -27,8 +27,8 @@ function deferred<T>() {
|
||||
|
||||
async function bench(isLoopback = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
let preference = 'system'
|
||||
const namespace = () => ({
|
||||
@@ -56,15 +56,15 @@ async function bench(isLoopback = true) {
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
|
||||
// The settings transport and the forwarded-event port the plugin injects.
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
await ctx.plugin(SettingsScopeBinder).await()
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate,
|
||||
setHostPreference: (next: string) => { preference = next },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
function declareItems(slots: SlotsService): () => void {
|
||||
function declareItems(slots: SlotRegistry): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
@@ -73,7 +73,7 @@ function declareItems(slots: SlotsService): () => void {
|
||||
|
||||
/** Mirror the framework's inject choreography: bake a real instance from the
|
||||
* declared handle and hand its actions to the entry's inject factory. */
|
||||
function faceOf(slots: SlotsService) {
|
||||
function faceOf(slots: SlotRegistry) {
|
||||
const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)!
|
||||
const handle = entry.store as ReturnType<typeof createAppearanceRowStore>
|
||||
const instance = handle.create()
|
||||
@@ -109,7 +109,7 @@ describe('ui-theme apply', () => {
|
||||
const b = await bench()
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
// An event ahead of any inject hits the unbound-actions arm.
|
||||
theme.setTheme('dark')
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('ui-theme apply', () => {
|
||||
b.setHostPreference('dark')
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
expect(b.describe).toHaveBeenCalledOnce()
|
||||
@@ -144,7 +144,7 @@ describe('ui-theme apply', () => {
|
||||
const remote = await bench(false)
|
||||
declareItems(remote.slots)
|
||||
await remote.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const remoteTheme = remote.ctx.get('theme') as ThemeService
|
||||
const remoteTheme = remote.ctx.get('theme') as ThemeRuntime
|
||||
remoteTheme.setTheme('dark')
|
||||
await Promise.resolve()
|
||||
expect(remote.describe).not.toHaveBeenCalled()
|
||||
@@ -159,7 +159,7 @@ describe('ui-theme apply', () => {
|
||||
b.describe.mockImplementationOnce(() => pending.promise)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
pending.resolve(await describe())
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
@@ -170,7 +170,7 @@ describe('ui-theme apply', () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('sepia')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { SettingsProvider, 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 {
|
||||
class MemorySettings extends SettingsProvider {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
@@ -34,12 +34,12 @@ describe('ui-theme host', () => {
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
let disposed = false
|
||||
ctx.provide('httpServer', {
|
||||
ctx.provide('webServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => { disposed = true }
|
||||
},
|
||||
} as HttpServerService)
|
||||
} as WebServer)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
@@ -53,12 +53,12 @@ describe('ui-theme host', () => {
|
||||
it('uses the system preference when only an HTTP server exists', async () => {
|
||||
const ctx = new Context()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
ctx.provide('httpServer', {
|
||||
ctx.provide('webServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => undefined
|
||||
},
|
||||
} as HttpServerService)
|
||||
} as WebServer)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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 { apply as clientApply, inject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
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'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('invariant companion', () => {
|
||||
// copy, hence the slots + locale edges.
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
new SlotRegistry(ctx)
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: () => Promise.resolve({
|
||||
rpcId: 'theme-invariant' as never,
|
||||
@@ -39,6 +39,6 @@ describe('invariant companion', () => {
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeRuntime)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,22 +2,26 @@
|
||||
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'
|
||||
import type {
|
||||
ThemeSettings,
|
||||
ThemeSnapshot,
|
||||
ThemeTokenOverrides,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
const make = (host = stubSettingsScope<ThemeSettings>()): {
|
||||
ctx: Context
|
||||
theme: ThemeService
|
||||
theme: ThemeRuntime
|
||||
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, host.scope), events, host }
|
||||
return { ctx, theme: new ThemeRuntime(ctx, host.scope), events, host }
|
||||
}
|
||||
|
||||
describe('ThemeService', () => {
|
||||
describe('ThemeRuntime', () => {
|
||||
it('defaults to the system preference resolved against prefers-color-scheme', () => {
|
||||
const { theme } = make()
|
||||
const snapshot = theme.getTheme()
|
||||
@@ -103,6 +107,91 @@ describe('ThemeService', () => {
|
||||
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('stacks reversible token overrides in call order and selects the active palette value', () => {
|
||||
const { theme } = make()
|
||||
const firstTokens: ThemeTokenOverrides = {
|
||||
'--shared': { light: 'first-light', dark: 'first-dark' },
|
||||
'--first': { light: 'first-only-light', dark: 'first-only-dark' },
|
||||
}
|
||||
const disposeFirst = theme.overrideTokens('first', firstTokens)
|
||||
firstTokens['--shared']!.light = 'mutated-after-call'
|
||||
const disposeSecond = theme.overrideTokens('second', {
|
||||
'--shared': { light: 'second-light', dark: 'second-dark' },
|
||||
})
|
||||
|
||||
expect(theme.getTheme().active.tokens).toMatchObject({
|
||||
'--first': 'first-only-light',
|
||||
'--shared': 'second-light',
|
||||
})
|
||||
theme.setTheme('dark')
|
||||
expect(theme.getTheme().active.tokens).toMatchObject({
|
||||
'--first': 'first-only-dark',
|
||||
'--shared': 'second-dark',
|
||||
})
|
||||
|
||||
disposeSecond()
|
||||
expect(theme.getTheme().active.tokens['--shared']).toBe('first-dark')
|
||||
disposeFirst()
|
||||
expect(theme.getTheme().active.tokens['--shared']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replacing one source leaves its stale disposer harmless', () => {
|
||||
const { theme, events } = make()
|
||||
const stale = theme.overrideTokens('package', {
|
||||
'--old': { light: 'old-light', dark: 'old-dark' },
|
||||
})
|
||||
const current = theme.overrideTokens('package', {
|
||||
'--new': { light: 'new-light', dark: 'new-dark' },
|
||||
})
|
||||
stale()
|
||||
expect(theme.getTheme().active.tokens).toEqual({ '--new': 'new-light' })
|
||||
current()
|
||||
current()
|
||||
expect(theme.getTheme().active.tokens).toEqual({})
|
||||
expect(events).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('exports sorted built-in, registered, and override-only token descriptions as copies', () => {
|
||||
const { theme } = make()
|
||||
theme.register({
|
||||
id: 'custom',
|
||||
colorScheme: 'light',
|
||||
tokens: {
|
||||
'--dsw-alias-bg-base': 'duplicate-built-in',
|
||||
'--registered': 'registered',
|
||||
},
|
||||
})
|
||||
theme.overrideTokens('package', {
|
||||
'--registered': { light: 'duplicate-registered', dark: 'duplicate-registered' },
|
||||
semanticAccent: { light: 'pink', dark: 'red' },
|
||||
})
|
||||
|
||||
const tokens = theme.exportInspectTokens()
|
||||
expect(tokens.map(token => token.name)).toEqual([...tokens.map(token => token.name)].sort())
|
||||
expect(tokens.find(token => token.name === '--registered')).toMatchObject({
|
||||
valueType: 'CSS value',
|
||||
cssVariable: '--registered',
|
||||
})
|
||||
const semantic = tokens.find(token => token.name === 'semanticAccent')
|
||||
expect(semantic).toMatchObject({ valueType: 'CSS value' })
|
||||
expect(semantic).not.toHaveProperty('cssVariable')
|
||||
expect(tokens.filter(token => token.name === '--dsw-alias-bg-base')).toHaveLength(1)
|
||||
|
||||
tokens[0]!.description = 'caller mutation'
|
||||
expect(theme.exportInspectTokens()[0]!.description).not.toBe('caller mutation')
|
||||
})
|
||||
|
||||
it('rejects every malformed token override value with a teaching error', () => {
|
||||
const { theme } = make()
|
||||
const override = (value: unknown): void => {
|
||||
theme.overrideTokens('package', { '--bad': value } as unknown as ThemeTokenOverrides)
|
||||
}
|
||||
expect(() => { override('red') }).toThrow(/bare string.*light.*dark/)
|
||||
for (const value of [1, null, {}, { light: 1, dark: 'dark' }, { light: 'light' }]) {
|
||||
expect(() => { override(value) }).toThrow(/must map to a \{ light, dark \} pair/)
|
||||
}
|
||||
})
|
||||
|
||||
it('context dispose releases the scope subscription', async () => {
|
||||
const { ctx, host } = make()
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
|
||||
Reference in New Issue
Block a user