docs: state the Host-face rule for the browser e2e and settle the follow-ups

apps/web/tests/README.md records why these e2e type-check in the Host aggregate
and why importing a Client package there pulls its project tree into the Host
build graph, with mirroring as the standing answer. The Agent Note drops the
directory-picker face split (assessed and declined) and the grep-level gate in
favour of that README.

docs: regenerate the catalogs and retarget the moved declarations

The forwarded-event change moved three owner packages' cordis `Events`
declarations and their branded types into client-safe `./types` modules, and
the settings-scope split moves the shell spec into ui-settings-general. Point
the type-equivalence manifest and the affected Agent Note at those homes,
register the new `remote/*` event scope and the `ctx.settingsScope` service in
the catalog partition, and re-run the generators.

`$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note
fences that quote a bare member signature are marked `ignore-check`: they are
declaration fragments, not compilable units.

refactor(client): make ui-settings the settings domain's base layer

The settings-namespace transport lived in client/runtime, where every feature
could value-import it because runtime is a platform module. It belongs to the
settings domain, but moving it into ui-settings as a shared function fails
twice: the client bundle purity gate forbids cross-plugin value imports, and
ui-settings reached ui-sidebar for its shell, so any feature depending on it
closed a cycle through ui-layout and ui-theme.

Both halves move. `ctx.settingsScope` is now a cordis service — the
collaboration shape the purity gate prescribes, and the service proxy binds
`this.ctx` to the caller, so a bound scope's disposer belongs to the calling
fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its
navigation, and the nav-row projection) moves to ui-settings-general, which
already owns the chrome and the General section. What stays in ui-settings is
what carries no `ui-*` dependency: the scope service and the canonical settings
slot types, `settings.general.item` included. That type was parked in the locale
package precisely because the declarer was unreachable without a cycle; every
registrant now depends on this base layer, so it comes home.

The scope CONTRACT stays in client/runtime: a feature service accepts a scope
through its own signature without depending on the surface that binds it.

The forwarded settings invalidation replaces the deleted client-side
`settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches
`$on` through the gateway's Client half plus the allowlist's type-only subpath
rather than api-remotes' Client face: that face imports a Host-tsdown-generated
artifact, and this package is reachable from the Host build graph through its
callers.

refactor(client): reach the settings transport through ctx.settingsScope

Every feature that owns a preference row switches from value-importing a shared
binder to the settings domain's service, and declares the two injections that
binding needs: `settingsScope` for the transport and `remote` for the forwarded
invalidation it subscribes to on the caller's own context.

The rows stay with the features that own the preferences — Language with locale,
Appearance with ui-theme, Composer Enter with ui-conversation. Only their route
to the transport changes, so no settings surface moves and no feature gains a
dependency on the shell.

The `settings.general.item` slot type now arrives from ui-settings, the base
layer every registrant already depends on, which retires the re-export outlet
ui-theme kept and the parked declaration in the locale package.

client/runtime drops its settings-form and schemastery dependencies with the
transport that used them.

test(client): bind the settings transport in the specs that boot a preference row

Every bench that activates a plugin owning a preference row now supplies the two
services that plugin injects: the forwarded-event port and the scope service.
Specs that exercise no settings path get the minimal doubles; the ones that do
drive their refresh chains through `remote/host-event`, the same signal
client/runtime republishes from a forwarded frame, replacing the deleted
client-side `settings/changed` event.

Also fixes a publication defect the built-invariant gate catches once it runs:
api-remotes' invariant companion shared the allowlist module with the package
index, so rolldown hoisted it into a third chunk beside the two bundled entries
— a file the mechanically derived publication list does not carry, leaving an
installed companion unable to import it. The companion now reads the allowlist
through this package's own published `./types` subpath, which the bundle keeps
external, so each entry stays self-contained.

The dynamic-subscription cast in apiproxy is gone: after the vendored cordis
rescope, `on` accepts the rest-parameter handler directly, and the allowlist's
shape assertion still carries the safety argument.

fix(client): carry the settings-scope move across the release manifests

Rebasing onto the publishable release set replaced every manifest's dependency
block, so the packages this change touches restate their additions in the
workspace-protocol form: the base layer's own transport dependencies, and the
`ui-settings` plus `remote` edges each preference-row owner now needs.

ui-settings-general takes clsx with the shell it received, and client/runtime
drops the settings-form and schemastery dependencies that left with the
transport.

fix(api-gateway): give each $on subscription its own registration and containment

Two defects in the forwarded-event subscription table, both raised in review:

A set keyed on listener identity stored one entry when two callers subscribed the
same function object to the same event, so the first frame reached it once instead
of twice and either disposer silenced the surviving registration. Subscriptions are
now records addressed by registration, which is what "the disposer belongs to the
calling fiber" requires.

A listener declared void may still be `async`, and the synchronous `try/catch`
could not see its rejection: the promise was dropped and surfaced as an unhandled
rejection outside the documented containment. Delivery now attaches a rejection
handler when a listener returns a promise, so both failure modes are logged and
isolated alike.

Delivery also iterates a snapshot, so a listener that subscribes or disposes during
a frame no longer changes who receives that frame, and production matches the
TestRemote double instead of relying on live Set iteration order.

Both fixes are pinned by tests that fail against the previous implementation. The
double gains its own spec for the `$mount` refusal and the unsubscribed-name drop —
per-file coverage reaches it — plus a note that it propagates a throwing listener
where production contains one, so no spec mistakes it for the containment guarantee.

Three prose corrections: `assertJsonArgs` states where its throw actually surfaces
(the emitter's listener containment, not load or emit time), the browser e2e README
names every standing Client import rather than claiming one exception, and two
comments and a test title state the forwarded event instead of the deleted
client-side one.

refactor(remote): deliver forwarded frames through ctx.remote.$dispatch

The carrier used to relay each decoded frame over an internal
`remote/host-event` cordis event so the delivery port could stay off the Remote
contract. The relay was the wrong shape twice over: it put a client-face event
into a scan whose subject is the Host vocabulary, forcing a walk exemption for
something that is not a Host event at all, and it made a direct handoff between
two Client plugins look like a broadcast any plugin participates in.

`TypeRTClientRemote` now carries both roles of one surface — consumers subscribe
with `$on`, and whoever owns the Host frame sink hands frames over with
`$dispatch` — so client/runtime calls the Remote service directly and the event
declaration is gone. A cordis service method is the collaboration shape the
client bundle purity gate prescribes, and it needs no relay to satisfy it.

The trade is that the handoff is now developer-visible: any plugin holding
`ctx.remote` can synthesize a forwarded event. That is the exposure the relay
already had — `ctx.emit` was equally reachable — stated in the contract instead
of hidden behind a private subscriber.

runtime reaches `ctx.remote` through the gateway's Client face rather than
api-remotes': that face imports a Host-tsdown-generated artifact, and this
project sits in the Host build graph.

refactor(api-remotes): keep the allowlist value out of types.ts

`src/types.ts` carries only types by package convention, but it held the
forwarded-event array, so the type-only subpath published runtime code. The
array moves to `src/remote-events.ts` and `types.ts` derives its projection from
it; both compiler faces list both files, so the Host forwarding loop and the
consumer key face still read one declaration and the package's exports are
unchanged.

The invariant companion returns to an empty installer. Its dispatch-shape check
was the only reason the companion imported the allowlist, which made the two
bundled entries share a module: rolldown hoisted it into a third chunk that the
mechanically derived publication list does not carry, so an installed companion
could not import it. Dropping the check retires that coupling along with the
subpath-import and bundle-external workarounds it needed, and the shape the
check enforced at runtime is the part the Host face's `TypeRTForwardableEvent`
assertion already refuses at compile time.

test(ui-task): bind the locale plugin's new injections in its bench

The bench boots the real locale plugin, which now injects the settings-scope
service and the forwarded-event port, so it stayed pending and left `ctx.locale`
undefined. Supplies both doubles like the other benches that boot a plugin
owning a preference row.

docs: close the documentation gates for the forwarded-event surface

Regenerates the two graph catalogs and re-records every bilingual pair this
branch edited. Several pairs needed real work beyond the record:

- The generators write only the English side, so the Chinese sides of
  `event-producer-consumer` and `module-graph` had drifted: the former still
  listed the three deleted client-face events and pointed at declaration sites
  this branch moved into `types.ts` modules, and the latter carried a stale
  dependency graph.
- `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides.
- The pairing contract requires both sides to link the same target, so the
  apiproxy README and the design note now link the English note from both
  languages, and the note's code blocks are byte-identical across the pair
  (a translated comment inside a fence counts as divergence).
- `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane
  documents a discipline reviewers apply, so it belongs in the bilingual corpus
  rather than in the pairing exemption list.
- Four fences in the design note are marked `ignore-check`: each quotes a member
  signature, a union arm, or a snippet that names symbols it does not import, so
  none is a compilable unit.

docs(agent-note): transition the forwarded-event note to implemented

The design shipped in this PR, so the pair moves into `implemented/` and takes
that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`,
and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what
pins the behavior) and `## Consequences` (what the shipped shape costs).

Facts that moved after the proposal are corrected rather than preserved: the
allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`,
the delivery port is `$dispatch` rather than an internal cordis event, and the
invariant companion is an explained empty installer. `Verification` states the
two `$on` defects the review found — independent registration identity and
async-rejection containment — since those are now the properties tests pin.

Supersession is partial, so five active notes stay active and gain a
cross-link each: `web-config-plane`, `web-client-session-scope`,
`config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and
`permission-default-for-new-sessions` each described a frame this change
replaced. Only the mechanism sentence is annotated; every conclusion those
notes own is untouched, and `host/models-changed` remains apiproxy's own
derived frame in all of them.

Also pins the disposer's idempotence: calling one `$on` disposer twice must not
splice a surviving twin registration out from under its owner.

fix: docs

fix: test
This commit is contained in:
imccyu
2026-08-10 22:06:25 +08:00
parent 77ccffa35e
commit 01ecb43ebc
135 changed files with 1664 additions and 1176 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: ab27e073dc76335efc619f56365d1705007f7ef2
README.zh.md: 16ff5604bee5425569b783e27a29699344f630e3
README.md: 93bb2b04982a0e7bc8a248c1127428aec509773f
README.zh.md: 5cf5da2f92a0b5a1f53f3306665c3c84381a0e85

View File

@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes"
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
}
@@ -48,19 +49,21 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@deepseek-ai/schemastery": "workspace:^",
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@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-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -71,11 +74,12 @@
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
"files": [

View File

@@ -0,0 +1,224 @@
/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
foot trigger row + centered 1080x700 modal panel. The trigger reproduces
the former sidebar foot geometry (49px wide row / 36px rail circle); the
panel is a two-column layout — 188px nav rail + content column with a
54px header and the 24px-padded options area. */
/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */
.trigger {
flex: none;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 49px;
margin: 8px 0 0;
padding: 0 2px 0 6px;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-family: inherit;
font-size: 14px;
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Rail trigger: the same 36x36 circle box as the other rail controls. */
.trigger.rail {
width: 36px;
height: 36px;
margin: 18px 0 10px;
justify-content: center;
gap: 0;
padding: 0;
border-radius: 50%;
}
.triggerLabel {
overflow: hidden;
white-space: nowrap;
}
/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the
Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800 wide.
One height for every section, taken from the viewport rather than the
content: sections differ by hundreds of pixels (a settings list against the
composition editor), and a content-sized panel would resize under the
pointer on every nav click. Whatever does not fit scrolls in `.options`. */
.panel {
position: relative;
z-index: 1;
display: flex;
width: 800px;
height: min(800px, calc(100vh - 48px));
max-width: calc(100vw - 48px);
border-radius: 24px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
Declared on the panel rather than the scrolling `.options` child so the
elevation choice sits with the surface; the custom properties inherit
down to whichever descendant scrolls (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0),
gap 18, no own fill — the panel white shows through. */
.nav {
flex: none;
display: flex;
flex-direction: column;
gap: 18px;
width: 188px;
padding: 22px 12px 0;
box-sizing: border-box;
}
/* Title row (figma 501:29959): 16/500 lh24, 12px side padding. */
.navTitle {
padding: 0 12px;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* Cell stack (figma 501:29961): gap 4. */
.navList {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad
(12,9,16,9), gap 8; label 14/400 lh22; selected fill #EBEEF2. */
.navCell {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 9px 16px 9px 12px;
box-sizing: border-box;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: 14px;
line-height: 22px;
font-weight: 400;
color: var(--dsw-alias-label-primary);
text-align: left;
}
.navCell:hover {
background: var(--dsw-specific-sidebar-nav-item-hover);
}
.navCell.active {
background: var(--dsw-specific-sidebar-nav-item-active);
}
.navIcon {
flex: none;
}
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Content column (figma Content 501:29980): header + options. */
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Header (figma .Header 501:29981): h54, pad (10,20,14,8), close right. */
.header {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
height: 54px;
padding: 20px 14px 8px 10px;
box-sizing: border-box;
}
.actions {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 28px;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */
.options {
flex: 1;
min-height: 0;
padding: 0 24px 24px;
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

@@ -0,0 +1,172 @@
/**
* Settings shell root: the sidebar-foot trigger row plus the centered modal
* 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;
* the onboarding coordinator mounts exactly one ordered registrant while the
* sessions-derived empty-Hero fact is active — the takeover chrome
* (OnboardingSurface) belongs to the step, so a mounted-but-deciding step
* paints nothing here.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}
type PanelProps = {
rows: readonly SettingsSectionRow[]
renderSlot: SettingsRootComponentProps['renderSlot']
activeId: string | undefined
onSelect: (id: string) => void
onClose: () => void
}
/**
* The modal layer: full-viewport mask + centered panel. Close paths: the
* header button, a mask click, and document-level Escape (mounted only while
* open, so the listener lifetime is the panel's).
*/
function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
// Entries can unmount underneath the requested id, so the render-time
// projection falls back to the first row when the id is gone.
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
const titleId = useId()
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [onClose])
// Baseline focus management: entering the dialog lands on the close button.
const closeButton = useRef<HTMLButtonElement | null>(null)
useEffect(() => { closeButton.current?.focus() }, [])
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-labelledby={titleId}>
<nav className={css.nav}>
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
<div className={css.navList}>
{rows.map(row => (
<button
key={row.id}
type="button"
className={clsx(css.navCell, row.id === active && css.active)}
aria-current={row.id === active ? 'true' : undefined}
onClick={() => { onSelect(row.id) }}
>
{navIcon(row.id)}
<span className={css.navLabel}>{row.label}</span>
</button>
))}
</div>
</nav>
<div className={css.content}>
<div className={css.header}>
<div className={css.actions}>{renderSlot('settings.action', {})}</div>
<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}>
{active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
</div>
</div>
</div>
</div>
)
}
/**
* Render the settings trigger and panel.
* @param props - composed slot props (contract/slots.ts).
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
const [open, setOpen] = useState(false)
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
const close = useCallback(() => {
setOpen(false)
setActiveId(undefined)
}, [])
const openSection = useCallback((id: string) => {
setActiveId(id)
setOpen(true)
}, [])
// 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.
const rows = useSections(s => s)
const onboardingSteps = useOnboardingSteps(s => s)
const onboardingActive = useSessions(state =>
state.phase === 'ready'
&& (state.current === undefined || state.byId[state.current]?.blank === true))
const onboardingStep = onboardingActive
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
: undefined
useEffect(() => {
if (onboardingActive) return
setCompletedOnboarding(new Set())
}, [onboardingActive])
const completeOnboardingStep = useCallback((id: string) => {
setCompletedOnboarding((previous) => {
if (previous.has(id)) return previous
return new Set([...previous, id])
})
}, [])
return (
<>
<button
type="button"
className={clsx(css.trigger, !wide && css.rail)}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(true) }}
>
{renderSlot('settings.trigger', { wide })}
</button>
{open && (
<SettingsPanel
rows={rows}
renderSlot={renderSlot}
activeId={activeId}
onSelect={setActiveId}
onClose={close}
/>
)}
{/* The takeover chrome (OnboardingSurface: mask, opaque stage, `#root`
inert) lives inside the step component, wrapped around its visible
content — a step still deciding (private facts loading) renders
null, so nothing paints or blocks while it decides. */}
{onboardingStep !== undefined && renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</>
)
}

View File

@@ -7,14 +7,21 @@
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
// Type-only: the settings slot declarations plus the ctx.settingsScope Context
// merge. Cross-plugin collaboration goes through the service, never a value
// import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
// Type-only: pulls ctx.locale into this program.
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './shell-contract.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
@@ -99,6 +106,77 @@ export function apply(ctx: ClientContext): void {
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: metadata invalidations')
// The settings shell: this package occupies the sidebar-owned hole and
// declares the settings slots. Ledger → nav-row projection as an observable
// source (uSES contract: getSnapshot returns the cached rows until the
// ledger version moves). Labels may be locale-following thunks, so the cache
// key includes the locale revision and subscribers ride both sources.
let rowsVersion = -1
let rowsRevision = -1
let rows: readonly SettingsSectionRow[] = []
let onboardingVersion = -1
let onboardingSteps: readonly SettingsOnboardingStep[] = []
const shellInjected = (): SettingsRootInjected => ({
hooks: {
sections: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.section')
const revision = ctx.locale.getSnapshot().revision
if (version !== rowsVersion || revision !== rowsRevision) {
rowsVersion = version
rowsRevision = revision
rows = ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: resolveSlotLabel(e.options.label) ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: (listener) => {
const offLedger = ctx.slots.subscribe('settings.section', listener)
const offLocale = ctx.locale.subscribe(listener)
return () => {
offLedger()
offLocale()
}
},
},
onboardingSteps: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.onboarding')
if (version !== onboardingVersion) {
onboardingVersion = version
onboardingSteps = ctx.slots.entries('settings.onboarding')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id */
id: e.options.id ?? '',
order: e.options.order ?? 0,
}))
.sort((a, b) => a.order - b.order)
}
return onboardingSteps
},
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
},
},
})
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
name: 'sidebar.settings',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: shellInjected,
}, SettingsRoot))
ctx.slots.inject('settings.trigger', () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
ctx.slots.inject('settings.header', () =>

View File

@@ -0,0 +1,59 @@
/**
* Settings shell contract — the types of the `sidebar.settings` occupant this
* package renders. They live here rather than in ui-settings because they
* reference the sidebar's own slot type: ui-settings is the settings domain's
* base layer and must not depend on any `ui-*` presentation package, or the
* reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme.
* The settings SLOT types (what registrants contribute) stay in ui-settings.
*/
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: pulls the settings slot declarations the shell renders into.
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/** One ordered onboarding step projected from a slot registration. */
export interface SettingsOnboardingStep {
id: string
order: number
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/
export type SettingsRootInjected = {
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
/** settings.onboarding ledger projected into coordinator order. */
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
}
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face
* (hooks compartment bound to useSections). No store is registered — modal
* open state and active section id are component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<
| 'settings.trigger'
| 'settings.header'
| 'settings.action'
| 'settings.close'
| 'settings.section'
| 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>

View File

@@ -34,7 +34,7 @@ async function bench(isLoopback = true) {
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `remote/host-event` signal the connection sink republishes.
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
@@ -170,9 +170,9 @@ describe('ui-settings-general apply', () => {
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1])
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 1])
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('remote/host-event', 'settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
b.ctx.remote.$dispatch('settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })

View File

@@ -0,0 +1,247 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
afterEach(cleanup)
type Row = { id: string; order: number; label: string }
type Step = { id: string; order: number }
/** 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.action': 'Open configuration file',
'settings.close': 'Close',
}
function mount({
wide = true,
onboardingActive = true,
rows = [
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
{ id: 'agent-presets', order: 20, label: 'Agent presets' },
],
steps = [
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
],
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((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'],
)
const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
? { phase: 'ready', current: undefined, byId: {} }
: {
phase: 'ready',
current: 'active-session',
byId: { 'active-session': { blank: false } },
})) as never
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
const props: SettingsRootComponentProps = {
useSessions,
useWorkspaces: unusedHook,
wide,
useOnboardingSteps: select => select(steps),
useSections: (select) => {
const [, force] = useState(0)
useEffect(() => {
const listener = () => { force(n => n + 1) }
listeners.add(listener)
return () => { listeners.delete(listener) }
}, [])
return select(current)
},
renderSlot,
}
const view = render(<SettingsRoot {...props} />)
const bump = (next: Row[]) => {
act(() => {
current = next
for (const fn of [...listeners]) fn()
})
}
return { view, renderSlot, bump, listeners }
}
function openPanel() {
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
}
describe('SettingsRoot trigger', () => {
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.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('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')
})
it('renders header actions before the shell-owned close control', () => {
const { renderSlot } = mount()
openPanel()
expect(screen.getByText('Open configuration file')).toBeTruthy()
expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
})
})
describe('SettingsPanel close paths', () => {
it('closes via the header button', () => {
mount()
openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('closes via a mask click', () => {
mount()
openPanel()
const dialog = screen.getByRole('dialog')
fireEvent.click(dialog.parentElement!.firstElementChild!)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('closes via document-level Escape and unhooks the listener with the panel', () => {
mount()
openPanel()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
// Ignored while closed (listener removed with the panel) and non-Escape
// keys are ignored while open.
fireEvent.keyDown(document, { key: 'Escape' })
openPanel()
fireEvent.keyDown(document, { key: 'Enter' })
expect(screen.getByRole('dialog')).toBeTruthy()
})
it('lands focus on the close button when the dialog opens', () => {
mount()
openPanel()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
})
})
describe('SettingsPanel navigation', () => {
it('projects rows, marks the first active, and renders only that section', () => {
mount()
openPanel()
expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
expect(screen.getByTestId('section-general')).toBeTruthy()
})
it('switches the rendered section on nav click', () => {
mount()
openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Models' }))
expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
expect(screen.getByTestId('section-models')).toBeTruthy()
expect(screen.queryByTestId('section-general')).toBeNull()
})
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
const { renderSlot } = mount()
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
expect(first?.[2]).toEqual({ only: 'welcome' })
act(() => {
(first?.[1] as { complete: () => void }).complete()
;(first?.[1] as { complete: () => void }).complete()
})
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
const second = onboardingCalls.at(-1)
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
expect(second?.[2]).toEqual({ only: 'credential' })
act(() => {
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
})
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByTestId('section-models')).toBeTruthy()
cleanup()
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
.filter(call => call[0] === 'settings.onboarding')
expect(inactive).toHaveLength(0)
})
it('paints no takeover chrome of its own around the mounted step', () => {
// The chrome (mask, opaque stage, #root inert) belongs to the step via
// the OnboardingSurface primitive — a mounted-but-deciding step that
// renders null must show and block nothing (the reload white-flash fix;
// onboarding-surface.spec.tsx pins the primitive's half).
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
const { view } = mount()
expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
expect(appRoot.inert).not.toBe(true)
view.unmount()
appRoot.remove()
})
it('falls back to the first row when the active entry unregisters', () => {
const { bump } = mount()
openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Models' }))
bump([{ id: 'general', order: 0, label: 'General' }])
expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
expect(screen.getByTestId('section-general')).toBeTruthy()
})
it('renders an empty content column when the ledger is empty', () => {
const { renderSlot } = mount({ rows: [] })
openPanel()
expect(screen.getByRole('dialog')).toBeTruthy()
const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
expect(sectionCalls).toHaveLength(0)
})
it('drops the ledger subscription on unmount', () => {
const { view, listeners } = mount()
expect(listeners.size).toBe(1)
view.unmount()
expect(listeners.size).toBe(0)
})
})

View File

@@ -0,0 +1,157 @@
/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '../src/client/index.ts'
import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
// Copy machinery the shell only reads a revision from; the real locale
// plugin would drag its own settings-row dependencies into this bench.
ctx.provide('locale', {
register: () => () => {},
bind: () => (key: string) => key,
getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
subscribe: () => () => {},
} as never)
ctx.provide('connection', {
api: { settings: { describe: async () => ({ result: { ok: false } }) } },
isLoopback: false,
} as never)
ctx.provide('remote', { $on: () => () => {} } as never)
return { ctx, slots: ctx.get('slots') as SlotsService }
}
function declare(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
function injectedOf(slots: SlotsService): SettingsRootInjected {
const entry = slots.entries('sidebar.settings')[0]!
return (entry.inject as () => SettingsRootInjected)()
}
/** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */
const CHILD_SPECS = {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
} as const
describe('ui-settings apply', () => {
it('declares only the slot registry (a pure composition face, no locale)', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the shell and declares every child slot, 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)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name])
}
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
declare(after.slots)
await Promise.resolve()
expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
})
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 { sections } = injectedOf(b.slots).hooks
// This package registers the General section itself; every other section
// arrives from a feature registrant.
const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
expect(sections.getSnapshot()).toEqual([GENERAL])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
const rows = sections.getSnapshot()
expect(rows).toEqual([
GENERAL,
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
// Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn()
const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalled()
expect(sections.getSnapshot()).not.toBe(rows)
off()
})
it('projects onboarding entries into stable coordinator order', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { onboardingSteps } = injectedOf(b.slots).hooks
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
const steps = onboardingSteps.getSnapshot()
expect(steps).toEqual([
// This package's own onboarding page, registered by the same apply.
{ id: 'welcome-notice', order: -100 },
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
{ id: 'default-order', order: 0 },
])
expect(onboardingSteps.getSnapshot()).toBe(steps)
const listener = vi.fn()
const off = onboardingSteps.subscribe(listener)
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalledOnce()
off()
})
it('re-registers after an HMR collapse re-declares the slot (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('sidebar.settings')).toHaveLength(1)
// 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)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name])
}
})
it('unregisters the shell and collapses every child slot 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)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toBeUndefined()
}
})
})

View File

@@ -40,6 +40,9 @@
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../ui-sidebar"
}
]
}