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.
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
|
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
|
|
|
|
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
|
|
const ctx = new Context()
|
|
const events: LocaleSnapshot[] = []
|
|
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
|
|
return { ctx, svc: new LocaleService(ctx), events }
|
|
}
|
|
|
|
describe('LocaleService', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
})
|
|
|
|
it('translates through the active-locale -> zh -> key chain', () => {
|
|
const { svc } = make()
|
|
svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
|
|
svc.register('ns', 'en', { hello: 'Hello' })
|
|
const t = svc.bind('ns')
|
|
expect(svc.getLocale().active).toBe('zh')
|
|
expect(t('hello')).toBe('你好')
|
|
svc.setLocale('en')
|
|
expect(t('hello')).toBe('Hello')
|
|
expect(t('onlyZh')).toBe('仅中文')
|
|
expect(t('missing.key')).toBe('missing.key')
|
|
})
|
|
|
|
it('interpolates {name} params and leaves unknown placeholders intact', () => {
|
|
const { svc } = make()
|
|
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
|
|
const t = svc.bind('ns')
|
|
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
|
|
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
|
|
})
|
|
|
|
it('bind returns a stable per-namespace function identity', () => {
|
|
const { svc } = make()
|
|
expect(svc.bind('a')).toBe(svc.bind('a'))
|
|
expect(svc.bind('a')).not.toBe(svc.bind('b'))
|
|
})
|
|
|
|
it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => {
|
|
const { svc } = make()
|
|
const dispose = svc.register('ns', 'zh', { k: 'v1' })
|
|
expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
|
|
dispose()
|
|
const t = svc.bind('ns')
|
|
expect(t('k')).toBe('k')
|
|
svc.register('ns', 'zh', { k: 'v2' })
|
|
expect(t('k')).toBe('v2')
|
|
dispose()
|
|
expect(t('k')).toBe('v2')
|
|
})
|
|
|
|
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
|
const { svc, events } = make()
|
|
svc.setLocale('en')
|
|
expect(svc.getLocale().active).toBe('en')
|
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]).toBe(svc.getLocale())
|
|
expect(events[0]!.revision).toBe(1)
|
|
svc.setLocale('en')
|
|
expect(events).toHaveLength(1)
|
|
})
|
|
|
|
it('throws on unknown locale ids', () => {
|
|
const { svc } = make()
|
|
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
|
})
|
|
|
|
it('restores a persisted locale and falls back to zh on garbage', () => {
|
|
localStorage.setItem(STORAGE_KEY, 'en')
|
|
expect(make().svc.getLocale().active).toBe('en')
|
|
localStorage.setItem(STORAGE_KEY, 'fr')
|
|
expect(make().svc.getLocale().active).toBe('zh')
|
|
})
|
|
|
|
it('exposes the two shipped locales with self-described labels', () => {
|
|
const { svc } = make()
|
|
expect(svc.getLocale().locales).toEqual([
|
|
{ id: 'zh', label: '中文' },
|
|
{ id: 'en', label: 'English' },
|
|
])
|
|
})
|
|
})
|