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,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width.
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables).
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.

View File

@@ -24,7 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
},
@@ -36,6 +37,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-ui-theme": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
@@ -43,6 +45,7 @@
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -4,13 +4,16 @@
* four child slots (declaration = exclusive render authority), seats the
* layout store (panel geometry), and wires the panel-action service face.
* ctx.layout is the cross-plugin panel-action seam; navigation state lives
* with the runtime sessions service.
* with the runtime sessions service. A second effect seats the theme
* presenter, which projects ctx.theme snapshots onto document.body.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type { PanelActions } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutService } from './service.ts'
import { ThemePresenter } from './theme-presenter.ts'
// Contract surface only (export-convergence rule: cross-package consumers
// keep a symbol exported; test-only/package-internal symbols live off /src).
@@ -62,7 +65,7 @@ export interface DetailsOwnerProps {}
export interface EmptyOwnerProps { children?: never }
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots']
export const inject = ['slots', 'theme']
/**
* Client plugin body: provide ctx.layout, then one register() call — AppFrame
@@ -98,4 +101,16 @@ export function apply(ctx: ClientContext): void {
void disposeService()
}
}, 'ui-layout: service + root registration')
// Theme presentation: pure DOM writes from resolved snapshots — initial
// state through the getter once, then event-driven only; no React path.
ctx.effect(() => {
const presenter = new ThemePresenter()
presenter.apply(ctx.theme.getTheme())
const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) })
return () => {
off()
presenter.dispose()
}
}, 'ui-layout: theme presenter')
}

View File

@@ -0,0 +1,43 @@
/**
* Global theme DOM applier: projects the resolved ThemeSnapshot onto
* document.body — the `data-ds-dark-theme` palette switch plus the active
* theme's alias-token overrides as inline CSS variables. Pure DOM writes, no
* React involvement; the presenter only ever retracts what it wrote itself,
* so foreign body attributes and inline styles survive apply/dispose.
*/
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
/** Body attribute selecting the dark base palette in the token stylesheets. */
export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
/** Applies theme snapshots to document.body; one instance per plugin fiber. */
export class ThemePresenter {
/** Token names this presenter wrote in the last apply (its retraction set). */
private appliedTokens: string[] = []
/**
* Project a snapshot onto the body: switch the palette attribute from
* `active.colorScheme` (never the id — `system` is resolved upstream) and
* replace the previously applied token variables with `active.tokens`.
* @param snapshot - resolved theme snapshot from ctx.theme.
*/
apply(snapshot: ThemeSnapshot): void {
const body = document.body
if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
else body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)
this.appliedTokens = []
for (const [name, value] of Object.entries(snapshot.active.tokens)) {
body.style.setProperty(name, value)
this.appliedTokens.push(name)
}
}
/** Retract everything this presenter wrote: the palette attribute and all applied token variables. */
dispose(): void {
const body = document.body
body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)
this.appliedTokens = []
}
}

View File

@@ -9,6 +9,7 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
@@ -16,13 +17,14 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
await slotsFiber.await()
return { ctx, slots: ctx.get('slots') as SlotsService }
}
describe('ui-layout client apply', () => {
it('declares its service dependencies', () => {
expect(inject).toEqual(['slots'])
expect(inject).toEqual(['slots', 'theme'])
})
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
@@ -53,6 +55,23 @@ describe('ui-layout client apply', () => {
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
})
it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
const { ctx } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
// Initial getter application: jsdom has no matchMedia, system resolves light.
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
const theme = ctx.get('theme') as ThemeService
theme.setTheme('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
await fiber.dispose()
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
// Listener is off: further theme changes no longer reach the body.
theme.setTheme('light')
theme.setTheme('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
})
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })

View File

@@ -0,0 +1,56 @@
// @vitest-environment jsdom
// ThemePresenter behavior account: the palette attribute follows
// active.colorScheme only, token variables replace the previous apply's set,
// and dispose retracts everything the presenter wrote.
import { beforeEach, describe, expect, it } from 'vitest'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts'
function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string> = {}): ThemeSnapshot {
// The presenter must key off colorScheme, not the id — keep them distinct.
const active = { id: `${colorScheme}-test`, colorScheme, tokens }
return { preference: colorScheme, active, themes: [active], revision: 1 }
}
beforeEach(() => {
document.body.removeAttribute(DARK_ATTRIBUTE)
document.body.removeAttribute('style')
})
describe('ThemePresenter', () => {
it('light scheme leaves the dark attribute absent', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('light'))
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('dark scheme sets the attribute; switching back to light removes it', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark'))
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
presenter.apply(snapshot('light'))
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('applies tokens as inline variables and clears the previous set on theme change', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111', '--dsw-alias-fg': '#eee' }))
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#111')
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('#eee')
presenter.apply(snapshot('light', { '--dsw-alias-bg': '#fff' }))
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#fff')
// The old theme's extra variable is gone, not merged.
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('')
})
it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => {
document.body.style.setProperty('--foreign', 'kept')
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
presenter.dispose()
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
})
})

View File

@@ -14,6 +14,9 @@
{
"path": "../ui-slots"
},
{
"path": "../ui-theme"
},
{
"path": "../ui-primitives"
},