Files
deepseek-harness/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
T
imccyu b1b180e098 fix(gui): CI gate repairs — docs, coverage, HMR re-registration
Regenerate the module-graph/config-catalog/event-graph docs for the
locale rename and new packages; allowlist the three settings READMEs;
complete the RFC code block and add its English pair. Cover the
ThemeService media-query paths (stubbed matchMedia) and mark the
unreachable registry fallback. General section re-registration now
judges presence on the slot ledger instead of a local disposer, which
went stale when an HMR collapse removed the entry.
2026-07-26 00:57:21 +08:00

7.0 KiB
Raw Blame History

Agent Note: Client Settings, Locale, and Theme layering

Status: proposed

English | 中文

Problem

The browser client's existing Settings is written directly inside the Sidebar, and language and theme are applied by component-local state mutating the DOM directly. As a result Settings cannot be extended by independent plugins, preference state has no stable cross-plugin service contract, and the theme registry carries both state and presentation responsibilities.

Proposal

The Sidebar declares the sidebar.settings single slot; ui-settings occupies it and declares the settings.section list slot. Each section is contributed by an independent plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via only.

The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind.

@deepseek-ai/dsh-client-locale provides ctx.locale; ui-theme provides ctx.theme. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default).

General's apply layer subscribes to locale/change and theme/change and projects the snapshots into the Zustand store declared by that section. React components only read useStore and write through the injected setter callbacks, never reading ctx or the services.

The theme preference has three states — light, dark, system — defaulting to system (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the prefers-color-scheme matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both preference and the resolved active definition.

The theme service never touches the DOM. ui-layout reads the Theme getter initially and then subscribes to theme/change; the presenter owned by Layout updates body[data-ds-dark-theme] and the theme tokens according to active. The presenter has no notion of system — it consumes only resolved results.

First-phase section scope

section Plugin First-phase content
General ui-settings-general Language (Selector dropdown) and Appearance (Light/Dark/System three cubes) genuinely switch; Permission and Tool Call are visual skeletons only, with no write operations
Models ui-settings-models Navigation item only; the content area is empty
Plugin no package Not built this phase, and the navigation does not show the item (an external-link entry with no target never renders; once a later plugin registers the section it appears automatically)

The first phase localizes only the copy inside the Settings overlay (the General rows plus the navigation); copy on other pages is untouched.

Slot topology

root
└─ sidebar
   └─ sidebar.settings                 single/root
      └─ ui-settings
         └─ settings.section           list/root
            ├─ general                 ui-settings-general
            └─ models                  ui-settings-models

Section contributions use declaration-aware deferral and do not depend on the client manifest's apply order.

Service contracts

type ThemePreference = 'light' | 'dark' | 'system'

interface ThemeDefinition {
  id: string
  colorScheme: 'light' | 'dark'
  tokens: Record<string, string>
}

interface ThemeSnapshot {
  preference: ThemePreference
  active: ThemeDefinition            // system 已解析为具体 light/dark 定义
  themes: readonly ThemeDefinition[]
  revision: number
}

interface LocaleDefinition {
  id: 'zh' | 'en'
  label: string
}

interface LocaleSnapshot {
  active: 'zh' | 'en'
  locales: readonly LocaleDefinition[]
  revision: number
}

interface Events {
  /** @param snapshot - Current locale registry snapshot. @mode emit */
  'locale/change'(snapshot: LocaleSnapshot): void
  /** @param snapshot - Current theme registry snapshot. @mode emit */
  'theme/change'(snapshot: ThemeSnapshot): void
}

Locale ships with 中文 and English built in; setLocale/setTheme are the only write entry points, and an unknown id fails.

Alternatives considered

Having the app shell subscribe to preferences centrally and re-render the root slot tree. A language or theme change only needs to update the actual consumers; a whole-tree refresh amplifies the blast radius and wires business preferences into the shell.

The theme service mutating the DOM directly. The registry service would then depend on the presentation environment, with unclear lifecycle and global-style ownership; Layout already owns the page-root presentation boundary.

Resolving system in the Layout presenter. The presenter would need its own matchMedia subscription and would pick the concrete definition out of the themes list, forcing the presentation layer to understand preference semantics; resolving on the service side gives every consumer the same resolved snapshot.

Settings importing and enumerating the sections. Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin.

Injecting the Locale/Theme snapshots into React directly. Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding.

Acceptance criteria

  • The Settings shell depends only on the slot ledger, never on any section implementation.
  • Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events.
  • The General store initializes from the getters and is thereafter updated by the two events with local re-renders.
  • Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter.
  • 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately.
  • Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes.
  • The overlay closes via the close button, a mask click, and ESC.

Risks

The apply order of slot declarations and contributions is not fixed, so every new section must keep declaration-aware registration and idempotence guards. Service events may fire before a section's first render, so both the General store's init and the controller attach must align to the current snapshot from the getters. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR.