refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -1,165 +1,290 @@
// SlotCore terminal-design behavior: the single register composition API —
// a-priori 'root', children declaration/authorization, load-time validation,
// one-axis lifecycle cascade, store scope pinning, subscription surface.
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { RootBinding, SessionBinding, SlotOptions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// 'root' is NOT merged here: the runtime package owns the built-in row, and
// the client aggregate program would see both merges collide.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'test.single': { kind: 'single'; scope: 'root'; props: { label: string } }
'test.list': { kind: 'list'; scope: 'root'; props: { label: string } }
'test.keyed': { kind: 'keyed'; scope: 'session'; props: { label: string; useSession: unknown } }
'test.single': { kind: 'single'; scope: 'root' }
'test.session': { kind: 'single'; scope: 'session' }
'test.list': { kind: 'list'; scope: 'root' }
'test.keyed': { kind: 'keyed'; scope: 'session' }
'test.grandchild': { kind: 'single'; scope: 'root' }
}
}
const Comp: FC<{ label: string }> = () => null
const SessionComp: FC<{ label: string; useSession: unknown }> = () => null
// Wide-accepting fixture: assignable wherever the composed constraint is an
// object type (children-declaring fixtures erase via `as never` instead —
// RendersCheck would demand a renderSlot consumer).
const Comp: SlotComponent<object> = () => null
/** A minimal structurally-valid store handle (identity is what the ledger tracks). */
function fakeHandle(): StoreHandle<{ n: number }, Record<string, (d: { n: number }) => void>> {
return {
spec: { init: () => ({ n: 0 }), actions: {} },
create: () => { throw new Error('not under test') },
}
}
/** Register a root-frame entry declaring the four test child slots. */
function mountFrame(core: SlotCore) {
return core.register({
name: 'root',
children: {
'test.single': { kind: 'single', scope: 'root' },
'test.session': { kind: 'single', scope: 'session' },
'test.list': { kind: 'list', scope: 'root' },
'test.keyed': { kind: 'keyed', scope: 'session' },
},
// Type-level renderSlot presence is proven by the type-chain spec; erasing
// here keeps runtime fixtures terse.
}, Comp as never)
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('SlotCore kind semantics', () => {
it('throws on register before define', () => {
describe('a-priori root and declaration gate', () => {
it('seeds root as single/root at construction', () => {
const core = new SlotCore()
expect(() => core.register('test.single', Comp)).toThrow('not defined')
expect(core.specDynamic('root')).toEqual({ kind: 'single', scope: 'root' })
})
it('throws on duplicate define', () => {
it('throws on registering into an undeclared slot', () => {
const core = new SlotCore()
core.define('test.single', { kind: 'single', scope: 'root' })
expect(() => core.define('test.single', { kind: 'single', scope: 'root' })).toThrow('already defined')
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
})
it('single: second registration throws, disposer frees the seat', () => {
it('root is single: a second frame registration throws', () => {
const core = new SlotCore()
core.define('test.single', { kind: 'single', scope: 'root' })
const dispose = core.register('test.single', Comp)
expect(() => core.register('test.single', Comp)).toThrow('already has a registration')
dispose()
mountFrame(core)
expect(() => core.register({ name: 'root' }, Comp)).toThrow('already has a registration')
})
it('children declaration makes child slots registerable, with specs recorded', () => {
const core = new SlotCore()
mountFrame(core)
expect(core.specDynamic('test.session')).toEqual({ kind: 'single', scope: 'session' })
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
})
it('duplicate child declaration throws naming the first declarer', () => {
const core = new SlotCore()
mountFrame(core)
core.register({ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(() => core.register(
{ name: 'test.session', children: { 'test.grandchild': { kind: 'single', scope: 'root' } }, registrant: 'imposter' },
Comp as never,
)).toThrow(/already declared.*test\.single/)
})
})
describe('lifecycle cascade (one axis)', () => {
it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
const core = new SlotCore()
const disposeFrame = mountFrame(core)
const disposeChild = core.register(
{ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
core.register({ name: 'test.grandchild' }, Comp)
expect(core.entries('test.grandchild')).toHaveLength(1)
disposeFrame()
expect(core.specDynamic('test.single')).toBeUndefined()
expect(core.specDynamic('test.grandchild')).toBeUndefined()
expect(core.entries('test.single')).toHaveLength(0)
expect(() => core.register('test.single', Comp)).not.toThrow()
expect(core.entries('test.grandchild')).toHaveLength(0)
// Stale disposer of a cascaded-away entry is a no-op.
expect(() => { disposeChild() }).not.toThrow()
// Slots return to undeclared: contributing again throws until redeclared.
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
})
it('registration disposers are idempotent', () => {
const core = new SlotCore()
const dispose = mountFrame(core)
dispose()
dispose()
expect(core.entries('root')).toHaveLength(0)
// Redeclare works after collapse.
mountFrame(core)
expect(core.specDynamic('test.single')).toBeDefined()
})
it('isLive tracks ledger membership across dispose', () => {
const core = new SlotCore()
mountFrame(core)
const dispose = core.register({ name: 'test.single' }, Comp)
const entry = core.entries('test.single')[0]!
expect(core.isLive(entry)).toBe(true)
dispose()
expect(core.isLive(entry)).toBe(false)
})
})
describe('kind semantics', () => {
it('keyed: duplicate key throws, missing key throws', () => {
const core = new SlotCore()
core.define('test.keyed', { kind: 'keyed', scope: 'session' })
core.register('test.keyed', SessionComp, { key: 'a' })
expect(() => core.register('test.keyed', SessionComp, { key: 'a' })).toThrow('key "a"')
// Statically rejected since RegisterArgs made keyed options mandatory;
// the runtime guard stays for dynamically-composed callers.
// @ts-expect-error keyed registration requires options
expect(() => core.register('test.keyed', SessionComp)).toThrow('requires options.key')
expect(() => core.register('test.keyed', SessionComp, { key: 'b' })).not.toThrow()
mountFrame(core)
core.register({ name: 'test.keyed', key: 'a' }, Comp)
expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
// @ts-expect-error keyed registration requires options.key
expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
})
it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'c', order: 10 })
core.register('test.list', Comp, { id: 'a' })
core.register('test.list', Comp, { id: 'b' })
expect(() => core.register('test.list', Comp, { id: 'a' })).toThrow('id "a"')
// @ts-expect-error list registration requires options (static since RegisterArgs)
expect(() => core.register('test.list', Comp)).toThrow('requires options.id')
const ids = core.entries('test.list').map(e => (e.options as { id: string }).id)
expect(ids).toEqual(['a', 'b', 'c'])
mountFrame(core)
core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
core.register({ name: 'test.list', id: 'a' }, Comp)
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
// @ts-expect-error list registration requires options.id
expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
})
it('spec() exposes the definition; define disposer clears spec and entries', () => {
it('single: second registration throws, disposer frees the seat', () => {
const core = new SlotCore()
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
core.register('test.single', Comp)
expect(core.spec('test.single')).toEqual({ kind: 'single', scope: 'root' })
mountFrame(core)
const dispose = core.register({ name: 'test.single' }, Comp)
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
dispose()
expect(core.spec('test.single')).toBeUndefined()
expect(core.entries('test.single')).toHaveLength(0)
expect(() => core.register('test.single', Comp)).toThrow('not defined')
})
it('disposers are idempotent and stale disposers after redefine are no-ops', () => {
const core = new SlotCore()
const disposeDef = core.define('test.single', { kind: 'single', scope: 'root' })
const disposeReg = core.register('test.single', Comp)
disposeReg()
disposeReg()
disposeDef()
disposeDef()
core.define('test.single', { kind: 'single', scope: 'root' })
core.register('test.single', Comp)
disposeDef()
disposeReg()
expect(core.spec('test.single')).toBeDefined()
expect(core.entries('test.single')).toHaveLength(1)
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
})
})
describe('SlotCore subscription surface', () => {
describe('store scope pinning', () => {
it('one shared handle under two scopes throws at load', () => {
const core = new SlotCore()
mountFrame(core)
const handle = fakeHandle()
core.register({ name: 'test.session', store: handle }, Comp as never)
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
.toThrow('one handle, one scope')
})
it('same handle under same scope is fine; full unmount releases the pin', () => {
const core = new SlotCore()
mountFrame(core)
const handle = fakeHandle()
const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
d1()
// Still mounted once — scope stays pinned.
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
.toThrow('one handle, one scope')
d2()
// All mounts gone: the handle may pin a new scope.
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
})
it('factories are exempt from pinning (no shared identity)', () => {
const core = new SlotCore()
mountFrame(core)
const factory = () => fakeHandle()
core.register({ name: 'test.session', store: factory }, Comp as never)
expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
})
it('cascade releases store pins of collapsed child entries', () => {
const core = new SlotCore()
const disposeFrame = mountFrame(core)
const handle = fakeHandle()
core.register({ name: 'test.session', store: handle }, Comp as never)
disposeFrame()
mountFrame(core)
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
})
})
describe('subscription surface', () => {
it('entries() returns a stable cached reference between mutations', () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
mountFrame(core)
core.register({ name: 'test.list', id: 'a' }, Comp)
const first = core.entries('test.list')
expect(core.entries('test.list')).toBe(first)
core.register('test.list', Comp, { id: 'b' })
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(core.entries('test.list')).not.toBe(first)
})
it('bumps version synchronously but batches notifications per microtask', async () => {
const core = new SlotCore()
mountFrame(core)
const fn = vi.fn()
core.subscribe('test.list', fn)
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
core.register('test.list', Comp, { id: 'b' })
expect(core.getVersion('test.list')).toBe(3)
const before = core.getVersion('test.list')
core.register({ name: 'test.list', id: 'a' }, Comp)
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(core.getVersion('test.list')).toBe(before + 2)
expect(fn).not.toHaveBeenCalled()
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(1)
core.register('test.list', Comp, { id: 'c' })
core.register({ name: 'test.list', id: 'c' }, Comp)
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(2)
})
it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
const core = new SlotCore()
const fn = vi.fn()
core.subscribe('test.single', fn)
mountFrame(core)
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(1)
})
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
const core = new SlotCore()
mountFrame(core)
await flushMicrotasks()
const single = vi.fn()
const list = vi.fn()
core.subscribe('test.single', single)
const unsubscribe = core.subscribe('test.list', list)
core.define('test.single', { kind: 'single', scope: 'root' })
core.register({ name: 'test.single' }, Comp)
await flushMicrotasks()
expect(single).toHaveBeenCalledTimes(1)
expect(list).not.toHaveBeenCalled()
unsubscribe()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register({ name: 'test.list', id: 'a' }, Comp)
await flushMicrotasks()
expect(list).not.toHaveBeenCalled()
})
it('a mutation from inside a flush re-schedules instead of being lost', async () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
mountFrame(core)
await flushMicrotasks()
const seen: number[] = []
let reentered = false
core.subscribe('test.list', () => {
seen.push(core.getVersion('test.list'))
if (!reentered) {
reentered = true
core.register('test.list', Comp, { id: 'reentrant' })
core.register({ name: 'test.list', id: 'reentrant' }, Comp)
}
})
core.register('test.list', Comp, { id: 'a' })
core.register({ name: 'test.list', id: 'a' }, Comp)
await flushMicrotasks()
await flushMicrotasks()
expect(seen).toHaveLength(2)
expect(core.entries('test.list')).toHaveLength(2)
})
it('getVersion is 0 for untouched keys and monotonic across redefine', () => {
it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
const core = new SlotCore()
expect(core.getVersion('test.single')).toBe(0)
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
const dispose = mountFrame(core)
dispose()
const after = core.getVersion('test.single')
core.define('test.single', { kind: 'single', scope: 'root' })
mountFrame(core)
expect(core.getVersion('test.single')).toBeGreaterThan(after)
})
@@ -167,43 +292,14 @@ describe('SlotCore subscription surface', () => {
const core = new SlotCore()
const keys: string[] = []
const off = core.onMutate(key => keys.push(key))
core.define('test.single', { kind: 'single', scope: 'root' })
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
expect(keys).toEqual(['test.single', 'test.list', 'test.list'])
mountFrame(core)
// Contribution first, then each declared child key.
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
keys.length = 0
core.register({ name: 'test.list', id: 'a' }, Comp)
expect(keys).toEqual(['test.list'])
off()
core.register('test.list', Comp, { id: 'b' })
expect(keys).toHaveLength(3)
})
})
describe('SlotOptions typing', () => {
it('rejects kind-mismatched options and scope-mismatched inject bindings', () => {
// Compile-time negatives only: the body never runs (some rejected shapes
// would be legal at runtime, which validates kinds, not props).
const typeNegatives = (core: SlotCore) => {
// @ts-expect-error single options take no key
core.register('test.single', Comp, { key: 'x' })
// @ts-expect-error list options require id
core.register('test.list', Comp, { order: 1 })
// @ts-expect-error keyed options require key
core.register('test.keyed', SessionComp, { inject: () => ({}) })
// @ts-expect-error component props must match the SlotMap contract
core.register('test.single', SessionComp)
// @ts-expect-error kind must match the SlotMap declaration
core.define('test.single', { kind: 'list', scope: 'root' })
const rootInject: SlotOptions<{ kind: 'single'; scope: 'root'; props: { label: string } }> = {
// @ts-expect-error root slots bind RootBinding, which has no sessionId
inject: (b: RootBinding) => ({ sessionId: b.sessionId }),
}
return rootInject
}
expect(typeNegatives).toBeTypeOf('function')
const sessionInject: SlotOptions<{ kind: 'keyed'; scope: 'session'; props: { label: string } }> = {
key: 'k',
inject: (b: SessionBinding) => ({ sessionId: b.sessionId }),
}
expect(sessionInject.key).toBe('k')
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(keys).toHaveLength(1)
})
})

View File

@@ -1,24 +1,31 @@
// Dynamic-key escape hatches and untouched-key behavior of the terminal core.
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { narrowSlots, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'surface.a': { kind: 'single'; scope: 'root'; props: { label: string } }
'surface.b': { kind: 'single'; scope: 'root'; props: { label: string } }
'surface.a': { kind: 'single'; scope: 'root' }
'surface.b': { kind: 'single'; scope: 'root' }
}
}
const Comp: FC<{ label: string }> = () => null
const Comp: SlotComponent<object> = () => null
describe('dynamic-key escape hatch', () => {
it('specDynamic reads wide-typed specs for string keys; undefined before define', () => {
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
const core = new SlotCore()
expect(core.specDynamic('surface.a')).toBeUndefined()
core.define('surface.a', { kind: 'single', scope: 'root' })
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
expect(core.specDynamic('never.defined')).toBeUndefined()
expect(core.specDynamic('never.declared')).toBeUndefined()
})
it('spec() narrows by SlotMap key', () => {
const core = new SlotCore()
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
expect(core.spec('surface.b')).toBeUndefined()
})
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
@@ -27,26 +34,9 @@ describe('dynamic-key escape hatch', () => {
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
expect(core.getVersion('surface.b')).toBe(0)
})
})
describe('narrowSlots', () => {
it('returns the same surface narrowed to the subset whitelist', () => {
const wide: ScopedSlots<'surface.a' | 'surface.b'> = { renderSlot: () => null }
const narrow = narrowSlots<'surface.a', 'surface.a' | 'surface.b'>(wide)
expect(narrow).toBe(wide)
const rejects = (s: ScopedSlots<'surface.a'>) => {
// @ts-expect-error 'surface.b' is outside the narrowed whitelist
return () => s.renderSlot('surface.b', {})
}
expect(rejects(narrow)).toBeTypeOf('function')
})
})
describe('registration typing', () => {
it('keyed/list registrations statically require options (runtime guard retained)', () => {
it('isLive is false for entries the core never held', () => {
const core = new SlotCore()
core.define('surface.a', { kind: 'single', scope: 'root' })
// single: options omissible.
expect(() => core.register('surface.a', Comp)).not.toThrow()
expect(core.isLive({ component: Comp, options: {} })).toBe(false)
})
})

View File

@@ -1,140 +1,172 @@
// Slot type-chain negative samples (design.md §9 item 2) plus the slots-ring
// full-chain positive: register→inject→render composed under the ownership
// rule (owner share referenced, injected share locally declared).
// Terminal-design compile-time samples (design.md §11 item 2): the four-share
// composed register constraint — children spec x SlotMap alignment, renderSlot
// key-set containment, store share matching, inject face completeness — plus
// the full positive chain. Bodies with @ts-expect-error sites never run.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type {
OwnerOf, RootBinding, ScopedSlots, SessionBinding, SlotMap, SlotOptions,
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
/** Owner share as the slot-owning package's contract would export it. */
interface ChainOwnerShare { sessionId: string }
/** Registrant's own injected share (locally declared — ownership rule). */
interface ChainInjected { useThing: () => number; actions: { open: () => void } }
// Only package-unique SlotMap keys are merged here. The standard-kit
// interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
// the runtime package owns the real members, and in the client aggregate
// program a toy merge would collide with them — samples below stay
// shape-agnostic about kit member payloads for the same reason.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'chain.session': { kind: 'single'; scope: 'session'; props: ChainSessionProps; owner: ChainOwnerShare }
'chain.root': { kind: 'single'; scope: 'root'; props: ChainRootProps; owner: object }
'chain.keyed': { kind: 'keyed'; scope: 'root'; props: ChainRootProps; owner: object }
'chain.frame': { kind: 'single'; scope: 'root' }
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
'chain.conv': { kind: 'single'; scope: 'session' }
'chain.tools': { kind: 'keyed'; scope: 'session' }
}
}
/** Full props = owner share (referenced) + standard share + own injected. */
type ChainSessionProps = ChainOwnerShare & { useSession: unknown } & ChainInjected
type ChainRootProps = ChainInjected
declare const defineStore: DefineStore
const SessionComp: FC<ChainSessionProps> = () => null
const RootComp: FC<ChainRootProps> = () => null
describe('type-chain negatives (compile-time; bodies never run)', () => {
it('holds the six negative samples as expect-error sites', () => {
const negatives = (core: SlotCore, slots: ScopedSlots<'chain.session'>) => {
// 1. Owner passing a registrant-injected key through renderSlot.
// OwnerOf<'chain.session'> = ChainOwnerShare — useThing is not in it.
slots.renderSlot('chain.session', {
sessionId: 's1',
// @ts-expect-error injected keys are not owner-suppliable
useThing: () => 1,
})
// 2. Inject factory returning a share that mismatches the registrant's
// declared own-injected slice (missing `actions`). The I-typed
// options form is where the mismatch surfaces (the full composed
// register constraint lands with the phase-2 consumer migration).
const mismatched: SlotOptions<SlotMap['chain.session'], ChainInjected> = {
// @ts-expect-error inject must supply the full registrant share
inject: () => ({ useThing: () => 1 }),
}
void mismatched
// 3. renderSlot on a key outside the whitelist.
// @ts-expect-error 'chain.root' is not whitelisted on this surface
slots.renderSlot('chain.root', {})
// 4. keyed registration without options.
// @ts-expect-error keyed kind requires options (RegisterArgs)
core.register('chain.keyed', RootComp)
// 5. Session-slot inject factory typed against RootBinding's surface.
const sessionOpts: SlotOptions<{ kind: 'single'; scope: 'session'; props: ChainSessionProps }, ChainInjected> = {
// @ts-expect-error session binding has sessionId; RootBinding-only factories don't type-check
inject: (b: RootBinding & { notSession: true }) => ({ useThing: () => 1, actions: { open: () => {} } }),
}
void sessionOpts
// 6. Hand-copied owner share drifting from the contract (wrong value type)
// — the composed-reference version right below compiles instead.
interface DriftedProps { sessionId: number }
const Drifted: FC<DriftedProps & ChainInjected> = () => null
// @ts-expect-error drifted hand-copy of the owner share fails at register
core.register('chain.session', Drifted)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
/** Factory form (exclusive seat): module-level export, never a handle. */
function createPanelStore() {
return defineStore({
init: () => ({ sidebar: 280, details: 0 }),
persist: 'test.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = px },
setDetails: (d, px: number) => { d.details = px },
},
})
it('full chain (positive dual): composed props register, inject, and render cleanly', () => {
const core = new SlotCore()
core.define('chain.session', { kind: 'single', scope: 'session' })
const dispose = core.register('chain.session', SessionComp, {
inject: (b: SessionBinding): ChainInjected => ({
useThing: () => b.sessionId.length,
actions: { open: () => {} },
}),
})
const entry = core.entries('chain.session')[0]!
// Storage erasure boundary: entries() returns the default-I view; the
// registrant share is restored after read-back (the budgeted cast).
const injected = (entry.options.inject as unknown as (b: SessionBinding) => ChainInjected)(
{ sessionId: 's1', session: { useSelector: undefined }, ctx: undefined })
expect(injected.useThing()).toBe(2)
// Owner share stays reference-typed at the render surface.
const ownerShare: OwnerOf<'chain.session'> = { sessionId: 's1' }
expect(ownerShare.sessionId).toBe('s1')
dispose()
expect(core.entries('chain.session')).toHaveLength(0)
})
})
// ── children validation layer (B-b, opt-in per entry) ───────────────────────
/** Delegating owner share: entry declares children, component carries a slots face. */
interface DelegOwnerShare { sessionId: string }
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'chain.deleg': { kind: 'single'; scope: 'root'; props: object; owner: DelegOwnerShare; children: 'chain.child-a' | 'chain.child-b' }
'chain.child-a': { kind: 'single'; scope: 'root'; props: object; owner: object }
'chain.child-b': { kind: 'single'; scope: 'root'; props: object; owner: object }
'chain.outside': { kind: 'single'; scope: 'root'; props: object; owner: object }
}
}
describe('children validation layer (compile-time; bodies never run)', () => {
it('accepts whitelists inside the authorized union, rejects outside keys', () => {
const cases = (core: SlotCore) => {
// Positive: slots face ⊆ children union (a strict subset is fine).
const InUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a'> }> = () => null
core.register('chain.deleg', InUnion)
// Positive: the full authorized union.
const FullUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.child-b'> }> = () => null
core.register('chain.deleg', FullUnion)
// Positive: no slots face at all — delegation is optional.
const NoSlots: FC<DelegOwnerShare> = () => null
core.register('chain.deleg', NoSlots)
// Negative: a key outside the authorized union collapses the slots constraint.
const Outside: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.outside'> }> = () => null
// @ts-expect-error slots whitelist must stay inside the entry's children union
core.register('chain.deleg', Outside)
// Negative: smuggling an extra key alongside authorized ones still fails.
const Mixed: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.outside'> }> = () => null
// @ts-expect-error a partially-authorized whitelist is still out of union
core.register('chain.deleg', Mixed)
// Entries WITHOUT children stay unchecked: any slots face registers
// freely (I inferred from the inject factory as usual).
const FreeFace: FC<ChainOwnerShare & { useSession: unknown } & ChainInjected & { slots: ScopedSlots<'chain.outside'> }> = () => null
core.register('chain.session', FreeFace, {
inject: (): ChainInjected & { slots: ScopedSlots<'chain.outside'> } =>
({ useThing: () => 1, actions: { open: () => {} }, slots: { renderSlot: () => null } }),
})
const chatStore = () => defineStore({
init: () => ({ selection: null as { id: string } | null, draft: '' }),
actions: {
select: (d, t: { id: string }) => { d.selection = t },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
},
})
type ChatHandle = ReturnType<typeof chatStore>
type FrameProps =
& PropsRuntime<'chain.frame'>
& PropsRenderSlots<'chain.side' | 'chain.conv'>
& PropsStore<ReturnType<typeof createPanelStore>>
& { openSettings: () => void }
type ConvProps =
& PropsRuntime<'chain.conv'>
& PropsStore<ChatHandle>
& { send: (t: string) => void }
// Component fixtures (never rendered; the register call sites are the test).
declare function Frame(props: FrameProps): ReactNode
declare function Conv(props: ConvProps): ReactNode
declare function Details(props: PropsRuntime<'chain.conv'> & PropsStore<ChatHandle>): ReactNode
declare function Tool(props: PropsRuntime<'chain.tools'>): ReactNode
declare function Over(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side' | 'chain.conv'>): ReactNode
declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side'>): ReactNode
declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
describe('terminal-design type chain', () => {
it('holds the positive chain and the compile-time negatives', () => {
// Everything below is compile-surface only.
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
// ── positive chain ─────────────────────────────────────────────
// Frame: children + factory store + inject; actions arrive baked.
core.register({
name: 'chain.frame',
children: {
'chain.side': { kind: 'single', scope: 'root' },
'chain.conv': { kind: 'single', scope: 'session' },
},
store: createPanelStore,
inject: (actions) => {
actions.setSidebar(0)
return { openSettings: () => {} }
},
}, Frame)
// Conv: shared handle; inject params derive as (sessionId, actions).
core.register({
name: 'chain.conv',
store: chat,
inject: (sessionId, actions) => ({
send: (text: string) => {
const sid: string = sessionId
actions.setDraft(text)
void sid
},
}),
}, Conv)
// Pure reader: same handle, no inject.
core.register({ name: 'chain.conv', store: chat }, Details)
// Owner + store shares arrive typed on the component face. Standard-kit
// member payloads are the runtime merge's property — not probed here
// (the runtime package's own tests cover them).
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
const draft: string = cp.useStore((s) => s.draft)
cp.actions.select({ id: 'm1' })
void draft
// Keyed registration carries key.
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
// ── negatives ──────────────────────────────────────────────────
// children spec must match the SlotMap entry.
core.register({
name: 'chain.frame',
// @ts-expect-error chain.conv is session-scoped in SlotMap
children: { 'chain.conv': { kind: 'single', scope: 'root' } },
}, (() => null) as SlotComponent<never>)
// renderSlot key set ⊄ children declaration.
// @ts-expect-error component renderSlot keys exceed the declaration
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Over)
// renderSlot consumption without any children declaration.
// @ts-expect-error no children declaration authorizes rendering
core.register({ name: 'chain.frame' }, NoDecl)
// children declared but component consumes no renderSlot.
// @ts-expect-error children declared, component consumes no renderSlot
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Blind)
// store share mismatch.
// @ts-expect-error component's store share doesn't match the declared handle
core.register({ name: 'chain.conv', store: chat }, WrongStore)
// inject face incomplete for the component's business share.
// @ts-expect-error inject face missing `send`
core.register({ name: 'chain.conv', inject: () => ({ notSend: 1 }) }, Needs)
// @ts-expect-error nothing provides `send` (no inject at all)
core.register({ name: 'chain.conv' }, Needs)
// root-scope inject takes no sessionId.
core.register({
name: 'chain.side',
// @ts-expect-error root-scope inject has no sessionId parameter
inject: (sessionId: string) => ({ x: sessionId }),
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
// keyed registration without key.
// @ts-expect-error keyed registration requires options.key
core.register({ name: 'chain.tools' }, Tool)
// renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing)
fp.renderSlot('chain.side', { collapsed: false })
// @ts-expect-error key not in this render share
fp.renderSlot('chain.tools', {})
// baked actions strip the draft parameter.
acts.setDraft('x')
// @ts-expect-error wrong payload type
acts.setDraft(1)
}
expect(cases).toBeTypeOf('function')
expect(samples).toBeTypeOf('function')
})
})