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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/client/ui-theme/src/client/AppearanceRow.module.css
Normal file
51
packages/client/ui-theme/src/client/AppearanceRow.module.css
Normal 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);
|
||||
}
|
||||
63
packages/client/ui-theme/src/client/AppearanceRow.tsx
Normal file
63
packages/client/ui-theme/src/client/AppearanceRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
17
packages/client/ui-theme/src/client/settings-contract.ts
Normal file
17
packages/client/ui-theme/src/client/settings-contract.ts
Normal 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 {}
|
||||
37
packages/client/ui-theme/src/client/settings-store.ts
Normal file
37
packages/client/ui-theme/src/client/settings-store.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
6
packages/client/ui-theme/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-theme/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user