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:
16
packages/client/locale/README.md
Normal file
16
packages/client/locale/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @deepseek-ai/dsh-client-locale
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the locale registry serves browser UI copy; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
|
||||
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
|
||||
51
packages/client/locale/package.json
Normal file
51
packages/client/locale/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
}
|
||||
}
|
||||
195
packages/client/locale/src/client/index.ts
Normal file
195
packages/client/locale/src/client/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Browser-side locale registry. Bound translation functions retain stable
|
||||
* identity for injected consumers.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
/** Locale identifier: the two shipped locales. */
|
||||
export type LocaleId = 'zh' | 'en'
|
||||
|
||||
/** One selectable locale: id plus its self-described display name. */
|
||||
export interface LocaleDefinition {
|
||||
/** Locale id (persisted; the setLocale argument). */
|
||||
id: LocaleId
|
||||
/** Display name in its own language (中文 / English). */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Immutable locale state published on every change. */
|
||||
export interface LocaleSnapshot {
|
||||
/** Active locale id. */
|
||||
active: LocaleId
|
||||
/** Selectable locales in display order. */
|
||||
locales: readonly LocaleDefinition[]
|
||||
/** Monotonic change counter (registry or active changes). */
|
||||
revision: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
locale: LocaleService
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* Locale state changed (active locale switched or registry updated).
|
||||
* @param snapshot - Current immutable locale snapshot.
|
||||
* @mode emit
|
||||
*/
|
||||
'locale/change'(snapshot: LocaleSnapshot): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback locale consulted after the active locale misses (also the default). */
|
||||
export const FALLBACK_LOCALE: LocaleId = 'zh'
|
||||
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
|
||||
/** localStorage key holding the persisted locale id. */
|
||||
export const STORAGE_KEY = 'dsh.locale'
|
||||
|
||||
/** The two shipped locales. */
|
||||
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'en', label: 'English' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: active
|
||||
* locale -> zh fallback -> the key itself (missing text stays visible, fail
|
||||
* loud in the UI rather than blank). Reads go through {@link getLocale};
|
||||
* writes only through {@link setLocale}; continuous sync only through the
|
||||
* `locale/change` event.
|
||||
*/
|
||||
export class LocaleService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private snapshot: LocaleSnapshot
|
||||
private readonly ctx: Context
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it).
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx
|
||||
this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current immutable locale snapshot.
|
||||
* @returns the current snapshot (stable reference until the next change).
|
||||
*/
|
||||
getLocale(): LocaleSnapshot {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale — the only preference write entry. Persists the
|
||||
* id and emits `locale/change`.
|
||||
* @param id - a registered locale id; unknown ids throw.
|
||||
*/
|
||||
setLocale(id: string): void {
|
||||
const match = this.snapshot.locales.find(l => l.id === id)
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
this.snapshot = Object.freeze({
|
||||
active: match.id,
|
||||
locales: this.snapshot.locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
persistPreference(match.id)
|
||||
this.ctx.emit('locale/change', this.snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the active locale at call time).
|
||||
*/
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
t = (key, params) => this.translate(ns, key, params)
|
||||
this.bound.set(ns, t)
|
||||
return t
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.snapshot.active)?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
||||
function restorePreference(): LocaleId {
|
||||
try {
|
||||
const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
|
||||
if (stored === 'zh' || stored === 'en') return stored
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode); the default below covers it.
|
||||
}
|
||||
return FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
|
||||
function persistPreference(id: LocaleId): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode / quota); the preference simply
|
||||
// does not survive the session.
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services (none; the loader passes the export surface as an object plugin). */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the locale service with base dictionaries.
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.register(COMMON_NS, 'zh', zh)
|
||||
locale.register(COMMON_NS, 'en', en)
|
||||
ctx.provide('locale', locale)
|
||||
}
|
||||
4
packages/client/locale/src/index.ts
Normal file
4
packages/client/locale/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the locale plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/locale/src/invariant.ts
Normal file
32
packages/client/locale/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`.
|
||||
* @module @deepseek-ai/dsh-client-locale/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-locale-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: ns-by-locale dictionary registry with a stable
|
||||
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
|
||||
* mutable relation; fallback-chain resolution and locale-store behavior are
|
||||
* asserted directly by this package's behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
2
packages/client/locale/src/locales/en.ts
Normal file
2
packages/client/locale/src/locales/en.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const en: Record<string, string> = {}
|
||||
2
packages/client/locale/src/locales/zh.ts
Normal file
2
packages/client/locale/src/locales/zh.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const zh: Record<string, string> = {}
|
||||
30
packages/client/locale/tests/invariant.spec.ts
Normal file
30
packages/client/locale/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
|
||||
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
|
||||
expect(inject).toEqual([])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
|
||||
expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
|
||||
expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
|
||||
})
|
||||
})
|
||||
90
packages/client/locale/tests/locale.spec.ts
Normal file
90
packages/client/locale/tests/locale.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// @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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
18
packages/client/locale/tsconfig.json
Normal file
18
packages/client/locale/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/locale/tsdown.config.ts
Normal file
3
packages/client/locale/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user