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
@@ -49,7 +49,7 @@
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
.railIn .footArea {
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
}
@@ -372,45 +372,11 @@
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
margins fold into the row so the hover pill spans the full 49px. */
.foot {
/* Foot seat: pure layout — the flex slot pinning the sidebar.settings slot
content to the column bottom. Row visuals belong to the slot occupant
(ui-settings). */
.footArea {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 49px;
margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */
padding: 0 2px 0 6px;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
}
.foot:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Rail settings: the same 36x36 circle box as the other rail controls. */
.collapsed .foot {
width: 36px;
height: 36px;
margin: 18px 0 10px;
justify-content: center;
gap: 0;
padding: 0;
border-radius: 50%;
}
.footLabel {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
}
.collapsed .footLabel {
max-width: 0;
}
@media (prefers-reduced-motion: reduce) {
@@ -419,7 +385,7 @@
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
.railIn .footArea {
transition: none;
animation: none;
}
@@ -12,7 +12,7 @@ import clsx from 'clsx'
import {
BrandWordmark, FishLogo,
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
IconProjectAddOutline16, IconSearchOutline16,
Menu, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
@@ -314,9 +314,10 @@ export function SidebarRoot({
)}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
{/* Foot seat: the flex slot pinning the settings entry to the column
bottom; ui-settings occupies it with the trigger row + panel. */}
<div className={css.footArea}>
{renderSlot('sidebar.settings', { wide })}
</div>
</div>
)
@@ -20,9 +20,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* is claiming); ui-workspace registers the picker.
*/
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
/**
* The settings seat at the sidebar foot. Declared by this package's
* 'sidebar' entry; ui-settings registers its trigger row + modal panel.
* The sidebar passes only its column state — it holds no settings state.
*/
'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps }
}
}
/**
* Owner share of the sidebar settings seat: the column display state the
* occupant's trigger row must render against (wide row vs rail icon).
*/
export interface SidebarSettingsOwnerProps {
/** Whether the sidebar renders wide content (false = 56px rail). */
wide: boolean
}
/**
* Owner share of the sidebar workspace hole: popover geometry plus the
* sidebar's pick semantics. The picked Host Workspace is already real; the
@@ -62,8 +77,9 @@ export type SidebarRootInjected = {
/**
* Full component props: layout owner state/actions plus global useSessions
* and useWorkspaces, the declared Workspace picker render share, and this
* package's injected callback. No store is registered.
* and useWorkspaces, the declared child-slot render shares (Workspace picker
* and settings seat), and this package's injected callback. No store is
* registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace' | 'sidebar.settings'> & SidebarRootInjected
@@ -3,7 +3,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSettingsOwnerProps, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
@@ -20,9 +20,12 @@ export function apply(ctx: ClientContext): void {
ctx.effect(
() => ctx.slots.register({
name: 'sidebar',
// SidebarRoot owns this picker site; ui-workspace registers the shared
// picker that selects a Host Workspace for a frontend Session Intent.
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
// SidebarRoot owns these sites; ui-workspace registers the shared
// picker, ui-settings registers the settings trigger + panel.
children: {
'sidebar.workspace': { kind: 'single', scope: 'root' },
'sidebar.settings': { kind: 'single', scope: 'root' },
},
inject: injectProps,
}, SidebarRoot),
'ui-sidebar: slot registration',