feat(gui): settings panel with locale and theme preferences

Add the browser Settings surface as slot-composed plugins over new
preference services:

- Rename dsh-client-i18n to dsh-client-locale (locale is the domain
  name); LocaleService adds getLocale()/setLocale(id), immutable
  snapshots, a locale/change event, and dsh.locale persistence.
- ThemeService owns the light/dark/system preference (default system),
  resolves system via prefers-color-scheme, publishes theme/change
  snapshots, persists dsh.theme, and no longer touches the DOM;
  ui-layout's ThemePresenter applies resolved snapshots
  (body[data-ds-dark-theme] + alias tokens) and cleans up on dispose.
- ui-sidebar drops the phase-1 settings dropdown/modal; the foot renders
  the new sidebar.settings slot with the column state.
- New ui-settings shell occupies sidebar.settings: foot trigger row and
  the centered 1080x700 panel (figma 501:29947) with 24% mask, close
  button / mask click / Escape all closing, and a 188px nav projected
  from the settings.section list slot it declares. Nav labels are
  registrant-localized; sections re-register on locale change, so the
  ledger version is the shell's only subscription.
- ui-settings-general registers the General section: Permission and
  Tool Call skeletons, live Language (locale menu) and Appearance
  (Light/Dark/System cubes following the persisted preference); its
  slot store mirrors both service snapshots via apply-side listeners.
- ui-settings-models registers the Models nav entry with an empty
  content column.
- Portaled menus pin z-index above modal overlays (a menu anchored
  inside the settings dialog rendered underneath it and was
  unclickable).
- theme/data/list-pen icons in ui-primitives; settings copy ships as
  zh/en dictionaries; fixture manifests gain the settings rows.
This commit is contained in:
imccyu
2026-07-26 00:16:05 +08:00
parent 84be7cc622
commit 6e721b9fdd
88 changed files with 2653 additions and 404 deletions

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-ui-theme
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
@@ -12,6 +12,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

@@ -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",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -44,5 +44,9 @@
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
}
}

View File

@@ -1,71 +1,196 @@
/**
* 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.
*/
import type { Context } from 'cordis'
/** 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()
this.media = globalThis.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) ?? this.themes[0]!
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)
}
}
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
function restorePreference(): ThemePreference {
try {
const stored = globalThis.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 {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, preference)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
@@ -77,5 +202,5 @@ export const inject: string[] = []
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
ctx.provide('theme', new ThemeService())
ctx.provide('theme', new ThemeService(ctx))
}

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

@@ -1,61 +1,90 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
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])
})
})