mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService;apply(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` 事件通知变化;它绝不接触 DOM:ui-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)。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/client/ui-theme/src/client/AppearanceRow.module.css
Normal file
51
packages/client/ui-theme/src/client/AppearanceRow.module.css
Normal 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);
|
||||
}
|
||||
63
packages/client/ui-theme/src/client/AppearanceRow.tsx
Normal file
63
packages/client/ui-theme/src/client/AppearanceRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
9
packages/client/ui-theme/src/client/settings-contract.ts
Normal file
9
packages/client/ui-theme/src/client/settings-contract.ts
Normal 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'
|
||||
37
packages/client/ui-theme/src/client/settings-store.ts
Normal file
37
packages/client/ui-theme/src/client/settings-store.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
6
packages/client/ui-theme/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-theme/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
75
packages/client/ui-theme/tests/appearance-row.spec.tsx
Normal file
75
packages/client/ui-theme/tests/appearance-row.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
117
packages/client/ui-theme/tests/apply.spec.ts
Normal file
117
packages/client/ui-theme/tests/apply.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
28
packages/client/ui-theme/tests/settings-store.spec.ts
Normal file
28
packages/client/ui-theme/tests/settings-store.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user