Merge remote-tracking branch 'origin/master' into worktree/i18n-complete-non-readme

# Conflicts:
#	packages/client/i18n/README.md
#	packages/client/locale/README.i18n.yaml
#	packages/client/locale/README.zh.md
#	packages/client/ui-layout/README.md
#	packages/client/ui-theme/README.md
This commit is contained in:
Tianyi Cui
2026-07-26 14:37:46 +08:00
136 changed files with 4696 additions and 459 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
README.md: de8e31b7dfaede81ca4523155e41197bad9c420e
README.zh.md: d455434f094aab0f5c32c39311a6ae82872d1cad
README.md: 5c9794d4f5faea42861f47423d4e8980cbf89216
README.zh.md: 2e0f76133ee38ba2fdc385e7fbb2a4a03b733cfa

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers); apply(id) toggles the `body[data-ds-dark-theme]` attribute, so theme switches are pure CSS cascade. Contract: api-contracts v3 §8.
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 (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8.
## Model Experience
None, as the theme service toggles browser CSS; nothing here reaches a model request.
None, as the theme service manages a browser preference; nothing here reaches a model request.
#### KV Cache effect
@@ -14,6 +14,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No theme-switch control ships in P-I** — the service surface (register/apply/current) is complete but no UI owner mounts a toggle; switching happens programmatically.
- **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22).

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeServiceapply(id) 会切换 `body[data-ds-dark-theme]` 属性,因此主题切换完全依靠 CSS 级联。契约api-contracts v3 §8。
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light``dark``system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约api-contracts v3 §8。
## 模型体验
无。主题服务切换浏览器 CSS;这里没有任何内容进入模型请求。
无。主题服务管理浏览器偏好;这里没有任何内容进入模型请求。
#### KV Cache 影响
@@ -14,6 +14,5 @@
## 已知限制与暂缓事项
- **P-I 不提供主题切换控件**服务表层register/apply/current已经完整但没有 UI owner 挂载开关;切换通过编程方式完成。
- **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。
- **token 样式表是颜色的唯一权威**:不会追加 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);应使用最接近的语义 token裁定于 2026-07-22

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-theme",
"description": "Theme plugin: ThemeService (apply = toggle body[data-ds-dark-theme]), --dsw-* token base stylesheets",
"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,
"type": "module",
@@ -24,18 +24,32 @@
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"inject": [
"@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"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@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:^",
"cordis": "^4.0.0-rc.7"
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
@@ -44,5 +58,12 @@
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"dependencies": {
"clsx": "^2.0.0"
}
}

View File

@@ -0,0 +1,51 @@
/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8,
* pad 16/0, hairline separator; the section column strips it when last). */
.group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
* icon-over-label column, gap 4). */
.themeCube {
box-sizing: border-box;
width: 276px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 20px 32px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: transparent;
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
* step has no alias-layer name). */
.selected {
background: var(--dsw-alias-bg-module-platform);
border-color: var(--dsw-static-neutral-bluish-400);
}

View File

@@ -0,0 +1,63 @@
/**
* Appearance preference row registered into the General section item slot
* (figma 501:30012 'Frame 2117131228'): title + three preference cubes.
* Registered by this package — the theme feature owns its own settings
* surface. Selection follows the persisted preference, never the resolved
* active theme.
*/
import clsx from 'clsx'
import {
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemePreference } from './index.ts'
import type {} from './settings-contract.ts'
import type { createAppearanceRowStore } from './settings-store.ts'
import css from './AppearanceRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
export interface AppearanceRowInjected {
/** Translate a `settings.theme` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the theme preference. */
setTheme: (id: ThemePreference) => void
}
/** Full component props: runtime share + store share + injected face. */
export type AppearanceRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
]
/**
* Render the Appearance row.
* @param props - composed slot props.
* @returns the row element tree.
*/
export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) {
const preference = useStore(s => s.preference)
return (
<div className={css.group}>
<div className={css.title}>{t('appearance.title')}</div>
<div className={css.cubeRow}>
{CUBES.map(({ id, labelKey, Icon }) => (
<button
key={id}
type="button"
className={clsx(css.themeCube, preference === id && css.selected)}
aria-pressed={preference === id}
onClick={() => { setTheme(id) }}
>
<Icon />
{t(labelKey)}
</button>
))}
</div>
</div>
)
}

View File

@@ -1,81 +1,276 @@
/**
* Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
* update CSS variables and `body[data-ds-dark-theme]` without React renders.
* Browser theme registry over the `--dsw-*` token stylesheets. The service
* owns the 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.
*/
import type { Context } from 'cordis'
import { deferRegistration, 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).
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'
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
export type { AppearanceRowState } from './settings-store.ts'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.theme'
/** 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). */
id: string
/**
* Which base palette this theme builds on. The presenter switches
* `body[data-ds-dark-theme]` from this field — never from the id.
*/
colorScheme: 'light' | 'dark'
/** Alias-layer overrides applied as inline CSS variables over the base palette. */
tokens: ThemeTokens
}
/** Immutable theme state published on every change. */
export interface ThemeSnapshot {
/** The persisted preference (may be `system`). */
preference: ThemePreference
/** The resolved active theme (`system` resolved via prefers-color-scheme). */
active: ThemeDefinition
/** Registered themes in registration order. */
themes: readonly ThemeDefinition[]
/** Monotonic change counter (registry or active changes). */
revision: number
}
declare module 'cordis' {
interface Context {
theme: ThemeService
}
interface Events {
/**
* Theme state changed (preference switched, registry updated, or the OS
* color scheme changed while the preference is `system`).
* @param snapshot - Current immutable theme snapshot.
* @mode emit
*/
'theme/change'(snapshot: ThemeSnapshot): void
}
}
/** 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({}) }),
])
/**
* Theme registry and switcher. `light`/`dark` are built in (the base
* stylesheets carry both palettes; the dark palette activates via the
* body[data-ds-dark-theme] attribute). Third-party themes register alias-layer
* overrides applied as inline CSS variables on body, cascading over whichever
* base palette the attribute selects.
* 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.
* 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 {
private themes = new Map<string, ThemeTokens>([['light', {}], ['dark', {}]])
private appliedTokens: ThemeTokens = {}
private active = 'light'
private readonly ctx: Context
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
private preference: ThemePreference
private revision = 0
private snapshot: ThemeSnapshot
private readonly media: MediaQueryList | undefined
/**
* Register a theme. Duplicate id throws (single occupant per id; the
* built-in pair counts).
* @param id - theme id.
* @param tokens - alias-layer overrides (variable name to value).
* @returns disposer. Disposing the active theme reverts to `light` so the
* UI never keeps tokens of an unregistered theme.
* @param ctx - owning context (change events are emitted on it; the
* media-query listener is released through ctx.effect on dispose).
*/
register(id: string, tokens: ThemeTokens): () => void {
if (this.themes.has(id)) throw new Error(`theme "${id}" is already registered`)
this.themes.set(id, tokens)
return () => {
if (!this.themes.delete(id)) return
if (this.active === id) this.apply('light')
constructor(ctx: Context) {
this.ctx = ctx
this.preference = restorePreference()
// 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()
if (this.media !== undefined) {
const media = this.media
const onChange = (): void => {
if (this.preference !== 'system') return
this.publish()
}
ctx.effect(() => {
media.addEventListener('change', onChange)
return () => { media.removeEventListener('change', onChange) }
}, 'ui-theme: prefers-color-scheme listener')
}
}
/**
* Activate a theme: toggle body[data-ds-dark-theme] (set only for `dark`)
* and swap the previous theme's inline token overrides for this one's.
* Unregistered id throws.
* @param id - registered theme id.
* Read the current immutable theme snapshot.
* @returns the current snapshot (stable reference until the next change).
*/
apply(id: string): void {
const tokens = this.themes.get(id)
if (!tokens) throw new Error(`theme "${id}" is not registered`)
const body = document.body
for (const name of Object.keys(this.appliedTokens)) body.style.removeProperty(name)
if (id === 'dark') body.setAttribute('data-ds-dark-theme', '')
else body.removeAttribute('data-ds-dark-theme')
for (const [name, value] of Object.entries(tokens)) body.style.setProperty(name, value)
this.appliedTokens = tokens
this.active = id
getTheme(): ThemeSnapshot {
return this.snapshot
}
/**
* Report the active theme id (initially `light`).
* @returns the active theme id.
* Switch the theme preference — the only preference write entry. Persists
* the preference and emits `theme/change`.
* @param id - a registered theme id or `system`; unknown ids throw.
*/
current(): string {
return this.active
setTheme(id: string): void {
if (id !== 'system' && !this.themes.some(t => t.id === id)) {
throw new Error(`theme "${id}" is not registered`)
}
if (this.preference === id) return
this.preference = id as ThemePreference
persistPreference(this.preference)
this.publish()
}
/**
* Register a theme. Duplicate id throws (single occupant per id; the
* built-in pair counts; `system` is a preference, not a registrable id).
* @param definition - theme id, colorScheme, and alias-token overrides.
* @returns disposer. Disposing the theme backing the active preference
* resets the preference to the default so the UI never keeps tokens of an
* unregistered theme.
*/
register(definition: ThemeDefinition): () => void {
if (definition.id === 'system') throw new Error('"system" is a preference, not a registrable theme id')
if (this.themes.some(t => t.id === definition.id)) {
throw new Error(`theme "${definition.id}" is already registered`)
}
this.themes = [...this.themes, definition]
this.publish()
return () => {
if (!this.themes.some(t => t.id === definition.id)) return
this.themes = this.themes.filter(t => t.id !== definition.id)
if (this.preference === definition.id) {
this.preference = DEFAULT_PREFERENCE
persistPreference(this.preference)
}
this.publish()
}
}
private buildSnapshot(): ThemeSnapshot {
const resolvedId = this.preference === 'system'
? (this.media?.matches === true ? 'dark' : 'light')
: this.preference
// Both built-ins always exist; a registered preference id resolves or has
// been reset by its disposer, so the lookup cannot miss.
const active = this.themes.find(t => t.id === resolvedId)
/* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
return Object.freeze({
preference: this.preference,
active,
themes: Object.freeze([...this.themes]),
revision: this.revision,
})
}
private publish(): void {
this.revision += 1
this.snapshot = this.buildSnapshot()
this.ctx.emit('theme/change', this.snapshot)
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/** 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']
/**
* Client plugin body: provide the theme service.
* Client plugin body: provide the theme service and register the
* feature-owned Appearance preference row into the General section's item
* slot (a feature owns its settings surface).
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
ctx.provide('theme', new ThemeService())
export function apply(ctx: ClientContext): void {
const theme = new ThemeService(ctx)
ctx.provide('theme', theme)
ctx.effect(() => {
const disposers = [
ctx.locale.register(SETTINGS_NS, 'zh', {
'appearance.title': '外观',
'appearance.light': '浅色',
'appearance.dark': '深色',
'appearance.system': '跟随系统',
}),
ctx.locale.register(SETTINGS_NS, 'en', {
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-theme: settings row dictionaries')
const store = createAppearanceRowStore()
let bound: BoundActions<typeof store> | undefined
const sync = (snapshot: ThemeSnapshot): void => {
bound?.sync(snapshot.preference, snapshot.revision)
}
ctx.on('theme/change', sync)
const injected = (actions: BoundActions<typeof store>): AppearanceRowInjected => {
bound = actions
// Re-sync from the getter so no event is lost between registration and
// first render (the store's revision guard drops stale duplicates).
sync(theme.getTheme())
return {
t: ctx.locale.bind(SETTINGS_NS),
setTheme: (id) => { theme.setTheme(id) },
}
}
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'appearance',
order: 10,
store,
inject: injected,
}, AppearanceRow))
return () => { deferred.dispose() }
}, 'ui-theme: appearance settings row registration')
}

View File

@@ -0,0 +1,9 @@
/**
* Re-export seam for the `settings.general.item` slot type consumed by this
* package's Appearance row. The canonical home is the locale package (the
* common dependency of every item registrant); this file exists so row
* modules import the type from within their own package.
*/
export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client'
// Side-effect type import: pulls the SlotMap merge into this program.
import type {} from '@deepseek-ai/dsh-client-locale/client'

View File

@@ -0,0 +1,37 @@
/**
* Appearance row slot store: a mirror of the theme service snapshot. The
* plugin's apply-world change listener is the only writer; the row component
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ThemePreference } from './index.ts'
/** Store state mirrored from the theme snapshot. */
export interface AppearanceRowState {
/** Persisted preference (selection state reads this, never the resolved active theme). */
preference: ThemePreference
/** Service revision; -1 until first sync so revision 0 lands as a change. */
revision: number
}
/** Declared action shape giving the exported factory a stable return type. */
type AppearanceRowActions = {
sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void
}
/**
* Declares the Appearance row state and write surface.
* @returns the store handle.
*/
export function createAppearanceRowStore(): EngineStoreHandle<AppearanceRowState, AppearanceRowActions> {
return defineStore({
init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }),
actions: {
sync: (d, preference: ThemePreference, revision: number) => {
if (revision <= d.revision) return
d.preference = preference
d.revision = revision
},
},
})
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -15,9 +15,10 @@ export const name = 'client-ui-theme-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a token-sheet registry whose apply() flips one body
* attribute — it emits no cordis events; registration/apply/current behavior
* is asserted directly by this package's behavior specs.
* 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.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,75 @@
// @vitest-environment jsdom
/** AppearanceRow behavior: three cubes, selection follows the persisted
* preference, clicks drive setTheme. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx'
import { createAppearanceRowStore } from '../src/client/settings-store.ts'
import type { ThemePreference } from '../src/client/index.ts'
afterEach(cleanup)
const COPY: Record<string, string> = {
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
}
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
function mount(preference: ThemePreference = 'system') {
// Real store instance — the sanctioned zero-machinery path for tests.
const store = createAppearanceRowStore().create()
store.actions.sync(preference, 0)
const setTheme = vi.fn()
const props: AppearanceRowComponentProps = {
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
t: (key: string) => COPY[key] ?? key,
setTheme,
}
render(<AppearanceRow {...props} />)
return { store, setTheme }
}
const pressed = (name: RegExp): string | null =>
screen.getByRole('button', { name }).getAttribute('aria-pressed')
describe('AppearanceRow', () => {
it('renders the title and three cubes with the preference cube selected', () => {
mount('dark')
expect(screen.getByText('Appearance')).toBeDefined()
expect(pressed(/Dark/)).toBe('true')
expect(pressed(/Light/)).toBe('false')
expect(pressed(/System/)).toBe('false')
})
it('click drives setTheme; selection follows the store mirror, not the click echo', () => {
const b = mount('dark')
fireEvent.click(screen.getByRole('button', { name: /Light/ }))
expect(b.setTheme).toHaveBeenCalledWith('light')
// No store write yet: selection is unchanged.
expect(pressed(/Dark/)).toBe('true')
act(() => { b.store.actions.sync('light', 1) })
expect(pressed(/Light/)).toBe('true')
expect(pressed(/Dark/)).toBe('false')
})
})

View File

@@ -0,0 +1,117 @@
/** 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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/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 { AppearanceRow } from '../src/client/AppearanceRow.tsx'
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
const SLOT = 'settings.general.item'
async function bench() {
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 }
}
/** Stand in for the settings shell: declare the General item slot from root. */
function declareItems(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
() => null,
)
}
/** 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) {
const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)!
const handle = entry.store as ReturnType<typeof createAppearanceRowStore>
const instance = handle.create()
const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions)
return { entry, instance, face }
}
describe('ui-theme apply', () => {
it('declares the slot and locale services', () => {
expect(inject).toEqual(['slots', 'locale'])
})
it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
const before = await bench()
declareItems(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观')
before.locale.setLocale('en')
expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance')
const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)!
expect(entry.options).toMatchObject({ id: 'appearance', order: 10 })
const after = await bench()
const fiber = after.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(after.slots.entries(SLOT)).toHaveLength(0)
declareItems(after.slots)
await Promise.resolve()
expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true)
})
it('projects service snapshots into the row store and routes face writes back', async () => {
const b = await bench()
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const theme = b.ctx.get('theme') as ThemeService
// An event ahead of any inject hits the unbound-actions arm.
theme.setTheme('dark')
const { instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().preference).toBe('dark')
expect(face.t('appearance.dark')).toBe('深色')
face.setTheme('system')
expect(theme.getTheme().preference).toBe('system')
expect(instance.getSnapshot().preference).toBe('system')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
const b = await bench()
const host = declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries(SLOT)).toHaveLength(1)
// Collapse: the declarer dies, the cascade removes our entry while the
// apply closure still holds its (now stale) disposer.
host()
expect(b.slots.entries(SLOT)).toHaveLength(0)
declareItems(b.slots)
await Promise.resolve()
expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true)
})
it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => {
const b = await bench()
declareItems(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries(SLOT)).toHaveLength(1)
await fiber.dispose()
expect(b.slots.entries(SLOT)).toHaveLength(0)
// Dictionary disposal: translation falls back to the bare key.
expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title')
// Never-declared bench: the effect disposer's dispose arm stays undefined.
const quiet = await bench()
const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
await f2.await()
await f2.dispose()
expect(quiet.slots.entries(SLOT)).toHaveLength(0)
})
})

View File

@@ -4,6 +4,8 @@ import { Context } from '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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
@@ -18,9 +20,13 @@ describe('invariant companion', () => {
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.theme with no service prerequisites', async () => {
expect(inject).toEqual([])
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'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
await ctx.plugin({ inject, apply: clientApply }).await()
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
})

View File

@@ -0,0 +1,28 @@
/** Appearance row store: snapshot-mirror action and the revision guard. */
import { describe, expect, it } from 'vitest'
import { createAppearanceRowStore } from '../src/client/settings-store.ts'
describe('createAppearanceRowStore', () => {
it('init shape: system preference with revision at -1', () => {
const store = createAppearanceRowStore().create()
expect(store.getSnapshot()).toEqual({ preference: 'system', revision: -1 })
})
it('sync mirrors the preference and advances the revision', () => {
const store = createAppearanceRowStore().create()
store.actions.sync('dark', 0)
expect(store.getSnapshot()).toEqual({ preference: 'dark', revision: 0 })
store.actions.sync('light', 2)
expect(store.getSnapshot().preference).toBe('light')
expect(store.getSnapshot().revision).toBe(2)
})
it('revision guard drops stale and duplicate writes', () => {
const store = createAppearanceRowStore().create()
store.actions.sync('dark', 3)
store.actions.sync('system', 2)
store.actions.sync('system', 3)
expect(store.getSnapshot().preference).toBe('dark')
expect(store.getSnapshot().revision).toBe(3)
})
})

View File

@@ -1,61 +1,151 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
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'
const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
const ctx = new Context()
const events: ThemeSnapshot[] = []
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
return { ctx, theme: new ThemeService(ctx), events }
}
describe('ThemeService', () => {
beforeEach(() => {
document.body.removeAttribute('data-ds-dark-theme')
document.body.removeAttribute('style')
localStorage.clear()
})
it('starts on light; apply toggles the dark body attribute both ways', () => {
const theme = new ThemeService()
expect(theme.current()).toBe('light')
theme.apply('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
expect(theme.current()).toBe('dark')
theme.apply('light')
it('defaults to the system preference resolved against prefers-color-scheme', () => {
const { theme } = make()
const snapshot = theme.getTheme()
expect(snapshot.preference).toBe('system')
// jsdom matchMedia is absent; system resolves to light.
expect(snapshot.active.id).toBe('light')
expect(snapshot.active.colorScheme).toBe('light')
expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
})
it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
const { theme, events } = 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(events).toHaveLength(1)
expect(events[0]).toBe(theme.getTheme())
// The service never touches presentation state.
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
expect(theme.current()).toBe('light')
// Same-value set is a no-op (no extra event).
theme.setTheme('dark')
expect(events).toHaveLength(1)
})
it('throws on unregistered apply and duplicate register (built-ins included)', () => {
const theme = new ThemeService()
expect(() => { theme.apply('sepia') }).toThrow('not registered')
expect(() => theme.register('light', {})).toThrow('already registered')
theme.register('sepia', {})
expect(() => theme.register('sepia', {})).toThrow('already registered')
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('applies third-party token overrides as body inline vars and swaps them on switch', () => {
const theme = new ThemeService()
theme.register('sepia', { '--dsw-alias-bg-base': 'rgb(1, 2, 3)' })
theme.apply('sepia')
expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('rgb(1, 2, 3)')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
theme.apply('dark')
expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
const { theme } = make()
expect(() => { theme.setTheme('sepia') }).toThrow('not registered')
expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered')
expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference')
})
it('disposing the active theme reverts to light; disposer is idempotent', () => {
const theme = new ThemeService()
const dispose = theme.register('sepia', { '--dsw-alias-bg-base': 'red' })
theme.apply('sepia')
it('registered themes join the snapshot; disposing the active one resets to default', () => {
const { theme, events } = 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')
expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red')
dispose()
expect(theme.current()).toBe('light')
expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('')
expect(() => { theme.apply('sepia') }).toThrow('not registered')
expect(theme.getTheme().preference).toBe('system')
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
// register + set + dispose = three publishes; disposer is idempotent.
expect(events.length).toBe(3)
dispose()
expect(theme.current()).toBe('light')
expect(events.length).toBe(3)
})
it('disposing an inactive theme leaves the active selection untouched', () => {
const theme = new ThemeService()
const dispose = theme.register('sepia', {})
theme.apply('dark')
it('disposing an inactive theme keeps the active preference', () => {
const { theme } = make()
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} })
theme.setTheme('dark')
dispose()
expect(theme.current()).toBe('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
expect(theme.getTheme().preference).toBe('dark')
})
it('revision increases monotonically across every publish', () => {
const { theme, events } = make()
theme.setTheme('dark')
theme.setTheme('light')
const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} })
dispose()
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()
}
})
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
type Listener = () => void
const stubMedia = (initialMatches: boolean) => {
const listeners = new Set<Listener>()
const media = {
matches: initialMatches,
addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) },
removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) },
flip() {
this.matches = !this.matches
for (const fn of listeners) fn()
},
listenerCount: () => listeners.size,
}
vi.stubGlobal('matchMedia', () => media)
return media
}
afterEach(() => { vi.unstubAllGlobals() })
it('system resolves against the media query and follows OS flips', () => {
const media = stubMedia(true)
const { theme, events } = make()
expect(theme.getTheme().preference).toBe('system')
expect(theme.getTheme().active.id).toBe('dark')
media.flip()
expect(theme.getTheme().active.id).toBe('light')
expect(events).toHaveLength(1)
})
it('OS flips do not republish while a concrete preference is set', () => {
const media = stubMedia(false)
const { theme, events } = make()
theme.setTheme('light')
expect(events).toHaveLength(1)
media.flip()
expect(events).toHaveLength(1)
expect(theme.getTheme().active.id).toBe('light')
})
it('context dispose releases the media listener', async () => {
const media = stubMedia(false)
const { ctx } = make()
expect(media.listenerCount()).toBe(1)
await ctx.fiber.dispose()
expect(media.listenerCount()).toBe(0)
})
})
})

View File

@@ -8,6 +8,18 @@
"src"
],
"references": [
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},