refactor(gui): copy-free settings shell; ui-settings-general owns ownerless copy

The shell is now a pure composition face: no dictionaries, no locale
dependency, and three new chrome content seats (settings.trigger /
settings.header / settings.close) whose slot content also carries the
accessible names (trigger text, dialog aria-labelledby, visually hidden
close label). ui-settings-general returns as the owner of copy that
belongs to no single feature: chrome content, the General section with
its item slot, and the settings dictionaries. Slot types split homes —
trigger/header/close/section live in the shell contract; the
settings.general.item entry moves to the locale package (the common
dependency of every item registrant), with ui-theme consuming it
through a re-export seam; the verbatim duplicate merges are gone and
the dependency graph is a clean DAG.
This commit is contained in:
imccyu
2026-07-26 12:40:17 +08:00
parent 9a4be33899
commit a04a223bab
41 changed files with 808 additions and 336 deletions

View File

@@ -15,6 +15,7 @@ import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string

View File

@@ -1,18 +1,26 @@
/**
* 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.
* The `settings.general.item` slot type — one preference row inside the
* settings 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.
*
* TYPE HOME RATIONALE: the slot is declared at runtime by
* ui-settings-general's General entry, but its type lives here — this
* package is the common dependency of every item registrant (any settings
* row carries copy, so every registrant already depends on locale), whereas
* the declarer's own contract is unreachable for locale/ui-theme without a
* reference cycle.
*/
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 } }
/** One preference row inside the settings General section (see module JSDoc). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
export {}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}

View File

@@ -0,0 +1,15 @@
# @deepseek-ai/dsh-client-ui-settings-general
Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages.
## Model Experience
None, as the plugin renders browser settings 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. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine.

View File

@@ -0,0 +1,67 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-general",
"description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries",
"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"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"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-settings": "^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-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,14 +1,24 @@
/**
* 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.
* The 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 type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './GeneralSection.module.css'
/** Injected face of the General section: the settings-namespace translate. */
export interface GeneralSectionInjected {
/** Translate a `settings` dictionary key to the active-locale text. */
t: (key: string) => string
}
/** Full component props: section owner share + item render share + inject face. */
export type GeneralSectionComponentProps =
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected
/**
* Render the General section content column.
* @param props - composed slot props (contract/slots.ts).

View File

@@ -0,0 +1,7 @@
/* Trigger row label (the shell's button provides layout/colors; the label
* only guards against overflow during the sidebar collapse crossfade). */
.triggerLabel {
overflow: hidden;
white-space: nowrap;
}

View File

@@ -0,0 +1,56 @@
/**
* Shell chrome content registered into the shell's trigger/header seats: the
* trigger row icon + label (figma sidebar foot) and the panel title text.
* The shell renders the surrounding chrome (button, nav heading row) and
* reads each entry's `label` option for aria text.
*/
import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './chrome.module.css'
/** Injected face of both chrome seats: the settings-namespace translate. */
export interface ChromeInjected {
/** Translate a `settings` dictionary key to the active-locale text. */
t: (key: string) => string
}
/** Trigger content props: the sidebar column state + translate. */
export type TriggerContentProps = PropsRuntime<'settings.trigger'> & ChromeInjected
/** Header content props: translate only. */
export type HeaderContentProps = PropsRuntime<'settings.header'> & ChromeInjected
/**
* Render the trigger row content (icon; label only in the wide column).
* @param props - composed slot props.
* @returns the trigger content fragment.
*/
export function TriggerContent({ wide, t }: TriggerContentProps) {
return (
<>
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={css.triggerLabel}>{t('trigger')}</span>}
</>
)
}
/**
* Render the panel title text.
* @param props - composed slot props.
* @returns the title text node.
*/
export function HeaderContent({ t }: HeaderContentProps) {
return <>{t('title')}</>
}
/** Close-button label text props: translate only. */
export type CloseLabelProps = PropsRuntime<'settings.close'> & ChromeInjected
/**
* Render the close button's visually-hidden label text.
* @param props - composed slot props.
* @returns the label text node.
*/
export function CloseLabel({ t }: CloseLabelProps) {
return <>{t('close')}</>
}

View File

@@ -0,0 +1,87 @@
/**
* Settings ownerless-copy plugin, browser half: registers everything on the
* Settings surface that belongs to no single feature — the trigger/header
* chrome content, the General section (skeleton rows + the
* `settings.general.item` slot declaration), and the `settings`
* dictionaries. Feature-owned rows and sections stay with their features.
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ChromeInjected } from './chrome.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import type { GeneralSectionInjected } from './GeneralSection.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { en, zh } from './locales.ts'
export type {
ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps,
} from './chrome.tsx'
export type {
GeneralSectionComponentProps, GeneralSectionInjected,
} from './GeneralSection.tsx'
/** Dictionary namespace owned by this plugin (shell chrome + General copy). */
const NS = 'settings'
/**
* Required services (cordis fiber inject). The target slots are 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']
/**
* Register the `settings` dictionaries, the chrome content, and the General
* section, each once its slot declaration is on the ledger.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register(NS, 'zh', zh),
ctx.locale.register(NS, 'en', en),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: dictionaries')
const t = ctx.locale.bind(NS)
const chromeInjected = (): ChromeInjected => ({ t })
const generalInjected = (): GeneralSectionInjected => ({ t })
// All four seats refresh on locale change: re-registration bumps each
// slot's ledger version, which re-renders the outlets through their own
// subscriptions (outlet memoization would swallow a parent-only render).
ctx.effect(() => {
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
ctx.slots.register({ name: 'settings.trigger', inject: chromeInjected }, TriggerContent))
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
ctx.slots.register({ name: 'settings.header', inject: chromeInjected }, HeaderContent))
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
ctx.slots.register({ name: 'settings.close', inject: chromeInjected }, CloseLabel))
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
ctx.slots.register({
name: 'settings.section',
id: 'general',
order: 0,
label: t('general.nav'),
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
inject: generalInjected,
}, GeneralSection))
const offLocale = ctx.on('locale/change', () => {
trigger.refresh()
header.refresh()
close.refresh()
general.refresh()
})
return () => {
offLocale()
trigger.dispose()
header.dispose()
close.dispose()
general.dispose()
}
}, 'ui-settings-general: chrome and section registrations')
}

View File

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

View File

@@ -0,0 +1,4 @@
/** 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

@@ -0,0 +1,32 @@
/**
* 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 copy-owning registrant contributing chrome content
* and the General section into shell-declared slots — it emits no cordis
* events and owns no cross-plugin mutable relation; slot conflicts already
* fail loud in the slot core at load time.
*/
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

@@ -0,0 +1,150 @@
/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */
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-general/client'
import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
/** The four seats this plugin fills (slot name → expected component). */
const SEATS = [
['settings.trigger', TriggerContent],
['settings.header', HeaderContent],
['settings.close', CloseLabel],
['settings.section', GeneralSection],
] as const
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
}
/** Declare the shell's four child slots the way ui-settings' entry does. */
function declare(slots: SlotsService): () => void {
return slots.register(
{
name: 'root',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
},
} as never,
() => null,
)
}
function generalEntry(slots: SlotsService) {
return slots.entries('settings.section').find(e => e.component === GeneralSection)
}
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
})
it('fills all four seats for declarations before or after apply', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
for (const [name, component] of SEATS) {
expect(before.slots.entries(name)[0]!.component).toBe(component)
}
const entry = generalEntry(before.slots)!
expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' })
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
const injected = (entry.inject as unknown as () => GeneralSectionInjected)()
expect(injected.t('permission.title')).toBe('权限')
// The chrome seats share one inject face: the settings-ns translate.
const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)()
expect(chrome.t('trigger')).toBe('设置')
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
declare(after.slots)
await Promise.resolve()
for (const [name, component] of SEATS) {
expect(after.slots.entries(name)[0]!.component).toBe(component)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries(name)).toHaveLength(1)
}
})
it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.locale.bind('settings')('title')).toBe('设置')
b.locale.setLocale('en')
expect(b.locale.bind('settings')('close')).toBe('Close')
b.locale.setLocale('zh')
await fiber.dispose()
// The (ns, locale) seats are free again — the dictionary disposers ran.
expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
})
it('refreshes all four seats on locale change with fresh General label text', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
b.locale.setLocale('en')
// Every seat re-registered (version moved) and the label re-resolved.
SEATS.forEach(([name], i) => {
expect(b.slots.getVersion(name)).toBeGreaterThan(zhVersions[i]!)
expect(b.slots.entries(name)).toHaveLength(1)
})
expect(generalEntry(b.slots)!.options.label).toBe('General')
b.locale.setLocale('zh')
expect(generalEntry(b.slots)!.options.label).toBe('通用设置')
})
it('locale change while the slots are undeclared stays a no-op', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.locale.setLocale('en')
for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
b.locale.setLocale('zh')
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
// Declarer unload: the cascade removes every seat entry and the item
// declaration while our local disposers go stale.
redeclare()
for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
expect(b.slots.spec('settings.general.item')).toBeUndefined()
declare(b.slots)
await Promise.resolve()
for (const [name, component] of SEATS) {
expect(b.slots.entries(name)[0]!.component).toBe(component)
}
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
// The recovered registrations still ride the locale path.
b.locale.setLocale('en')
expect(generalEntry(b.slots)!.options.label).toBe('General')
b.locale.setLocale('zh')
})
it('removes every seat and the 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()
for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
expect(b.slots.spec('settings.general.item')).toBeUndefined()
})
})

View File

@@ -1,29 +1,50 @@
// @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 type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.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'],
)
// Global standard kit stubs: the section consumes neither hook.
const unusedHook = (() => { throw new Error('unused by GeneralSection') }) as never
const props: GeneralSectionComponentProps = {
useSessions: unusedHook,
useWorkspaces: unusedHook,
t: (key) => en[key] ?? key,
renderSlot,
}
const view = render(<GeneralSection {...props} />)
return { view, renderSlot }
}
const t = (key: string) => en[key] ?? key
// Global standard kit stubs: none of these components consume the hooks.
const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never
const kit = { useSessions: unusedHook, useWorkspaces: unusedHook }
describe('chrome content', () => {
it('TriggerContent renders the icon with the label in the wide column', () => {
const { container } = render(<TriggerContent {...kit} wide t={t} />)
expect(container.querySelector('svg')).toBeTruthy()
expect(screen.getByText('Settings')).toBeTruthy()
})
it('TriggerContent drops the label in the rail state', () => {
const { container } = render(<TriggerContent {...kit} wide={false} t={t} />)
expect(container.querySelector('svg')).toBeTruthy()
expect(screen.queryByText('Settings')).toBeNull()
})
it('HeaderContent and CloseLabel render their translated text', () => {
render(<HeaderContent {...kit} t={t} />)
render(<CloseLabel {...kit} t={t} />)
expect(screen.getByText('Settings')).toBeTruthy()
expect(screen.getByText('Close')).toBeTruthy()
})
})
describe('GeneralSection', () => {
function mount() {
const renderSlot = vi.fn(
((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
)
const props: GeneralSectionComponentProps = { ...kit, t, renderSlot }
const view = render(<GeneralSection {...props} />)
return { view, renderSlot }
}
it('renders the Permission skeleton row with the disabled selector', () => {
mount()
expect(screen.getByText('Permission')).toBeTruthy()

View File

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,33 @@
{
"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": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
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,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-settings
Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering).
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger.
## Model Experience
@@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet.
- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general.

View File

@@ -25,8 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-sidebar",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
},
@@ -47,7 +46,6 @@
"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-sidebar": "workspace:^",

View File

@@ -190,3 +190,13 @@
padding: 0 24px 8px;
overflow-y: auto;
}
/* Visually-hidden text seat (close button accessible name from slot content). */
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,15 +1,15 @@
/**
* Settings shell root: the sidebar-foot trigger row plus the centered modal
* panel (figma 501:29947, 1080x700) with the section nav rail. Modal open
* state and the active section id are component-local viewing state; the
* section ledger arrives through the injected face (nav labels are
* registrant-localized — the shell owns no locale/theme subscription).
* panel (figma 501:29947, 1080x700) with the section nav rail. The shell is
* a pure composition face — every piece of text (trigger label, panel title,
* close label, sections) arrives from registrants through slots; accessible
* names resolve to that content (trigger: its own text; dialog:
* aria-labelledby the title node; close: visually-hidden slot text). Modal
* open state and the active section id are component-local viewing state.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
@@ -20,7 +20,6 @@ function navIcon(id: string) {
}
type PanelProps = {
translate: SettingsRootComponentProps['translate']
rows: ReturnType<SettingsRootComponentProps['sections']>
renderSlot: SettingsRootComponentProps['renderSlot']
onClose: () => void
@@ -31,11 +30,12 @@ type PanelProps = {
* header button, a mask click, and document-level Escape (mounted only while
* open, so the listener lifetime is the panel's).
*/
function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
// Local selection; entries can unmount underneath it, so the render-time
// projection falls back to the first row when the id is gone.
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
const titleId = useId()
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -52,9 +52,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
return (
<div className={css.overlay} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div className={css.panel} role="dialog" aria-modal="true" aria-label={translate('settings:title')}>
<nav className={css.nav} aria-label={translate('settings:title')}>
<div className={css.navTitle}>{translate('settings:title')}</div>
<div className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}>
<nav className={css.nav}>
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
<div className={css.navList}>
{rows.map((row) => (
<button
@@ -72,8 +72,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
</nav>
<div className={css.content}>
<div className={css.header}>
<button ref={closeButton} type="button" className={css.close} aria-label={translate('settings:close')} onClick={onClose}>
<button ref={closeButton} type="button" className={css.close} onClick={onClose}>
<IconCloseOutline16 size={14} />
<span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span>
</button>
</div>
<div className={css.options}>
@@ -91,13 +92,13 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, translate, subscribeSections, sectionsVersion, sections, renderSlot } = props
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
// The ledger tick is the shell's only subscription: sections re-register
// with freshly localized labels on locale change, so the version bump also
// re-renders the shell's own translate()-read chrome copy.
// The ledger tick keeps the nav rows fresh: registrants re-register with
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
// State = ledger version: same-version notifications dedupe to no render.
const [, setSectionsRev] = useState(() => sectionsVersion())
useEffect(
@@ -111,15 +112,13 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
<button
type="button"
className={clsx(css.trigger, !wide && css.rail)}
aria-label={translate('settings:trigger')}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(true) }}
>
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={css.triggerLabel}>{translate('settings:trigger')}</span>}
{renderSlot('settings.trigger', { wide })}
</button>
{open && <SettingsPanel translate={translate} rows={rows} renderSlot={renderSlot} onClose={close} />}
{open && <SettingsPanel rows={rows} renderSlot={renderSlot} onClose={close} />}
</>
)
}

View File

@@ -1,11 +1,11 @@
/**
* 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.
* Settings shell slot contract — the canonical home of every settings slot
* type. The shell is a pure composition face with zero copy of its own: it
* occupies the sidebar-owned `sidebar.settings` hole and declares the slots
* below; ALL text (trigger label, panel title, close aria, section content)
* arrives from registrants. A feature owns its settings surface — adding a
* setting never means editing the shell; copy that belongs to no single
* feature (chrome, the General section) is owned by ui-settings-general.
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
@@ -14,28 +14,54 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The sidebar-foot trigger row content: icon + label, supplied as slot
* content (the accessible name comes from the content — rail state
* renders the label visually hidden). The shell renders the button
* chrome and owns open state. Absent contribution degrades to an
* icon-only button without an accessible name (broken-composition state;
* the shipped composition always registers the seat).
*/
'settings.trigger': { kind: 'single'; scope: 'root'; owner: SettingsTriggerOwnerProps }
/**
* The panel title text seat. Content renders inside the nav heading row;
* the dialog's accessible name points at that node via aria-labelledby.
* Absent contribution leaves the heading empty.
*/
'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
/**
* The close button's visually-hidden label text (the button itself —
* icon, geometry, focus — is shell chrome). Absent contribution leaves
* the button without an accessible name (broken-composition state).
*/
'settings.close': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
/**
* One settings page per list entry. Registrant options carry the nav
* identity: `id` (section key, drives `only` filtering), `order` (nav
* position), `label` (registrant-localized display text — the registrant
* re-registers with fresh text on locale change, so the shell never
* subscribes locale/theme state; the ledger bump doubles as the shell's
* subscribes locale state; the ledger bump doubles as the shell's
* re-render trigger). Sections render inside the panel content column.
* (`settings.general.item`, declared by ui-settings-general's General
* entry, is typed in the locale package — the common dependency of every
* item registrant; the shell neither declares nor renders it.)
*/
'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 } }
}
}
/** Owner share of the trigger content seat: the sidebar column state. */
export interface SettingsTriggerOwnerProps {
/** Whether the sidebar renders wide content (false = 56px rail, icon only). */
wide: boolean
}
/** Owner share of the header title seat (the shell supplies nothing). */
export interface SettingsHeaderOwnerProps {
/** Marker field: header owner props are intentionally empty. */
children?: never
}
/**
* Owner share of a settings section entry. The shell owns modal visibility
* and navigation; sections receive nothing but the render site (their data
@@ -48,16 +74,9 @@ export interface SettingsSectionOwnerProps {
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): locale-resolved nav labels come through `translate`.
* apply): ledger projections only — the shell reads no locale state.
*/
export type SettingsRootInjected = {
/**
* Resolve a "<ns>:<key>" locale reference to the active-locale text —
* shell chrome copy only (trigger/title/close); nav labels arrive already
* localized. Read at render time; the locale-change re-render rides the
* section ledger bump, not a shell-owned subscription.
*/
translate: (ref: string) => string
/** Read the settings.section ledger version (nav invalidation). */
sectionsVersion: () => number
/** Subscribe to settings.section ledger changes. */
@@ -68,27 +87,11 @@ export type SettingsRootInjected = {
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared section render share and the injected
* face. No store is registered — modal open state and active section id are
* (wide/rail state) plus the declared render shares and the injected face.
* No store is registered — modal open state and active section id are
* component-local viewing state.
*/
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
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
& SettingsRootInjected

View File

@@ -1,24 +1,21 @@
/**
* Settings shell plugin, browser half. Occupies the sidebar-owned
* `sidebar.settings` hole with the trigger row + modal panel, declares the
* `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.
* Settings shell plugin, browser half. A pure composition face: occupies the
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
* panel, declares the `settings.trigger` / `settings.header` /
* `settings.section` slots, and projects the section ledger into the panel
* navigation. The shell ships no copy and reads no locale state — all text
* arrives from registrants (ui-settings-general owns the chrome and General
* content; features own their rows and sections). Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
// 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 { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts'
import type { SettingsRootInjected } from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { en, zh } from './locales.ts'
export type {
GeneralSectionComponentProps, GeneralSectionInjected,
SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps,
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
/**
@@ -27,29 +24,15 @@ export type {
* constrained (dshClient.inject edges are informational); registration goes
* through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
export const inject = ['slots']
/**
* Register the settings shell into `sidebar.settings` and the shell-owned
* General section into `settings.section`, each once its declaration is on
* the ledger.
* Register the settings shell into `sidebar.settings` once the 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', 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(':')
if (colon === -1) return ref
return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1))
},
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
sections: () => ctx.slots.entries('settings.section')
@@ -65,33 +48,14 @@ export function apply(ctx: ClientContext): void {
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
ctx.slots.register({
name: 'sidebar.settings',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
},
inject: injected,
}, SettingsRoot))
return () => { deferred.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.
const generalInjected = (): GeneralSectionInjected => ({
t: ctx.locale.bind('settings'),
})
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
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))
// Nav labels are registrant-localized: refresh on locale change so the
// ledger carries fresh text (the version bump re-renders the shell).
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
return () => {
offLocale()
deferred.dispose()
}
}, 'ui-settings: general section registration')
}

View File

@@ -1,19 +1,15 @@
/** Settings shell registration: declaration-aware deferral, the injected face, and HMR recovery. */
/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */
import { Context } from 'cordis'
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 { GeneralSectionInjected, SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { 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()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
return { ctx, slots: ctx.get('slots') as SlotsService }
}
function declare(slots: SlotsService): () => void {
@@ -28,17 +24,27 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
return (entry.inject as () => SettingsRootInjected)()
}
/** The shell's four child declarations (chrome seats + the section list). */
const CHILD_SPECS = {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
} as const
describe('ui-settings apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
it('declares only the slot registry (a pure composition face, no locale)', () => {
expect(inject).toEqual(['slots'])
})
it('registers the shell for declarations that arrive before or after apply', async () => {
it('registers the shell and declares the four child slots, before or after the declaration', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
expect(before.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' })
for (const [name, spec] of Object.entries(CHILD_SPECS)) {
expect(before.slots.spec(name as never)).toEqual(spec)
}
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -50,43 +56,17 @@ describe('ui-settings apply', () => {
expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
})
it('registers the zh/en shell dictionaries and disposes them with the fiber', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.locale.bind('settings')('title')).toBe('设置')
b.locale.setLocale('en')
expect(b.locale.bind('settings')('close')).toBe('Close')
await fiber.dispose()
// The (ns, locale) seats are free again — the dictionary disposers ran.
expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
})
it('exposes translate over "<ns>:<key>" refs with literal echo for plain text', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots)
expect(injected.translate('settings:title')).toBe('设置')
expect(injected.translate('no colon ref')).toBe('no colon ref')
})
it('projects the section ledger into ordered nav rows with option defaults', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots)
// 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: '通用设置' }])
// The shell ships no sections of its own — registrants fill the ledger.
expect(injected.sections()).toEqual([])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply (order 0 ties
// keep registration sequence, so 'a' lands after the General entry).
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
expect(injected.sections()).toEqual([
{ id: 'general', order: 0, label: '通用设置' },
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
@@ -104,92 +84,28 @@ describe('ui-settings apply', () => {
const redeclare = declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
// Declarer unload: the cascade removes our entry and the slot spec while
// our local disposer variable goes stale.
// Declarer unload: the cascade removes our entry and every child
// declaration while our local disposer variable goes stale.
redeclare()
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
expect(b.slots.spec('settings.trigger')).toBeUndefined()
declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
expect(b.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' })
for (const [name, spec] of Object.entries(CHILD_SPECS)) {
expect(b.slots.spec(name as never)).toEqual(spec)
}
})
it('unregisters the shell and collapses settings.section on teardown', async () => {
it('unregisters the shell and collapses all four child slots on teardown', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
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()
for (const name of Object.keys(CHILD_SPECS)) {
expect(b.slots.spec(name as never)).toBeUndefined()
}
})
})

View File

@@ -6,14 +6,15 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
afterEach(cleanup)
const DICT: Record<string, string> = {
'settings:trigger': 'Settings',
'settings:title': 'Settings',
'settings:close': 'Close',
}
type Row = { id: string; order: number; label: string }
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
const SEAT_CONTENT: Record<string, string> = {
'settings.trigger': 'Settings',
'settings.header': 'Settings Title',
'settings.close': 'Close',
}
function mount({
wide = true,
rows = [
@@ -26,8 +27,10 @@ function mount({
let version = 0
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((_key: string, _owner: unknown, opts?: { only?: string }) =>
<div data-testid={`section-${opts?.only ?? 'all'}`} />) as SettingsRootComponentProps['renderSlot'],
((key: string, _owner: unknown, opts?: { only?: string }) => {
if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
return SEAT_CONTENT[key]
}) as SettingsRootComponentProps['renderSlot'],
)
// Global standard kit stubs: the shell consumes neither hook.
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
@@ -35,7 +38,6 @@ function mount({
useSessions: unusedHook,
useWorkspaces: unusedHook,
wide,
translate: (ref) => DICT[ref] ?? ref,
sectionsVersion: () => version,
subscribeSections: (listener) => {
listeners.add(listener)
@@ -60,19 +62,41 @@ function openPanel() {
}
describe('SettingsRoot trigger', () => {
it('renders the wide row with the label and opens the dialog', () => {
mount()
it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
const { renderSlot } = mount()
const trigger = screen.getByRole('button', { name: 'Settings' })
expect(trigger.textContent).toContain('Settings')
expect(trigger.hasAttribute('aria-label')).toBe(false)
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
})
it('drops the label in the rail state', () => {
mount({ wide: false })
expect(screen.getByRole('button', { name: 'Settings' }).textContent).toBe('')
it('hands the rail state to the trigger seat', () => {
const { renderSlot } = mount({ wide: false })
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
})
})
describe('SettingsPanel chrome seats', () => {
it('names the dialog via aria-labelledby pointing at the header seat node', () => {
mount()
openPanel()
const dialog = screen.getByRole('dialog')
const titleId = dialog.getAttribute('aria-labelledby')!
expect(titleId).toBeTruthy()
const title = document.getElementById(titleId)!
expect(title.textContent).toBe('Settings Title')
expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
})
it('names the close button through the visually-hidden close seat text', () => {
mount()
openPanel()
const close = screen.getByRole('button', { name: 'Close' })
expect(close.hasAttribute('aria-label')).toBe(false)
expect(close.textContent).toContain('Close')
})
})
@@ -143,7 +167,8 @@ describe('SettingsPanel navigation', () => {
const { renderSlot } = mount({ rows: [] })
openPanel()
expect(screen.getByRole('dialog')).toBeTruthy()
expect(renderSlot).not.toHaveBeenCalled()
const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
expect(sectionCalls).toHaveLength(0)
})
it('drops the ledger subscription on unmount', () => {

View File

@@ -23,9 +23,6 @@
{
"path": "../ui-sidebar"
},
{
"path": "../locale"
},
{
"path": "../../support/invariants"
}

View File

@@ -1,17 +1,9 @@
/**
* 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.
* Re-export seam for the `settings.general.item` slot type consumed by this
* package's Appearance row. The canonical home is the locale package (the
* common dependency of every item registrant); this file exists so row
* modules import the type from within their own package.
*/
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 {}
export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client'
// Side-effect type import: pulls the SlotMap merge into this program.
import type {} from '@deepseek-ai/dsh-client-locale/client'