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

@@ -0,0 +1,111 @@
/* 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;
flex-direction: column;
width: 100%;
}
.section > :last-child {
border-bottom: none;
}
/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */
.group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */
.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);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* 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;
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}
/* Tool Call mode cubes share an 8px gap. */
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset =
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */
.modeCube {
box-sizing: border-box;
width: 418px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 8px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: transparent;
text-align: left;
}
/* 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,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

@@ -0,0 +1,40 @@
/**
* `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. Feature-owned rows
* (Language, Appearance) ship their copy in their own packages.
*/
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
const SHARED = {
'permission.value': 'Read only',
'toolcall.schema.title': 'Schema mode',
'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time',
'toolcall.code.title': 'Code mode',
'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration',
} satisfies LocaleDict
/** Simplified Chinese dictionary. */
export const zh: LocaleDict = {
...SHARED,
'trigger': '设置',
'title': '设置',
'close': '关闭',
'general.nav': '通用设置',
'permission.title': '权限',
'permission.desc': '选择默认权限模式',
'toolcall.title': '工具调用',
}
/** English dictionary. */
export const en: LocaleDict = {
...SHARED,
'trigger': 'Settings',
'title': 'Settings',
'close': 'Close',
'general.nav': 'General',
'permission.title': 'Permission',
'permission.desc': 'Choose default permission mode',
'toolcall.title': 'Tool Call',
}

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()
})
})