refactor(gui): features register their own settings surfaces

Settings collaboration direction (recorded in the note): the shell only
provides composition faces — feature plugins register themselves. The
General section moves into the ui-settings shell (order 0, skeleton
rows) and declares the settings.general.item list slot; locale registers
the Language row and ui-theme the Appearance row (each with its own
store mirror, dictionaries, and ledger-judged deferral); the
ui-settings-general package is gone. ui-settings-models becomes
ui-models — a feature package that contributes its Settings section
rather than a settings-owned satellite. The item-slot SlotMap entry is
authored in the ui-settings contract and repeated verbatim in
locale/ui-theme (reference-cycle avoidance; declaration merging keeps
the copies identical).
This commit is contained in:
imccyu
2026-07-26 02:51:36 +08:00
parent 2ee4cda066
commit 23a60ade67
62 changed files with 1008 additions and 1049 deletions

View File

@@ -1,6 +1,6 @@
{
"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)",
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -23,18 +23,29 @@
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"inject": [
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
@@ -47,5 +58,8 @@
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"dependencies": {
"clsx": "^2.0.0"
}
}

View File

@@ -0,0 +1,47 @@
/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator;
* the section column removes the separator on its last child). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,68 @@
/**
* Language preference row registered into the General section item slot
* (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale
* menu. Registered by this package — the locale feature owns its own
* settings surface.
*/
import { useState } from 'react'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
export interface LanguageRowInjected {
/** Translate a `settings.locale` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the active locale (a registered locale id). */
setLocale: (id: string) => void
}
/** Full component props: runtime share + store share + injected face. */
export type LanguageRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
/**
* Render the Language row.
* @param props - composed slot props.
* @returns the row element tree.
*/
export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) {
const active = useStore(s => s.active)
const options = useStore(s => s.options)
const [open, setOpen] = useState(false)
const activeLabel = options.find(o => o.id === active)?.label ?? active
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('language.title')}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={options.map(o => ({ id: o.id, label: o.label }))}
selectedId={active}
onSelect={(id) => {
setLocale(id)
setOpen(false)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(v => !v) }}
>
{activeLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
)
}

View File

@@ -1,10 +1,20 @@
/**
* Browser-side locale registry. Bound translation functions retain stable
* identity for injected consumers.
* identity for injected consumers. The plugin also registers the Language
* preference row into the settings General section — the locale feature owns
* its own settings surface.
*/
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
import type { LanguageRowInjected } from './LanguageRow.tsx'
import { LanguageRow } from './LanguageRow.tsx'
import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
@@ -53,6 +63,9 @@ export const FALLBACK_LOCALE: LocaleId = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.locale'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
@@ -183,16 +196,65 @@ function persistPreference(id: LocaleId): void {
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/** Required services: the slot registry (the feature registers its own settings row). */
export const inject = ['slots']
/**
* Client plugin body: provide the locale service with base dictionaries.
* Client plugin body: provide the locale service with base dictionaries and
* register the feature-owned Language preference row into the General
* section's item slot (a feature owns its settings surface).
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
locale.register(COMMON_NS, 'zh', zh)
locale.register(COMMON_NS, 'en', en)
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
ctx.provide('locale', locale)
const store = createLanguageRowStore()
let bound: BoundActions<typeof store> | undefined
const sync = (snapshot: LocaleSnapshot): void => {
bound?.sync(
snapshot.active,
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
snapshot.revision,
)
}
ctx.on('locale/change', sync)
const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => {
bound = actions
// Re-sync from the getter so no event is lost between registration and
// first render (the store's revision guard drops stale duplicates).
sync(locale.getLocale())
return {
t: locale.bind(SETTINGS_NS),
setLocale: (id) => { locale.setLocale(id) },
}
}
// Declaration-aware registration; the LEDGER is the has-registered judge
// (not a local flag): after an HMR collapse re-declares the slot, the
// cascade already removed our entry, and a stale disposer must not block
// the re-registration.
ctx.effect(() => {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
if (ctx.slots.spec('settings.general.item') === undefined) return
if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return
dispose = ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
inject: injected,
}, LanguageRow)
}
const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() })
tryRegister()
return () => {
unsubscribe()
dispose?.()
}
}, 'locale: language settings row registration')
}

View File

@@ -0,0 +1,18 @@
/**
* Settings-surface slot merge consumed by this package's Language row. The
* AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract
* (declaring is claiming: the shell's General entry declares the slot); this
* file repeats the entry verbatim because the shell consumes ctx.locale
* (project reference ui-settings -> locale), so importing the shell's types
* from here would close a reference cycle. TypeScript declaration merging
* rejects diverging duplicates, so every program that sees both copies (the
* shell's own build, the client aggregate) enforces identity.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
}
}
export {}

View File

@@ -0,0 +1,47 @@
/**
* Language row slot store: a mirror of the locale service snapshot. The
* plugin's apply-world change listener is the only writer; the row component
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
/** One selectable locale row (id + self-described label). */
export interface LanguageOptionRow {
/** Locale id (the setLocale argument). */
id: string
/** Display name in its own language (中文 / English). */
label: string
}
/** Store state mirrored from the locale snapshot. */
export interface LanguageRowState {
/** Active locale id. */
active: string
/** Selectable locales in display order. */
options: LanguageOptionRow[]
/** Service revision; -1 until first sync so revision 0 lands as a change. */
revision: number
}
/** Declared action shape giving the exported factory a stable return type. */
type LanguageRowActions = {
sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void
}
/**
* Declares the Language row state and write surface.
* @returns the store handle.
*/
export function createLanguageRowStore(): EngineStoreHandle<LanguageRowState, LanguageRowActions> {
return defineStore({
init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }),
actions: {
sync: (d, active: string, options: LanguageOptionRow[], revision: number) => {
if (revision <= d.revision) return
d.active = active
d.options = options
d.revision = revision
},
},
})
}

View File

@@ -1,8 +1,10 @@
// @vitest-environment jsdom
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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
@@ -18,8 +20,10 @@ describe('invariant companion', () => {
})
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
expect(inject).toEqual([])
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)

View File

@@ -8,6 +8,15 @@
"src"
],
"references": [
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},

View File

@@ -1,4 +1,4 @@
# @deepseek-ai/dsh-client-ui-settings-models
# @deepseek-ai/dsh-client-ui-models
Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-models",
"description": "Models settings section plugin: nav entry with an empty content column (model management lands later)",
"name": "@deepseek-ai/dsh-client-ui-models",
"description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void {
ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-models: nav copy dictionaries')
}, 'ui-models: nav copy dictionaries')
// Declaration-aware registration; the LEDGER is the has-registered judge
// (not a local flag): after an HMR collapse re-declares the slot, the
// cascade already removed our entry, and a stale disposer must not block
@@ -63,5 +63,5 @@ export function apply(ctx: ClientContext): void {
unsubscribe()
dispose?.()
}
}, 'ui-settings-models: section registration')
}, 'ui-models: settings section registration')
}

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`.
* @module @deepseek-ai/dsh-client-ui-settings-models/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-models`.
* @module @deepseek-ai/dsh-client-ui-models/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-models'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models'
/** Cordis companion plugin name. */
export const name = 'client-ui-settings-models-invariant'
export const name = 'client-ui-models-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-models/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
async function bench() {
@@ -21,7 +21,7 @@ function declare(slots: SlotsService): () => void {
)
}
describe('ui-settings-models apply', () => {
describe('ui-models apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant'
import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
@@ -12,7 +12,7 @@ describe('invariant companion', () => {
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models')
const { apply } = await import('@deepseek-ai/dsh-client-ui-models')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-models', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,15 +0,0 @@
# @deepseek-ai/dsh-client-ui-settings-general
General settings section plugin: registers the `general` entry into `settings.section`. Language (中文/English) and Appearance (Light/Dark/System) are live preferences wired to `ctx.locale` / `ctx.theme`; Permission and Tool Call rows are visual skeletons with no write surface.
## Model Experience
None, as the section renders browser preference UI; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing.

View File

@@ -1,70 +0,0 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-general",
"description": "General settings section plugin: Language and Appearance preferences (live), Permission and Tool Call skeleton rows",
"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": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "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",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -1,118 +0,0 @@
/**
* General settings section: Permission and Tool Call skeleton rows (visual
* only, no interaction), live Language and Appearance preference rows wired
* through the injected setLocale/setTheme callbacks and the snapshot-mirror
* store. Figma: Settings > Content > Options (501:29983).
*/
import { useState } from 'react'
import clsx from 'clsx'
import {
IconChevronDownOutline14, IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GeneralSectionComponentProps, ThemePreferenceId } from './contract.ts'
import css from './GeneralSection.module.css'
/** Appearance cube order and icons (figma 501:30015-30017: Light, Dark, System). */
const THEME_CUBES: readonly { id: ThemePreferenceId; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
]
/**
* Render the General section content column.
* @param props - composed slot props (contract.ts).
* @returns the section element tree.
*/
export function GeneralSection(props: GeneralSectionComponentProps) {
const { t, setLocale, setTheme, useStore } = props
const localeActive = useStore(s => s.localeActive)
const localeOptions = useStore(s => s.localeOptions)
const themePreference = useStore(s => s.themePreference)
const [languageOpen, setLanguageOpen] = useState(false)
const activeLocaleLabel = localeOptions.find(l => l.id === localeActive)?.label ?? localeActive
return (
<div className={css.section}>
{/* Permission (skeleton): disabled selector pill. */}
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('permission.title')}</div>
<div className={css.desc}>{t('permission.desc')}</div>
</div>
<button type="button" className={css.selector} disabled>
{t('permission.value')}
<IconChevronDownOutline14 className={css.chevron} />
</button>
</div>
{/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */}
<div className={css.group}>
<div className={css.title}>{t('toolcall.title')}</div>
<div className={css.cubeRow}>
<div className={clsx(css.modeCube, css.selected)}>
<div className={css.title}>{t('toolcall.schema.title')}</div>
<div className={css.desc}>{t('toolcall.schema.desc')}</div>
</div>
<div className={css.modeCube}>
<div className={css.title}>{t('toolcall.code.title')}</div>
<div className={css.desc}>{t('toolcall.code.desc')}</div>
</div>
</div>
</div>
{/* Language: selector pill opens the locale menu. */}
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('language.title')}</div>
</div>
<Menu
open={languageOpen}
onClose={() => { setLanguageOpen(false) }}
items={localeOptions.map(l => ({ id: l.id, label: l.label }))}
selectedId={localeActive}
onSelect={(id) => {
setLocale(id)
setLanguageOpen(false)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={languageOpen}
onClick={() => { setLanguageOpen(v => !v) }}
>
{activeLocaleLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
{/* Appearance: three preference cubes; selection follows the persisted
* preference, never the resolved active theme. */}
<div className={clsx(css.group, css.last)}>
<div className={css.title}>{t('appearance.title')}</div>
<div className={css.cubeRow}>
{THEME_CUBES.map(({ id, labelKey, Icon }) => (
<button
key={id}
type="button"
className={clsx(css.themeCube, themePreference === id && css.selected)}
aria-pressed={themePreference === id}
onClick={() => { setTheme(id) }}
>
<Icon />
{t(labelKey)}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,66 +0,0 @@
/**
* General section component contract: the slot-store state shape, the
* injected business face, and the composed props type. The component imports
* only from here; service snapshot shapes are mirrored as plain rows so the
* presentation layer stays decoupled from the locale/theme packages.
*/
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { createGeneralSettingsStore } from './store.ts'
/** One selectable locale row projected into the store (id + self-described label). */
export interface LocaleOptionRow {
/** Locale id (the setLocale argument). */
id: string
/** Display name in its own language (中文 / English). */
label: string
}
/** Theme preference union mirrored from the theme service snapshot. */
export type ThemePreferenceId = 'light' | 'dark' | 'system'
/**
* Store state: mirrors of the locale/theme service snapshots, written only by
* the plugin's apply-world change listeners (components have no write path —
* preference writes go through the injected callbacks to the services, and
* the resulting change events flow back into this mirror).
*/
export interface GeneralSettingsState {
/** Active locale id. */
localeActive: string
/** Selectable locales in display order. */
localeOptions: LocaleOptionRow[]
/** Locale service revision (re-renders translated copy on dictionary/locale changes); -1 until first sync. */
localeRevision: number
/** Persisted theme preference (selection state reads this, never the resolved active theme). */
themePreference: ThemePreferenceId
/** Theme service revision; -1 until first sync. */
themeRevision: number
}
/**
* Registrant-private injected share of the General section (assembled in
* apply): the namespace-bound translate function (stable identity — re-render
* on locale change comes from the store revision, not from `t`) and the two
* preference write callbacks.
*/
export interface GeneralSectionInjected {
/** Translate a `settings.general` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the active locale (a registered locale id). */
setLocale: (id: string) => void
/** Switch the theme preference. */
setTheme: (id: ThemePreferenceId) => void
}
/** Store handle type for the props share (type-only; the factory stays internal to apply and tests). */
export type GeneralSettingsStoreHandle = ReturnType<typeof createGeneralSettingsStore>
/**
* Full component props of the General section: the section owner share
* (empty marker) plus the store share and the injected face. No child slots
* are declared; menu open state is component-local viewing state.
*/
export type GeneralSectionComponentProps =
PropsRuntime<'settings.section'> & PropsStore<GeneralSettingsStoreHandle> & GeneralSectionInjected

View File

@@ -1,117 +0,0 @@
/**
* General settings section plugin, browser half. Registers the `general`
* entry into the shell-declared `settings.section` list slot; Language and
* Appearance are live preferences projected from ctx.locale / ctx.theme
* through this entry's slot store. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: the locale/theme Context+Events merges and snapshot shapes.
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { GeneralSectionInjected } from './contract.ts'
import { createGeneralSettingsStore } from './store.ts'
import { en, zh } from './locales.ts'
import { GeneralSection } from './GeneralSection.tsx'
export type {
GeneralSectionComponentProps, GeneralSectionInjected, GeneralSettingsState,
GeneralSettingsStoreHandle, LocaleOptionRow, ThemePreferenceId,
} from './contract.ts'
/** Dictionary namespace owned by this section (also the nav-label reference prefix). */
const NS = 'settings.general'
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
*/
export const inject = ['slots', 'locale', 'theme']
/**
* Register the `settings.general` dictionaries and the General section entry
* once the `settings.section` declaration is on the ledger. The slot store
* mirrors the locale/theme snapshots: change listeners attach here in apply,
* write through the bound actions captured at inject time, and the inject
* factory re-syncs from the getters so no event is lost between registration
* and first render (the store's revision guard drops stale duplicates).
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposeZh = ctx.locale.register(NS, 'zh', zh)
const disposeEn = ctx.locale.register(NS, 'en', en)
return () => {
disposeZh()
disposeEn()
}
}, 'ui-settings-general: dictionaries')
const store = createGeneralSettingsStore()
let bound: BoundActions<typeof store> | undefined
const syncLocale = (snapshot: LocaleSnapshot): void => {
bound?.syncLocale(
snapshot.active,
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
snapshot.revision,
)
}
const syncTheme = (snapshot: ThemeSnapshot): void => {
bound?.syncTheme(snapshot.preference, snapshot.revision)
}
ctx.on('locale/change', syncLocale)
ctx.on('theme/change', syncTheme)
const injected = (actions: BoundActions<typeof store>): GeneralSectionInjected => {
bound = actions
syncLocale(ctx.locale.getLocale())
syncTheme(ctx.theme.getTheme())
return {
t: ctx.locale.bind(NS),
setLocale: (id) => { ctx.locale.setLocale(id) },
setTheme: (id) => { ctx.theme.setTheme(id) },
}
}
ctx.effect(() => {
let dispose: (() => void) | undefined
// Presence is judged on the ledger, not on the local disposer: an HMR
// collapse of the declaring entry removes this entry from the slot core
// while `dispose` stays set (the stale disposer is a no-op), so a local
// guard would block the re-registration when the declaration returns.
const registered = (): boolean =>
ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)
const tryRegister = (): void => {
if (ctx.slots.spec('settings.section') === undefined || registered()) return
dispose = ctx.slots.register({
name: 'settings.section',
id: 'general',
order: 0,
label: ctx.locale.bind(NS)('nav'),
store,
inject: injected,
}, GeneralSection)
}
// Nav labels are registrant-localized: re-register on locale change so
// the ledger carries fresh text (the version bump re-renders the shell).
// The ledger check mirrors tryRegister: after an HMR collapse `dispose`
// stays set while the entry is gone — relabeling then must stay quiet.
const offLocale = ctx.on('locale/change', () => {
if (dispose === undefined || !registered()) return
dispose()
dispose = undefined
tryRegister()
})
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
tryRegister()
return () => {
offLocale()
unsubscribe()
dispose?.()
}
}, 'ui-settings-general: section registration')
}

View File

@@ -1,43 +0,0 @@
/**
* General section slot store: locale/theme snapshot mirrors. The plugin
* creates the handle at apply time (identity follows the fiber) and its
* change listeners are the only writers; components read via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { GeneralSettingsState, LocaleOptionRow, ThemePreferenceId } from './contract.ts'
/** Declared action shape used to give the exported factory a stable return type. */
type GeneralSettingsActions = {
syncLocale: (draft: GeneralSettingsState, active: string, options: LocaleOptionRow[], revision: number) => void
syncTheme: (draft: GeneralSettingsState, preference: ThemePreferenceId, revision: number) => void
}
/**
* Declares the General section state and write surface. Revisions start at -1
* so the apply-time initial sync (revision 0) always lands as a change.
* @returns the store handle.
*/
export function createGeneralSettingsStore(): EngineStoreHandle<GeneralSettingsState, GeneralSettingsActions> {
return defineStore({
init: (): GeneralSettingsState => ({
localeActive: '',
localeOptions: [],
localeRevision: -1,
themePreference: 'system',
themeRevision: -1,
}),
actions: {
syncLocale: (d, active: string, options: LocaleOptionRow[], revision: number) => {
if (revision <= d.localeRevision) return
d.localeActive = active
d.localeOptions = options
d.localeRevision = revision
},
syncTheme: (d, preference: ThemePreferenceId, revision: number) => {
if (revision <= d.themeRevision) return
d.themePreference = preference
d.themeRevision = revision
},
},
})
}

View File

@@ -1,4 +0,0 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the general settings plugin. */
export function apply(): void {}

View File

@@ -1,32 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`.
* @module @deepseek-ai/dsh-client-ui-settings-general/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general'
/** Cordis companion plugin name. */
export const name = 'client-ui-settings-general-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a section plugin projecting two service change events
* into its own slot store — it emits no cordis events of its own and owns no
* cross-plugin mutable relation; snapshot/store agreement is asserted 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 */

View File

@@ -1,139 +0,0 @@
/** apply wiring: dictionary registration, declaration-aware section entry,
* snapshot projection into the slot store, locale-driven relabeling, and
* recovery after an HMR collapse of the declaring entry. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import type { createGeneralSettingsStore } from '../src/client/store.ts'
const NS = 'settings.general'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
const theme = new ThemeService(ctx)
ctx.provide('locale', locale)
ctx.provide('theme', theme)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, theme }
}
/** Stand in for the settings shell: declare the section list slot from root. */
function declareSection(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never,
() => null,
)
}
/** Mirror the framework's inject choreography: bake a real instance from the
* declared handle and hand its actions to the entry's inject factory. */
function faceOf(slots: SlotsService) {
const entry = slots.entries('settings.section')[0]!
const handle = entry.store as ReturnType<typeof createGeneralSettingsStore>
const instance = handle.create()
const face = (entry.inject as unknown as (a: typeof instance.actions) => GeneralSectionInjected)(instance.actions)
return { entry, instance, face }
}
describe('ui-settings-general apply', () => {
it('declares the slot, locale, and theme services', () => {
expect(inject).toEqual(['slots', 'locale', 'theme'])
})
it('registers dictionaries and the section entry for declarations before or after apply', async () => {
const before = await bench()
declareSection(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
const entry = before.slots.entries('settings.section')[0]!
expect(entry.component).toBe(GeneralSection)
expect(entry.options).toMatchObject({ id: 'general', order: 0, label: '通用设置' })
expect(before.locale.bind(NS)('nav')).toBe('通用设置')
const after = await bench()
const fiber = after.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(after.slots.entries('settings.section')).toHaveLength(0)
declareSection(after.slots)
await Promise.resolve()
expect(after.slots.entries('settings.section')[0]!.component).toBe(GeneralSection)
// Teardown without a live registration exercises the undefined-disposer arm.
await fiber.dispose()
expect(after.slots.entries('settings.section')).toHaveLength(0)
})
it('projects service snapshots into the store and routes face writes back', async () => {
const b = await bench()
declareSection(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
// Events ahead of any inject hit the unbound-actions arm without a store.
b.theme.setTheme('dark')
const { instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: both mirrors are current.
expect(instance.getSnapshot().localeActive).toBe('zh')
expect(instance.getSnapshot().localeOptions.map(l => l.id)).toEqual(['zh', 'en'])
expect(instance.getSnapshot().themePreference).toBe('dark')
expect(face.t('nav')).toBe('通用设置')
face.setLocale('en')
expect(b.locale.getLocale().active).toBe('en')
expect(instance.getSnapshot().localeActive).toBe('en')
expect(face.t('nav')).toBe('General')
face.setTheme('system')
expect(b.theme.getTheme().preference).toBe('system')
expect(instance.getSnapshot().themePreference).toBe('system')
})
it('re-registers with a fresh ledger label when the locale changes', async () => {
const b = await bench()
declareSection(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置')
b.locale.setLocale('en')
const entry = b.slots.entries('settings.section')[0]!
expect(entry.options.label).toBe('General')
expect(entry.component).toBe(GeneralSection)
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
const b = await bench()
const host = declareSection(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('settings.section')).toHaveLength(1)
// Collapse: the declarer dies, the cascade removes our entry while the
// apply closure still holds its (now stale) disposer.
host()
expect(b.slots.entries('settings.section')).toHaveLength(0)
// A locale change inside the collapsed window must stay quiet.
b.locale.setLocale('en')
expect(b.slots.entries('settings.section')).toHaveLength(0)
// Redeclaration restores the entry — with the current locale's label.
declareSection(b.slots)
await Promise.resolve()
const entry = b.slots.entries('settings.section')[0]!
expect(entry.component).toBe(GeneralSection)
expect(entry.options.label).toBe('General')
})
it('removes the entry and the dictionaries on teardown', async () => {
const b = await bench()
declareSection(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries('settings.section')).toHaveLength(1)
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
// Dictionary disposal: translation falls back to the bare key.
expect(b.locale.bind(NS)('nav')).toBe('nav')
})
})

View File

@@ -1,112 +0,0 @@
// @vitest-environment jsdom
/** GeneralSection behavior: skeleton rows stay inert, Language menu drives
* setLocale, Appearance cubes follow the preference and drive setTheme. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { createGeneralSettingsStore } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
import type { GeneralSectionComponentProps } from '../src/client/contract.ts'
afterEach(cleanup)
const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the section reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
function mount(init?: { active?: string; preference?: 'light' | 'dark' | 'system' }) {
// Real store instance — the sanctioned zero-machinery path for tests.
const store = createGeneralSettingsStore().create()
store.actions.syncLocale(init?.active ?? 'en', LOCALES, 0)
store.actions.syncTheme(init?.preference ?? 'system', 0)
const setLocale = vi.fn()
const setTheme = vi.fn()
const props: GeneralSectionComponentProps = {
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
t: (key: string) => en[key] ?? key,
setLocale,
setTheme,
}
render(<GeneralSection {...props} />)
return { store, setLocale, setTheme }
}
const pressed = (name: RegExp): string | null =>
screen.getByRole('button', { name }).getAttribute('aria-pressed')
describe('GeneralSection', () => {
it('renders the four groups with skeleton rows inert', () => {
const b = mount()
// Permission: disabled selector showing the fixed value.
const permission = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
expect(permission.disabled).toBe(true)
fireEvent.click(permission)
// Tool Call: both mode cubes render as plain text, no buttons.
expect(screen.getByText('Schema mode')).toBeDefined()
expect(screen.getByText('Code mode')).toBeDefined()
expect(screen.queryByRole('button', { name: /Schema mode/ })).toBeNull()
expect(b.setLocale).not.toHaveBeenCalled()
expect(b.setTheme).not.toHaveBeenCalled()
})
it('opens the language menu, selects a locale, and closes', () => {
const b = mount({ active: 'en' })
const trigger = screen.getByRole('button', { name: /English/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('menuitem', { name: '中文' }))
expect(b.setLocale).toHaveBeenCalledWith('zh')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
})
it('closes the language menu on outside pointerdown without selecting', () => {
const b = mount({ active: 'en' })
const trigger = screen.getByRole('button', { name: /English/ })
fireEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined()
fireEvent.pointerDown(document.body)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
expect(b.setLocale).not.toHaveBeenCalled()
})
it('reflects a store locale change in the trigger label (unknown id falls back to the id)', () => {
const b = mount({ active: 'en' })
act(() => { b.store.actions.syncLocale('zh', LOCALES, 1) })
expect(screen.getByRole('button', { name: /中文/ })).toBeDefined()
act(() => { b.store.actions.syncLocale('fr', LOCALES, 2) })
expect(screen.getByRole('button', { name: /fr/ })).toBeDefined()
})
it('marks the appearance cube matching the preference and switches on click', () => {
const b = mount({ preference: 'dark' })
expect(pressed(/Dark/)).toBe('true')
expect(pressed(/Light/)).toBe('false')
expect(pressed(/System/)).toBe('false')
fireEvent.click(screen.getByRole('button', { name: /Light/ }))
expect(b.setTheme).toHaveBeenCalledWith('light')
// Selection follows the store mirror, not the click echo.
act(() => { b.store.actions.syncTheme('light', 1) })
expect(pressed(/Light/)).toBe('true')
expect(pressed(/Dark/)).toBe('false')
})
})

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/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(GeneralInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View File

@@ -1,56 +0,0 @@
/** General settings store: snapshot-mirror actions and the revision guard. */
import { describe, expect, it } from 'vitest'
import { createGeneralSettingsStore } from '../src/client/store.ts'
const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
describe('createGeneralSettingsStore', () => {
it('init shape: empty mirrors with revisions at -1', () => {
const store = createGeneralSettingsStore().create()
expect(store.getSnapshot()).toEqual({
localeActive: '',
localeOptions: [],
localeRevision: -1,
themePreference: 'system',
themeRevision: -1,
})
})
it('syncLocale mirrors the snapshot and advances the revision', () => {
const store = createGeneralSettingsStore().create()
store.actions.syncLocale('zh', LOCALES, 0)
expect(store.getSnapshot().localeActive).toBe('zh')
expect(store.getSnapshot().localeOptions).toEqual(LOCALES)
expect(store.getSnapshot().localeRevision).toBe(0)
store.actions.syncLocale('en', LOCALES, 1)
expect(store.getSnapshot().localeActive).toBe('en')
expect(store.getSnapshot().localeRevision).toBe(1)
})
it('syncLocale revision guard drops stale and duplicate writes', () => {
const store = createGeneralSettingsStore().create()
store.actions.syncLocale('en', LOCALES, 5)
// Stale (lower) and duplicate (equal) revisions leave the mirror intact.
store.actions.syncLocale('zh', LOCALES, 4)
store.actions.syncLocale('zh', LOCALES, 5)
expect(store.getSnapshot().localeActive).toBe('en')
expect(store.getSnapshot().localeRevision).toBe(5)
})
it('syncTheme mirrors the preference and guards its revision independently', () => {
const store = createGeneralSettingsStore().create()
store.actions.syncTheme('dark', 0)
expect(store.getSnapshot().themePreference).toBe('dark')
expect(store.getSnapshot().themeRevision).toBe(0)
store.actions.syncTheme('light', 2)
expect(store.getSnapshot().themePreference).toBe('light')
// Stale theme write is dropped; the locale revision axis is untouched.
store.actions.syncTheme('system', 1)
expect(store.getSnapshot().themePreference).toBe('light')
expect(store.getSnapshot().themeRevision).toBe(2)
expect(store.getSnapshot().localeRevision).toBe(-1)
})
})

View File

@@ -1,36 +0,0 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../ui-primitives"
},
{
"path": "../runtime"
},
{
"path": "../ui-settings"
},
{
"path": "../locale"
},
{
"path": "../ui-theme"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,3 +0,0 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,3 +0,0 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,6 +1,7 @@
/* General section rows (figma 501:29983 'Options'): four groups, 16px
* vertical padding each, hairline separator under all but the last. The
* shell's content column owns the outer horizontal padding. */
/* General section rows (figma 501:29983 'Options'): stacked groups, 16px
* vertical padding each, hairline separator under all but the last child
* (feature-contributed rows carry their own row chrome and separators; the
* :last-child rule strips the trailing one wherever the column ends). */
.section {
display: flex;
@@ -8,6 +9,10 @@
width: 100%;
}
.section > :last-child {
border-bottom: none;
}
/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */
.row {
display: flex;
@@ -26,10 +31,6 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.last {
border-bottom: none;
}
/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */
.rowText {
flex: 1;
@@ -79,7 +80,7 @@
flex: none;
}
/* Cube rows share an 8px gap; cubes stretch to equal height. */
/* Tool Call mode cubes share an 8px gap. */
.cubeRow {
display: flex;
align-items: stretch;
@@ -102,27 +103,6 @@
text-align: left;
}
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
* icon-over-label column, gap 4). */
.themeCube {
box-sizing: border-box;
width: 276px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 20px 32px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: transparent;
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token the bluish-400
* step has no alias-layer name). */
.selected {

View File

@@ -0,0 +1,51 @@
/**
* Shell-owned General section (figma 501:29983 'Options'): Permission and
* Tool Call skeleton rows, then the feature-contributed preference rows from
* the `settings.general.item` slot (locale → Language, ui-theme →
* Appearance). The section column stacks rows; each row draws its own
* internals and separator.
*/
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { GeneralSectionComponentProps } from './contract/slots.ts'
import css from './GeneralSection.module.css'
/**
* Render the General section content column.
* @param props - composed slot props (contract/slots.ts).
* @returns the section element tree.
*/
export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) {
return (
<div className={css.section}>
{/* Permission (skeleton): disabled selector pill. */}
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('permission.title')}</div>
<div className={css.desc}>{t('permission.desc')}</div>
</div>
<button type="button" className={css.selector} disabled>
{t('permission.value')}
<IconChevronDownOutline14 className={css.chevron} />
</button>
</div>
{/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */}
<div className={css.group}>
<div className={css.title}>{t('toolcall.title')}</div>
<div className={css.cubeRow}>
<div className={`${css.modeCube} ${css.selected}`}>
<div className={css.title}>{t('toolcall.schema.title')}</div>
<div className={css.desc}>{t('toolcall.schema.desc')}</div>
</div>
<div className={css.modeCube}>
<div className={css.title}>{t('toolcall.code.title')}</div>
<div className={css.desc}>{t('toolcall.code.desc')}</div>
</div>
</div>
</div>
{/* Feature-owned preference rows (Language, Appearance, …). */}
{renderSlot('settings.general.item', {})}
</div>
)
}

View File

@@ -1,7 +1,11 @@
/**
* Settings shell slot contract: the shell occupies the sidebar-owned
* `sidebar.settings` hole and declares the `settings.section` list slot that
* section plugins (General, Models, …) contribute pages into.
* Settings shell slot contract. The shell occupies the sidebar-owned
* `sidebar.settings` hole, declares the `settings.section` list slot that
* feature plugins contribute top-level pages into, and ships the first
* section itself: General, whose `settings.general.item` list slot receives
* preference rows from the features that own them (locale → Language,
* ui-theme → Appearance). A feature owns its settings surface — adding a
* setting never means editing the shell.
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
@@ -19,6 +23,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* re-render trigger). Sections render inside the panel content column.
*/
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
/**
* One preference row inside the General section, contributed by the
* feature plugin that owns the preference (locale → Language, ui-theme →
* Appearance). Options: `id` (row key), `order` (row position). Rows
* draw their own internals (row layout, separators via CSS); the section
* column only stacks them. NOTE: packages/client/locale and ui-theme
* repeat this entry verbatim (reference-cycle avoidance) — declaration
* merging enforces the copies stay identical; edit all three together.
*/
'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
}
}
@@ -60,3 +74,21 @@ export type SettingsRootInjected = {
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected
/**
* Injected share of the shell-owned General section: the shell's own
* `settings` namespace translate function for the skeleton rows (Permission,
* Tool Call). Live preference rows arrive through the item slot with their
* own faces.
*/
export type GeneralSectionInjected = {
/** Translate a `settings` dictionary key to the active-locale text. */
t: (key: string) => string
}
/**
* Full component props of the shell-owned General section: the section owner
* share, the declared item render share, and the injected face.
*/
export type GeneralSectionComponentProps =
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected

View File

@@ -1,17 +1,24 @@
/**
* Settings shell plugin, browser half. Occupies the sidebar-owned
* `sidebar.settings` hole with the trigger row + modal panel, declares the
* `settings.section` list slot, and projects that ledger into the panel
* navigation. Export discipline: packages/client/AGENTS.md.
* `settings.section` list slot, projects that ledger into the panel
* navigation, and ships the first section itself: General, which declares
* the `settings.general.item` slot that feature plugins contribute
* preference rows into. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context/Events merges (ctx.locale,
// 'locale/change') into this program.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { SettingsRootInjected } from './contract/slots.ts'
import type { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { en, zh } from './locales.ts'
export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts'
export type {
GeneralSectionComponentProps, GeneralSectionInjected,
SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps,
} from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
@@ -22,18 +29,20 @@ export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionO
export const inject = ['slots', 'locale']
/**
* Register the settings shell into `sidebar.settings` once the declaration is
* on the ledger.
* Register the settings shell into `sidebar.settings` and the shell-owned
* General section into `settings.section`, each once its declaration is on
* the ledger.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register('settings', 'zh', { trigger: '设置', title: '设置', close: '关闭' }),
ctx.locale.register('settings', 'en', { trigger: 'Settings', title: 'Settings', close: 'Close' }),
ctx.locale.register('settings', 'zh', zh),
ctx.locale.register('settings', 'en', en),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings: shell copy dictionaries')
const injected = (): SettingsRootInjected => ({
translate: (ref) => {
const colon = ref.indexOf(':')
@@ -73,4 +82,38 @@ export function apply(ctx: ClientContext): void {
dispose?.()
}
}, 'ui-settings: shell registration')
// The shell's own General section: first page, declares the item slot the
// feature plugins (locale, ui-theme, …) contribute preference rows into.
// Same ledger-judged deferral; label re-registers on locale change.
const generalInjected = (): GeneralSectionInjected => ({
t: ctx.locale.bind('settings'),
})
ctx.effect(() => {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
if (ctx.slots.spec('settings.section') === undefined) return
if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return
dispose = ctx.slots.register({
name: 'settings.section',
id: 'general',
order: 0,
label: ctx.locale.bind('settings')('general.nav'),
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
inject: generalInjected,
}, GeneralSection)
}
const offLocale = ctx.on('locale/change', () => {
dispose?.()
dispose = undefined
tryRegister()
})
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
tryRegister()
return () => {
offLocale()
unsubscribe()
dispose?.()
}
}, 'ui-settings: general section registration')
}

View File

@@ -1,7 +1,9 @@
/**
* `settings.general` namespace dictionaries. Skeleton-row technical copy
* `settings` namespace dictionaries: shell chrome plus the shell-owned
* General section (nav label, skeleton rows). Skeleton-row technical copy
* (Read only / Schema mode / Code mode and their descriptions) is shared
* verbatim across locales per the Figma design.
* verbatim across locales per the Figma design. Feature-owned rows
* (Language, Appearance) ship their copy in their own packages.
*/
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
@@ -16,27 +18,23 @@ const SHARED = {
/** Simplified Chinese dictionary. */
export const zh: LocaleDict = {
...SHARED,
'nav': '通用设置',
'trigger': '设置',
'title': '设置',
'close': '关闭',
'general.nav': '通用设置',
'permission.title': '权限',
'permission.desc': '选择默认权限模式',
'toolcall.title': '工具调用',
'language.title': '语言',
'appearance.title': '外观',
'appearance.light': '浅色',
'appearance.dark': '深色',
'appearance.system': '跟随系统',
}
/** English dictionary. */
export const en: LocaleDict = {
...SHARED,
'nav': 'General',
'trigger': 'Settings',
'title': 'Settings',
'close': 'Close',
'general.nav': 'General',
'permission.title': 'Permission',
'permission.desc': 'Choose default permission mode',
'toolcall.title': 'Tool Call',
'language.title': 'Language',
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
}

View File

@@ -4,8 +4,9 @@ import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { GeneralSectionInjected, SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
async function bench() {
const ctx = new Context()
@@ -77,11 +78,14 @@ describe('ui-settings apply', () => {
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots)
expect(injected.sections()).toEqual([])
// The shell ships its own General section (order 0) — the ledger is never
// empty once apply settles.
expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
b.slots.register({ name: 'settings.section', id: 'a', order: 5 } as never, () => null)
expect(injected.sections()).toEqual([
{ id: 'a', order: 0, label: '' },
{ id: 'general', order: 0, label: '通用设置' },
{ id: 'a', order: 5, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
@@ -118,3 +122,72 @@ describe('ui-settings apply', () => {
expect(b.slots.spec('settings.section')).toBeUndefined()
})
})
describe('ui-settings general section', () => {
it('registers the shell-owned General entry and declares the item slot', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.section')[0]!
expect(entry.component).toBe(GeneralSection)
expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' })
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
const injected = (entry.inject as () => GeneralSectionInjected)()
expect(injected.t('permission.title')).toBe('权限')
})
it('re-registers with fresh label text on locale change', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.locale.setLocale('en')
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General')
b.locale.setLocale('zh')
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置')
})
it('locale change while settings.section is undeclared stays a no-op', async () => {
const b = await bench()
// No sidebar.settings declaration: the shell never registers, so
// settings.section is never declared either.
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.locale.setLocale('en')
expect(b.slots.entries('settings.section')).toHaveLength(0)
b.locale.setLocale('zh')
})
it('re-registers after an HMR collapse of the whole chain (stale disposer must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('settings.section')).toHaveLength(1)
// Root declarer unload: the cascade removes the shell entry, the
// settings.section declaration, and the General entry below it.
redeclare()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.spec('settings.general.item')).toBeUndefined()
declare(b.slots)
// Two deferral hops: the shell re-registers (re-declaring
// settings.section), then General re-registers into it.
await Promise.resolve()
await Promise.resolve()
const entry = b.slots.entries('settings.section')[0]!
expect(entry.component).toBe(GeneralSection)
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
// The recovered registration still rides the locale path.
b.locale.setLocale('en')
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General')
b.locale.setLocale('zh')
})
it('removes the General entry and its item declaration on teardown', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.spec('settings.general.item')).toBeDefined()
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.spec('settings.general.item')).toBeUndefined()
})
})

View File

@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type { GeneralSectionComponentProps } from '../src/client/contract/slots.ts'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
function mount() {
const renderSlot = vi.fn(
((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
)
const props: GeneralSectionComponentProps = {
t: (key) => en[key] ?? key,
renderSlot,
}
const view = render(<GeneralSection {...props} />)
return { view, renderSlot }
}
describe('GeneralSection', () => {
it('renders the Permission skeleton row with the disabled selector', () => {
mount()
expect(screen.getByText('Permission')).toBeTruthy()
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
expect(selector.disabled).toBe(true)
})
it('renders the Tool Call skeleton cubes with schema pinned selected', () => {
mount()
expect(screen.getByText('Tool Call')).toBeTruthy()
const schema = screen.getByText('Schema mode')
const code = screen.getByText('Code mode')
expect(schema.parentElement!.className).toContain('selected')
expect(code.parentElement!.className).not.toContain('selected')
expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy()
expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy()
})
it('renders the feature-contributed item slot after the skeleton rows', () => {
const { renderSlot } = mount()
expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {})
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-theme",
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets",
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -24,18 +24,32 @@
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
@@ -48,5 +62,8 @@
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"dependencies": {
"clsx": "^2.0.0"
}
}

View File

@@ -0,0 +1,51 @@
/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8,
* pad 16/0, hairline separator; the section column strips it when last). */
.group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
* icon-over-label column, gap 4). */
.themeCube {
box-sizing: border-box;
width: 276px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 20px 32px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: transparent;
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
* step has no alias-layer name). */
.selected {
background: var(--dsw-alias-bg-module-platform);
border-color: var(--dsw-static-neutral-bluish-400);
}

View File

@@ -0,0 +1,63 @@
/**
* Appearance preference row registered into the General section item slot
* (figma 501:30012 'Frame 2117131228'): title + three preference cubes.
* Registered by this package — the theme feature owns its own settings
* surface. Selection follows the persisted preference, never the resolved
* active theme.
*/
import clsx from 'clsx'
import {
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemePreference } from './index.ts'
import type {} from './settings-contract.ts'
import type { createAppearanceRowStore } from './settings-store.ts'
import css from './AppearanceRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
export interface AppearanceRowInjected {
/** Translate a `settings.theme` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the theme preference. */
setTheme: (id: ThemePreference) => void
}
/** Full component props: runtime share + store share + injected face. */
export type AppearanceRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
]
/**
* Render the Appearance row.
* @param props - composed slot props.
* @returns the row element tree.
*/
export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) {
const preference = useStore(s => s.preference)
return (
<div className={css.group}>
<div className={css.title}>{t('appearance.title')}</div>
<div className={css.cubeRow}>
{CUBES.map(({ id, labelKey, Icon }) => (
<button
key={id}
type="button"
className={clsx(css.themeCube, preference === id && css.selected)}
aria-pressed={preference === id}
onClick={() => { setTheme(id) }}
>
<Icon />
{t(labelKey)}
</button>
))}
</div>
</div>
)
}

View File

@@ -2,9 +2,24 @@
* Browser theme registry over the `--dsw-*` token stylesheets. The service
* owns the theme preference (light/dark/system), resolves `system` through
* `prefers-color-scheme`, and publishes immutable snapshots; it never touches
* the DOM — ui-layout's presenter consumes the resolved snapshot.
* the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin
* also registers the Appearance preference row into the settings General
* section — the theme feature owns its own settings surface.
*/
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
import { AppearanceRow } from './AppearanceRow.tsx'
import { createAppearanceRowStore } from './settings-store.ts'
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
export type { AppearanceRowState } from './settings-store.ts'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.theme'
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
export type ThemeTokens = Record<string, string>
@@ -200,13 +215,75 @@ function persistPreference(preference: ThemePreference): void {
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/** Required services: slots + locale (the feature registers its own settings row with localized copy). */
export const inject = ['slots', 'locale']
/**
* Client plugin body: provide the theme service.
* Client plugin body: provide the theme service and register the
* feature-owned Appearance preference row into the General section's item
* slot (a feature owns its settings surface).
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
ctx.provide('theme', new ThemeService(ctx))
export function apply(ctx: ClientContext): void {
const theme = new ThemeService(ctx)
ctx.provide('theme', theme)
ctx.effect(() => {
const disposers = [
ctx.locale.register(SETTINGS_NS, 'zh', {
'appearance.title': '外观',
'appearance.light': '浅色',
'appearance.dark': '深色',
'appearance.system': '跟随系统',
}),
ctx.locale.register(SETTINGS_NS, 'en', {
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-theme: settings row dictionaries')
const store = createAppearanceRowStore()
let bound: BoundActions<typeof store> | undefined
const sync = (snapshot: ThemeSnapshot): void => {
bound?.sync(snapshot.preference, snapshot.revision)
}
ctx.on('theme/change', sync)
const injected = (actions: BoundActions<typeof store>): AppearanceRowInjected => {
bound = actions
// Re-sync from the getter so no event is lost between registration and
// first render (the store's revision guard drops stale duplicates).
sync(theme.getTheme())
return {
t: ctx.locale.bind(SETTINGS_NS),
setTheme: (id) => { theme.setTheme(id) },
}
}
// Declaration-aware registration; the LEDGER is the has-registered judge
// (not a local flag): after an HMR collapse re-declares the slot, the
// cascade already removed our entry, and a stale disposer must not block
// the re-registration.
ctx.effect(() => {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
if (ctx.slots.spec('settings.general.item') === undefined) return
if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return
dispose = ctx.slots.register({
name: 'settings.general.item',
id: 'appearance',
order: 10,
store,
inject: injected,
}, AppearanceRow)
}
const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() })
tryRegister()
return () => {
unsubscribe()
dispose?.()
}
}, 'ui-theme: appearance settings row registration')
}

View File

@@ -0,0 +1,17 @@
/**
* Settings-surface slot merge consumed by this package's Appearance row. The
* AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract
* (declaring is claiming: the shell's General entry declares the slot); this
* file repeats the entry verbatim because the settings shell sits above the
* feature layer, so importing its types from here would invert the layering.
* TypeScript declaration merging rejects diverging duplicates, so every
* program that sees both copies enforces identity.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
}
}
export {}

View File

@@ -0,0 +1,37 @@
/**
* Appearance row slot store: a mirror of the theme service snapshot. The
* plugin's apply-world change listener is the only writer; the row component
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ThemePreference } from './index.ts'
/** Store state mirrored from the theme snapshot. */
export interface AppearanceRowState {
/** Persisted preference (selection state reads this, never the resolved active theme). */
preference: ThemePreference
/** Service revision; -1 until first sync so revision 0 lands as a change. */
revision: number
}
/** Declared action shape giving the exported factory a stable return type. */
type AppearanceRowActions = {
sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void
}
/**
* Declares the Appearance row state and write surface.
* @returns the store handle.
*/
export function createAppearanceRowStore(): EngineStoreHandle<AppearanceRowState, AppearanceRowActions> {
return defineStore({
init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }),
actions: {
sync: (d, preference: ThemePreference, revision: number) => {
if (revision <= d.revision) return
d.preference = preference
d.revision = revision
},
},
})
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -4,6 +4,8 @@ import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
@@ -18,9 +20,13 @@ describe('invariant companion', () => {
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.theme with no service prerequisites', async () => {
expect(inject).toEqual([])
it('client apply provides ctx.theme over the slots/locale edges', async () => {
// The feature registers its own Appearance settings row with localized
// copy, hence the slots + locale edges.
expect(inject).toEqual(['slots', 'locale'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
await ctx.plugin({ inject, apply: clientApply }).await()
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
})

View File

@@ -8,6 +8,18 @@
"src"
],
"references": [
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},