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

@@ -32,16 +32,16 @@ const workspaces: WorkspaceListState = {
function mount(sessionState: SessionListState = sessions) {
const startSession = vi.fn()
const open = vi.fn()
let pickerOwner: unknown
const owners: Record<string, unknown> = {}
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
startSession={startSession} open={open} toggleSidebar={vi.fn()}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>,
)
return { view, startSession, open, pickerOwner: () => pickerOwner }
return { view, startSession, open, pickerOwner: () => owners['sidebar.workspace'], settingsOwner: () => owners['sidebar.settings'] }
}
function mountSidebar({
@@ -58,14 +58,14 @@ function mountSidebar({
const startSession = vi.fn()
const open = vi.fn()
const toggleSidebar = vi.fn()
let pickerOwner: unknown
const owners: Record<string, unknown> = {}
let current = { sessionState, workspaceState, collapsed, width }
const root = () => (
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>
)
const view = render(root())
@@ -73,7 +73,8 @@ function mountSidebar({
startSession,
open,
toggleSidebar,
pickerOwner: () => pickerOwner,
pickerOwner: () => owners['sidebar.workspace'],
settingsOwner: () => owners['sidebar.settings'],
rerender(next: Partial<typeof current>) {
current = { ...current, ...next }
view.rerender(root())
@@ -246,6 +247,7 @@ describe('SidebarRoot', () => {
it('keeps wide content during live collapse, then settles to the rail', () => {
vi.useFakeTimers()
const b = mountSidebar({ width: 320 })
expect((b.settingsOwner() as { wide: boolean }).wide).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
b.rerender({ collapsed: true, width: 56 })
@@ -253,6 +255,8 @@ describe('SidebarRoot', () => {
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
// The settings seat share tracks the settled column state.
expect((b.settingsOwner() as { wide: boolean }).wide).toBe(false)
})
})