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,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])
})
})