@playfast/reform 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.8",
4
+ "version": "0.0.9",
5
5
  "type": "module",
6
6
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
7
7
  "keywords": [
@@ -4,21 +4,30 @@ import { type Manifest, definitionClass } from '../definition/definition'
4
4
  import { type Ctx } from '../internal/ctx'
5
5
  import type { Tracker } from '../internal/track'
6
6
  import { CurrentTracker } from '../internal/track'
7
- import { CurrentSlots, type SlotHost } from './host'
8
- import type { Node, SlotClass } from './slot'
7
+ import type { SlotClass } from './slot'
8
+ import type { Structure } from './structure'
9
9
  import type { UiClass, UiContract } from './ui'
10
10
  import { Props } from './props'
11
11
 
12
- /** Everything the host injects per render: the instance props, the dependency tracker, and slot bindings. */
12
+ /**
13
+ * What a composition's logic returns for one frame: a serializable {@link Structure}
14
+ * value — its computed props, per-slot fills, and event triggers, all as DATA. A body
15
+ * never returns (nor evaluates) a React node, so the engine, the wire server, and the
16
+ * proof harness consume the frame without React; presentation resolves the contract's
17
+ * view separately, by name, on a host.
18
+ */
19
+ export type Frame<C extends UiContract = UiContract> = Structure<C>
20
+
21
+ /** Everything the host injects per render: the instance props and the dependency tracker. */
13
22
  export interface RenderEnv {
14
23
  readonly props: unknown
15
24
  readonly tracker: Tracker
16
- readonly slots: SlotHost
17
25
  }
18
26
 
19
- /** A mounted composition: given a render environment, produces the current frame's node. */
27
+ /** A mounted composition: given a render environment, produces the current frame
28
+ * a {@link Structure} value (see `Frame`). */
20
29
  export interface CompositionService {
21
- readonly render: (env: RenderEnv) => Effect.Effect<Node, never, never>
30
+ readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
22
31
  }
23
32
 
24
33
  export interface CompositionConfig {
@@ -39,7 +48,20 @@ export interface CompositionConfig {
39
48
 
40
49
  type PropsOf<Config> = Config extends { props: Schema.Schema<infer P, any> } ? P : unknown
41
50
 
42
- export interface CompositionClass<out P, out C extends UiContract = UiContract> {
51
+ /**
52
+ * The composition's state-group tuple, recovered from the `const`-inferred config
53
+ * so consumers can type its seeds. Erased to `readonly []` when no `states` are
54
+ * declared; entries that are not state groups contribute no seed keys (`SeedsOf`).
55
+ */
56
+ type StatesOf<Config> = Config extends { states: infer S extends ReadonlyArray<unknown> }
57
+ ? S
58
+ : readonly []
59
+
60
+ export interface CompositionClass<
61
+ out P,
62
+ out C extends UiContract = UiContract,
63
+ out S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
64
+ > {
43
65
  new (): {}
44
66
  readonly manifest: Manifest & { readonly kind: 'Composition' } & CompositionConfig
45
67
  /** Phantom carrying the props type for `provide(slot, composition)` checks. */
@@ -51,6 +73,11 @@ export interface CompositionClass<out P, out C extends UiContract = UiContract>
51
73
  * invariant `impl` tag is never stored here, so no variance regression.
52
74
  */
53
75
  readonly Contract: C
76
+ /**
77
+ * Phantom carrying the state-group tuple, so `seedScene` can type a scene's
78
+ * seeds against the actual state members. Same erasure trick as `Contract`.
79
+ */
80
+ readonly States: S
54
81
  readonly tag: Context.Tag<CompositionService, CompositionService>
55
82
  }
56
83
 
@@ -66,12 +93,12 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
66
93
  // manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
67
94
  // so the manifest type — and its variance — is unchanged.
68
95
  config: Config & { readonly ui: UiClass<C> },
69
- ): CompositionClass<PropsOf<Config>, C> => {
96
+ ): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
70
97
  const tag = Context.GenericTag<CompositionService, CompositionService>(
71
98
  `reform/composition/${name}`,
72
99
  )
73
100
  const manifest = { kind: 'Composition' as const, name, ...config }
74
- return definitionClass<CompositionClass<PropsOf<Config>, C>>({ manifest, tag })
101
+ return definitionClass<CompositionClass<PropsOf<Config>, C, StatesOf<Config>>>({ manifest, tag })
75
102
  }
76
103
 
77
104
  /**
@@ -80,33 +107,40 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
80
107
  * host injects per render) surface as the Layer's `RIn`; the runtime runs the
81
108
  * body per render within the captured context.
82
109
  */
83
- export const live = <P, Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>>(
84
- // `live` doesn't use the contract type; accept any composition.
85
- composition: CompositionClass<P, any>,
86
- body: () => Generator<Eff, Node, never>,
110
+ export const live = <
111
+ P,
112
+ C extends UiContract,
113
+ Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
114
+ >(
115
+ // The contract `C` is captured (not erased to `any`) so the body's returned
116
+ // `Structure` is checked against the composition's contract.
117
+ composition: CompositionClass<P, C>,
118
+ body: () => Generator<Eff, Frame<C>, never>,
87
119
  ): Layer.Layer<CompositionService, never, Exclude<Ctx<Eff>, Props>> => {
88
120
  const logic = Effect.gen(body)
89
121
  return Layer.effect(
90
122
  composition.tag,
91
123
  Effect.gen(function* () {
92
- // Capture the build-time context (state stores, calc, ui, bus). `Props`,
93
- // the tracker, and slots are injected per render below.
124
+ // Capture the build-time context (state stores, calc, ui, bus). `Props` and
125
+ // the tracker are injected per render below.
94
126
  const context = yield* Effect.context<Exclude<Ctx<Eff>, Props>>()
95
- const render = (env: RenderEnv): Effect.Effect<Node, never, never> =>
96
- // Total once fully provided: the body is synchronous, reads can't fail,
97
- // and every requirement is now satisfied (D2). `Effect.gen` widens the
98
- // error channel to `unknown` for a generic body, so we restate it.
127
+ const render = (env: RenderEnv): Effect.Effect<Frame, never, never> =>
128
+ // The per-render erasure seam: the body is total once fully provided (it is
129
+ // synchronous, reads can't fail, every requirement is now satisfied D2) and
130
+ // its `Structure<C>` erases to the service's contract-agnostic `Frame`. `gen`
131
+ // widens the error channel to the body's inferred `E` for a generic body, so
132
+ // narrowing it (and the contract) back to the erased service type needs the
133
+ // `unknown` bridge — the one cast this render boundary has always required.
99
134
  logic.pipe(
100
135
  Effect.provideService(Props, env.props),
101
136
  Effect.provideService(CurrentTracker, env.tracker),
102
- Effect.provideService(CurrentSlots, env.slots),
103
137
  Effect.provide(context),
104
- ) as Effect.Effect<Node, never, never>
138
+ ) as unknown as Effect.Effect<Frame, never, never>
105
139
  return { render }
106
140
  }),
107
141
  )
108
142
  }
109
143
 
110
- /** Run a mounted composition for one frame, producing its node. */
111
- export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Node, never, never> =>
144
+ /** Run a mounted composition for one frame, producing its `Frame` (a `Structure`). */
145
+ export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Frame, never, never> =>
112
146
  service.render(env)
@@ -1,18 +1,12 @@
1
- import { Context } from 'effect'
2
1
  import type { Node } from './slot'
3
2
 
4
3
  /**
5
- * Binds a parent's slots to concrete child renderers. The host package
6
- * (`@reform/react`) builds one per composition instance from the manifest's
7
- * slot map; the `ui` read consults it so `<slots.Item .../>` renders the wired
8
- * child. Core stays renderer-neutral it only defines this hole.
4
+ * A slot handle the host hands a presentation: BOTH renderable (call it / use as a
5
+ * component to place the keyed children) AND inspectable (`.props` — the array of
6
+ * per-child fill props the structure carried, one entry per mounted child). The
7
+ * structure host (`renderStructure`) populates `.props` from the fills. Plan 06.
9
8
  */
10
- export interface SlotHost {
11
- slot(name: string): (props: unknown) => Node
9
+ export interface SlotRenderer {
10
+ (props: unknown): Node
11
+ readonly props: ReadonlyArray<unknown>
12
12
  }
13
-
14
- const CurrentSlotsBase: Context.TagClass<CurrentSlots, 'reform/Slots', SlotHost> =
15
- Context.Tag('reform/Slots')<CurrentSlots, SlotHost>()
16
-
17
- /** The active composition's slot bindings, provided per render by the host. */
18
- export class CurrentSlots extends CurrentSlotsBase {}
@@ -0,0 +1,90 @@
1
+ import { Schema as S } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { Composition } from '../index'
4
+ import { slot } from './slot'
5
+ import { type Contract, ui } from './ui'
6
+ import { absent, each, isStructure, mount, one, structureEquals, when } from './structure'
7
+
8
+ type List = Contract<typeof ListUi>
9
+
10
+ // A minimal parent/child contract pair: a list parent with one keyed slot, so the
11
+ // type-level `mount` checks have a real contract to bind against. Row carries a
12
+ // props schema so its external props (`Comp['Props']`) are `{ label: string }`,
13
+ // which is what each fill is checked against.
14
+ class RowUi extends ui('Row')<{ props: { label: string } }>() {}
15
+ class Row extends Composition.make('Row', {
16
+ title: 'Row',
17
+ props: S.Struct({ label: S.String }),
18
+ ui: RowUi,
19
+ }) {}
20
+ class RowSlot extends slot('Row')<typeof Row>() {}
21
+
22
+ class ListUi extends ui('List')<{
23
+ props: { total: number }
24
+ slots: { Row: RowSlot }
25
+ }>() {}
26
+
27
+ describe('structure combinators', () => {
28
+ test('each produces one keyed entry per item, props mapped', () => {
29
+ const fill = each([{ id: 'a' }, { id: 'b' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) })
30
+ expect(fill).toEqual({
31
+ _tag: 'Each',
32
+ items: [
33
+ { key: 'a', props: { label: 'a' } },
34
+ { key: 'b', props: { label: 'b' } },
35
+ ],
36
+ })
37
+ })
38
+
39
+ test('one and absent / when', () => {
40
+ expect(one({ label: 'x' })).toEqual({ _tag: 'One', props: { label: 'x' } })
41
+ expect(absent).toEqual({ _tag: 'Absent' })
42
+ expect(when(true, one({ label: 'y' }))).toEqual({ _tag: 'One', props: { label: 'y' } })
43
+ expect(when(false, one({ label: 'y' }))).toEqual({ _tag: 'Absent' })
44
+ })
45
+
46
+ test('mount stamps the Structure brand; isStructure discriminates it from a node', () => {
47
+ const s = mount<List>({
48
+ props: { total: 2 },
49
+ slots: { Row: each([{ id: 'a' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) }) },
50
+ })
51
+ expect(isStructure(s)).toBe(true)
52
+ expect(isStructure({ type: 'div' })).toBe(false)
53
+ expect(isStructure(null)).toBe(false)
54
+ expect(s.props).toEqual({ total: 2 })
55
+ })
56
+
57
+ test('structureEquals detects a two-frame fixpoint vs a change', () => {
58
+ const make = (label: string) =>
59
+ mount<List>({
60
+ props: { total: 1 },
61
+ slots: { Row: each([{ id: 'a' }], { key: (t) => t.id, props: () => ({ label }) }) },
62
+ })
63
+ expect(structureEquals(make('same'), make('same'))).toBe(true)
64
+ expect(structureEquals(make('a'), make('b'))).toBe(false)
65
+ })
66
+ })
67
+
68
+ // Type-level contract enforcement — never called; exists only so the negative
69
+ // cases are typechecked. Each `@ts-expect-error` must sit on the line that errors.
70
+ export const _typeChecks = (): void => {
71
+ mount<List>({
72
+ props: { total: 1 },
73
+ slots: {
74
+ // @ts-expect-error 'Nope' is not a declared slot of ListUi
75
+ Nope: one({ label: 'x' }),
76
+ },
77
+ })
78
+ mount<List>({
79
+ props: { total: 1 },
80
+ slots: {
81
+ // @ts-expect-error Row's child props are { label: string }, not { wrong: number }
82
+ Row: each([1], { key: () => 'k', props: () => ({ wrong: 1 }) }),
83
+ },
84
+ })
85
+ mount<List>({
86
+ // @ts-expect-error List's props are { total: number }, not { total: string }
87
+ props: { total: 'two' },
88
+ slots: { Row: absent },
89
+ })
90
+ }
@@ -0,0 +1,145 @@
1
+ import type { SlotProps } from './slot'
2
+ import type { EventsOf, UiContract } from './ui'
3
+
4
+ /**
5
+ * Structure-as-data (the headless-structure keystone). A composition's logic
6
+ * body returns this *value* instead of a React node: pure, serializable data that
7
+ * describes — for this frame — the composition's own props and, per declared slot,
8
+ * how that slot is filled (a list / a singleton / absent). The engine, the wire
9
+ * server, and the proof harness consume this directly, so none of them needs to
10
+ * evaluate a view or import React to discover structure. Presentation (React)
11
+ * resolves a contract's markup separately, by name, on a host.
12
+ *
13
+ * `live` accepts BOTH a `Node` (legacy view body) and a `Structure` during the
14
+ * migration; hosts discriminate at runtime via {@link isStructure}.
15
+ */
16
+
17
+ /** A list slot fill: one keyed entry per item. `key` is the SINGLE source of both
18
+ * the wire `key` and the per-item `StateFamily` key — they can no longer drift. */
19
+ export interface EachFill<P> {
20
+ readonly _tag: 'Each'
21
+ readonly items: ReadonlyArray<{ readonly key: string; readonly props: P }>
22
+ }
23
+
24
+ /** A singleton slot fill — the slot renders exactly one child with these props. */
25
+ export interface OneFill<P> {
26
+ readonly _tag: 'One'
27
+ readonly props: P
28
+ }
29
+
30
+ /** A slot that renders nothing this frame (the data-driven analog of `cond && …`). */
31
+ export interface AbsentFill {
32
+ readonly _tag: 'Absent'
33
+ }
34
+
35
+ /** How one declared slot is filled this frame, typed by the child's external props. */
36
+ export type SlotFill<P> = EachFill<P> | OneFill<P> | AbsentFill
37
+
38
+ /** The per-slot fill map a contract's structure carries — one fill per declared slot,
39
+ * each typed by that child composition's external props (`SlotProps`). Empty when the
40
+ * contract declares no slots. */
41
+ export type StructureSlots<C extends UiContract> = C extends {
42
+ slots: infer S
43
+ }
44
+ ? { readonly [K in keyof S]: SlotFill<SlotProps<S[K]>> }
45
+ : Record<never, never>
46
+
47
+ /**
48
+ * The data a composition's logic returns for one frame: its computed props plus,
49
+ * for every declared slot, a fill description. Strict against the contract — props
50
+ * must match, and every declared slot must be filled (use {@link when} for
51
+ * conditional presence). Carries the child's contract per slot (via `SlotProps`),
52
+ * so consumers can select and descend into sub-compositions type-safely.
53
+ *
54
+ * `events` rides ON the structure: because a `mount(...)`-returning body never
55
+ * calls its view, the event triggers it acquired (`yield* Event.trigger(X)`) are
56
+ * no longer captured by a view's `CaptureSink` record — they must travel with the
57
+ * frame so hosts can register them (proof `actions`, the wire `TriggerRegistry`,
58
+ * the react presentation `events` arg). Triggers are functions, so `events` is NOT
59
+ * serializable and is intentionally excluded from {@link structureEquals}. Typed to
60
+ * the contract's event map; omit it (defaults to `{}`) when the contract has none.
61
+ */
62
+ export interface Structure<C extends UiContract> {
63
+ readonly props: C['props']
64
+ readonly slots: StructureSlots<C>
65
+ readonly events?: EventsOf<C>
66
+ }
67
+
68
+ /** Runtime brand distinguishing a `Structure` value from a React `Node` (added by
69
+ * {@link mount}; the authoring TYPE intentionally omits it so structure literals stay
70
+ * plain). */
71
+ export const StructureTypeId: unique symbol = Symbol.for('reform/Structure')
72
+ export type StructureTypeId = typeof StructureTypeId
73
+
74
+ /**
75
+ * Assemble a frame's structure, checked against the contract `C`. Identity at
76
+ * runtime apart from stamping the {@link StructureTypeId} brand; its real job is
77
+ * the compile-time contract check. `C` is recovered from the surrounding `live`
78
+ * body's return type (contextual typing), so call sites read `mount({ props, slots })`.
79
+ * A leaf composition (no declared slots) passes `slots: {}`. `events` defaults to an
80
+ * empty map when omitted, so a frame always carries an `events` record for hosts.
81
+ */
82
+ export const mount = <C extends UiContract>(structure: Structure<C>): Structure<C> =>
83
+ Object.assign({ [StructureTypeId]: StructureTypeId, events: {} }, structure)
84
+
85
+ /**
86
+ * A list slot fill. `key` derives the stable identity for BOTH the wire node and
87
+ * the per-item state family — passing the same id source to both is no longer
88
+ * possible to get wrong. `props` maps each item to the child's external props.
89
+ */
90
+ export const each = <T, P>(
91
+ collection: Iterable<T>,
92
+ options: { readonly key: (item: T) => string; readonly props: (item: T) => P },
93
+ ): EachFill<P> => ({
94
+ _tag: 'Each',
95
+ items: Array.from(collection, (item) => ({ key: options.key(item), props: options.props(item) })),
96
+ })
97
+
98
+ /** A singleton slot fill — render one child with these external props. */
99
+ export const one = <P>(props: P): OneFill<P> => ({ _tag: 'One', props })
100
+
101
+ /** The shared empty fill — a slot that renders nothing this frame. */
102
+ export const absent: AbsentFill = { _tag: 'Absent' }
103
+
104
+ /** Conditional presence: the fill when `cond`, otherwise {@link absent}. */
105
+ export const when = <F extends SlotFill<unknown>>(cond: boolean, fill: F): F | AbsentFill =>
106
+ cond ? fill : absent
107
+
108
+ /** Whether a frame value is a `Structure` (vs a legacy React `Node`). The runtime
109
+ * discriminator hosts use during the migration, replacing React's `isValidElement`. */
110
+ export const isStructure = (frame: unknown): frame is Structure<UiContract> =>
111
+ typeof frame === 'object' && frame !== null && StructureTypeId in frame
112
+
113
+ /** Whether a value is a plain record (not an array) — narrows for the deep compare
114
+ * below without a cast. */
115
+ const isRecord = (u: unknown): u is Record<string, unknown> =>
116
+ typeof u === 'object' && u !== null && !Array.isArray(u)
117
+
118
+ /** Minimal structural deep-equality for serializable shapes (plain JSON — the
119
+ * UI-contract guarantee). Avoids a heavyweight dep for the settle comparison. */
120
+ const deepEquals = (a: unknown, b: unknown): boolean => {
121
+ if (Object.is(a, b)) return true
122
+ if (Array.isArray(a) && Array.isArray(b)) {
123
+ return a.length === b.length && a.every((item, index) => deepEquals(item, b[index]))
124
+ }
125
+ if (isRecord(a) && isRecord(b)) {
126
+ const aKeys = Object.keys(a)
127
+ const bKeys = Object.keys(b)
128
+ return (
129
+ aKeys.length === bKeys.length &&
130
+ aKeys.every(
131
+ (key) => Object.prototype.hasOwnProperty.call(b, key) && deepEquals(a[key], b[key]),
132
+ )
133
+ )
134
+ }
135
+ return false
136
+ }
137
+
138
+ /** Structural equality over two structures — props and every slot fill compared by
139
+ * value. `events` is intentionally excluded: triggers are functions, freshly
140
+ * acquired each render, so comparing them by identity would never match and would
141
+ * break the settle fixpoint. The `StructureTypeId` brand is a symbol key, so
142
+ * `Object.keys` skips it. Used by the proof/server settle loop to detect a
143
+ * two-frame fixpoint. */
144
+ export const structureEquals = (a: Structure<UiContract>, b: Structure<UiContract>): boolean =>
145
+ deepEquals(a.props, b.props) && deepEquals(a.slots, b.slots)
@@ -1,8 +1,6 @@
1
1
  import { expect, test } from 'vitest'
2
- import { Effect, Schema as S } from 'effect'
3
- import { Ui, isUi, ui } from '../index'
4
- import { CaptureSink, type UiCapture } from '../internal/capture'
5
- import type { Trigger } from '../ui/trigger'
2
+ import { Schema as S } from 'effect'
3
+ import { isUi, ui } from '../index'
6
4
 
7
5
  // `ui` has two authoring forms with one source of truth each: the type-only form
8
6
  // (local, no schema) and the schema form (wired, the type is *derived*). Both are
@@ -34,29 +32,3 @@ test('schema `ui` with no events omits the events schema', () => {
34
32
  expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
35
33
  expect(TitleUi.manifest.events).toBeUndefined()
36
34
  })
37
-
38
- test('a contract resolves its presentation and reports to a capturing sink', () => {
39
- class CounterUi extends ui('Counter', {
40
- props: S.Struct({ count: S.Number }),
41
- events: { bump: S.Struct({ by: S.Number }) },
42
- }) {}
43
-
44
- const view = Ui.make(CounterUi, ({ count }) => `count:${count}`)
45
- const records: UiCapture[] = []
46
- const sink = { record: (capture: UiCapture): void => void records.push(capture) }
47
-
48
- const logicView = Effect.runSync(
49
- CounterUi.pipe(
50
- Effect.provideService(CounterUi.impl, view),
51
- Effect.provideService(CaptureSink, sink),
52
- ),
53
- )
54
-
55
- const bump: Trigger<{ by: number }> = () => {}
56
- const node = logicView({ count: 3 }, { bump })
57
-
58
- expect(node).toBe('count:3')
59
- expect(records).toHaveLength(1)
60
- expect(records[0]?.name).toBe('Counter')
61
- expect(records[0]?.props).toEqual({ count: 3 })
62
- })
package/src/compose/ui.ts CHANGED
@@ -1,10 +1,7 @@
1
1
  import type { FunctionComponent } from 'react'
2
- import { Context, Effect, Option, type Schema } from 'effect'
3
- import { type Manifest, yieldableClass } from '../definition/definition'
4
- import { CaptureSink } from '../internal/capture'
5
- import { SlotRenderingUnavailable } from '../internal/errors'
2
+ import { Context, type Schema } from 'effect'
3
+ import { type Manifest, definitionClass } from '../definition/definition'
6
4
  import type { Trigger } from '../ui/trigger'
7
- import { CurrentSlots } from './host'
8
5
  import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './slot'
9
6
 
10
7
  export interface UiContract {
@@ -14,7 +11,10 @@ export interface UiContract {
14
11
  }
15
12
 
16
13
  type PropsOf<C extends UiContract> = C['props']
17
- type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
14
+ /** The contract's event map (`{ [name]: Trigger<P> }`), or empty when it declares
15
+ * none. Exported so a `Structure<C>` can type its optional `events` field to the
16
+ * same map a composition body acquires. */
17
+ export type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
18
18
  // A slot is a React COMPONENT whose props are OPTIONAL (a `Partial`): a parent that
19
19
  // drives a child supplies them (`<slots.Item id=… />` / `createElement(slots.Item, {…})`),
20
20
  // but a placeholder render (the remote client, where the per-child props were captured
@@ -35,15 +35,18 @@ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
35
35
  // child props are `any` the whole thing still collapses to `any` (`any & X = any`), keeping
36
36
  // the component assignable to a bare `(props: unknown) => Node` slot-renderer shape. An
37
37
  // outer intersection would leave a required-shape object that `unknown` can't satisfy.
38
+ //
39
+ // A slot handle is BOTH renderable (the `FunctionComponent` — places the keyed children)
40
+ // AND inspectable (`.props` — the array of per-child fill props the structure carried, one
41
+ // entry per mounted child, typed by the child contract). A presentation can read the fills
42
+ // (`slots.Item.props.length`, a header, layout decisions) and still render them
43
+ // (`<slots.Item/>`); it never DERIVES multiplicity — the engine already fixed it. Read-only.
38
44
  readonly [K in keyof S]: FunctionComponent<
39
45
  Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
40
- >
46
+ > & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
41
47
  }
42
48
  : Record<never, never>
43
49
 
44
- /** What the composition logic calls: props + handlers in, a node out. Slots are bound by the renderer. */
45
- export type LogicView<C extends UiContract> = (props: PropsOf<C>, events?: EventsOf<C>) => Node
46
-
47
50
  /** What `.make` authors: the pure presentation. Slots are injected by the renderer. */
48
51
  export type ViewImpl<C extends UiContract> = (
49
52
  props: PropsOf<C>,
@@ -73,8 +76,9 @@ export interface WiredUiManifest extends UiManifest {
73
76
  readonly props: Schema.Schema<any, any>
74
77
  }
75
78
 
76
- export interface UiClass<C extends UiContract>
77
- extends Effect.Effect<LogicView<C>, never, ViewImpl<C>> {
79
+ // A branded, extendable class carrying the wire `manifest` + the DI `impl` tag. Not
80
+ // yieldable: bodies return a `Structure`, and the host reads `impl` to resolve the view.
81
+ export interface UiClass<C extends UiContract> {
78
82
  new (): {}
79
83
  readonly [UiTypeId]: UiTypeId
80
84
  readonly manifest: UiManifest
@@ -144,34 +148,9 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
144
148
  manifest: M,
145
149
  ): UiClass<C> & { readonly manifest: M } => {
146
150
  const impl = Context.GenericTag<ViewImpl<C>, ViewImpl<C>>(`reform/ui/${name}`)
147
- const read = Effect.gen(function* () {
148
- const render = yield* impl
149
- // Slots are bound by the host (`@reform/react`) per composition instance.
150
- // Core has no renderer, so without a host every slot access throws.
151
- const host = yield* Effect.serviceOption(CurrentSlots)
152
- const slots = new Proxy({} as SlotsOf<C>, {
153
- get(_t, key) {
154
- if (Option.isNone(host)) {
155
- throw new SlotRenderingUnavailable({ slot: String(key) })
156
- }
157
- return host.value.slot(String(key))
158
- },
159
- })
160
- // A capturing host (proofs, the dev tool) reports each render's observable
161
- // surface without replacing the presentation. Absent in production.
162
- const sink = yield* Effect.serviceOption(CaptureSink)
163
- return (props: PropsOf<C>, events?: EventsOf<C>) => {
164
- if (Option.isSome(sink)) {
165
- sink.value.record({
166
- name,
167
- props,
168
- events: (events ?? {}) as Record<string, Trigger<unknown>>,
169
- })
170
- }
171
- return render(props, slots, (events ?? {}) as EventsOf<C>)
172
- }
173
- })
174
- return yieldableClass(read, {
151
+ // Extendable definition class (`class Counter extends ui('Counter', …) {}`); resolved
152
+ // by name on a host via `impl`, never yielded.
153
+ return definitionClass<UiClass<C> & { readonly manifest: M }>({
175
154
  [UiTypeId]: UiTypeId,
176
155
  manifest,
177
156
  impl,
@@ -179,9 +158,8 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
179
158
  }
180
159
 
181
160
  /**
182
- * A renderer-neutral UI contract. The logic resolves it (`yield* TodoAppUi`) to
183
- * a `(props, events) => node` view with slots already bound; `.make` authors the
184
- * presentation. Core defines the contract; `@reform/react` does the rendering.
161
+ * A renderer-neutral UI contract: `.make` authors the presentation, a composition
162
+ * names it, and `@reform/react` resolves it to a view. Core defines the contract.
185
163
  *
186
164
  * Two authoring forms, one source of truth each:
187
165
  *
package/src/index.ts CHANGED
@@ -56,8 +56,25 @@ export * as Composition from './compose/composition'
56
56
  export type {
57
57
  CompositionClass,
58
58
  CompositionService,
59
+ Frame,
59
60
  RenderEnv,
60
61
  } from './compose/composition'
62
+ export {
63
+ absent,
64
+ each,
65
+ type EachFill,
66
+ isStructure,
67
+ mount,
68
+ one,
69
+ type OneFill,
70
+ type AbsentFill,
71
+ type SlotFill,
72
+ type Structure,
73
+ structureEquals,
74
+ type StructureSlots,
75
+ StructureTypeId,
76
+ when,
77
+ } from './compose/structure'
61
78
  export { Props } from './compose/props'
62
79
  export {
63
80
  isSlot,
@@ -68,12 +85,11 @@ export {
68
85
  type SlotProps,
69
86
  SlotTypeId,
70
87
  } from './compose/slot'
71
- export { CurrentSlots, type SlotHost } from './compose/host'
88
+ export { type SlotRenderer } from './compose/host'
72
89
  export * as Ui from './compose/ui'
73
90
  export {
74
91
  type DerivedContract,
75
92
  isUi,
76
- type LogicView,
77
93
  type MadeView,
78
94
  ui,
79
95
  type UiClass,
@@ -151,7 +167,6 @@ export {
151
167
  FeatureLoadFailed,
152
168
  forceSync,
153
169
  InvalidProvideTarget,
154
- SlotRenderingUnavailable,
155
170
  UnknownGroupState,
156
171
  } from './internal/errors'
157
172
  // Notification scheduler: hosts that need per-runtime isolation (concurrent SSR,
@@ -10,6 +10,14 @@ export interface UiCapture {
10
10
  readonly name: string
11
11
  readonly props: unknown
12
12
  readonly events: Record<string, Trigger<unknown>>
13
+ /**
14
+ * The stable slot-fill key this render was mounted under (an `each` item's
15
+ * `key`, or a synthesized singleton key for a `one`). Present only on the
16
+ * structure path — the legacy Node path defers slot expansion to host
17
+ * components and has no fill key, so it is omitted there. Lets the proof
18
+ * facade select a child by key (`byKey`) instead of by render index.
19
+ */
20
+ readonly key?: string
13
21
  }
14
22
 
15
23
  /** Where captured renders are reported, when a capturing host is present. */
@@ -19,18 +19,6 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
19
19
  /** The fieldless variant — a `Data.TaggedError(tag)<{}>` constructor. */
20
20
  type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & { readonly _tag: Tag }
21
21
 
22
- const SlotRenderingUnavailableBase: TaggedErrorClass<
23
- 'reform/SlotRenderingUnavailable',
24
- { readonly slot: string }
25
- > = Data.TaggedError('reform/SlotRenderingUnavailable')<{ readonly slot: string }>
26
-
27
- /** A `<slots.X/>` was rendered without a render-target host to realize it. */
28
- export class SlotRenderingUnavailable extends SlotRenderingUnavailableBase {
29
- override get message(): string {
30
- return `reform: rendering slot '${this.slot}' requires @reform/react`
31
- }
32
- }
33
-
34
22
  const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
35
23
  Data.TaggedError('reform/InvalidProvideTarget')<{}>
36
24
 
@@ -1,8 +1,10 @@
1
1
  import { Layer } from 'effect'
2
2
  import type { CompositionClass, CompositionService } from '../compose/composition'
3
3
  import type { UiContract } from '../compose/ui'
4
+ import type { EventOf } from '../event/event'
4
5
  import { CurrentSeedOverrides } from '../internal/seeds'
5
- import type { Bus, Tagged } from '../runtime/bus'
6
+ import type { SeedsOf } from '../state/stateGroup'
7
+ import type { Bus } from '../runtime/bus'
6
8
 
7
9
  // A scene is a VALUE — a composition plus the closed wiring that runs it: the
8
10
  // layers that supply its logic/views (each seeding its own live state) and the
@@ -23,27 +25,37 @@ import type { Bus, Tagged } from '../runtime/bus'
23
25
  */
24
26
  export type MountedServices = CompositionService | Bus
25
27
 
26
- export interface Scene<C extends UiContract = UiContract> {
28
+ // A scene's boot list holds CONSTRUCTED events (`Event.construct(E, payload)`),
29
+ // not bare tagged objects — a lightweight nudge toward the real builder. It is
30
+ // not a guarantee the event is handled by the wiring (reform carries no
31
+ // type-level union of handled tags), only that it came from an event definition.
32
+ type BootEvent = EventOf<string, unknown>
33
+
34
+ export interface Scene<
35
+ C extends UiContract = UiContract,
36
+ S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
37
+ > {
27
38
  readonly kind: 'Scene'
28
- /** The composition to run, with its contract preserved for typed consumers. */
29
- readonly composition: CompositionClass<unknown, C>
39
+ /** The composition to run, with its contract + states preserved for typed consumers. */
40
+ readonly composition: CompositionClass<unknown, C, S>
30
41
  /** Closed wiring (logic + views), each layer seeding its own live state. */
31
42
  readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
32
43
  /** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
33
- readonly boot?: ReadonlyArray<Tagged>
44
+ readonly boot?: ReadonlyArray<BootEvent>
34
45
  }
35
46
 
36
47
  /**
37
48
  * Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
38
- * The composition's contract flows through `C`, so consumers stay typed.
49
+ * The composition's contract `C` and state tuple `S` flow through, so consumers
50
+ * (the facade, `seedScene`) stay typed.
39
51
  */
40
- export const scene = <C extends UiContract>(
41
- composition: CompositionClass<unknown, C>,
52
+ export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
53
+ composition: CompositionClass<unknown, C, S>,
42
54
  config: {
43
55
  readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
44
- readonly boot?: ReadonlyArray<Tagged>
56
+ readonly boot?: ReadonlyArray<BootEvent>
45
57
  },
46
- ): Scene<C> => ({
58
+ ): Scene<C, S> => ({
47
59
  kind: 'Scene',
48
60
  composition,
49
61
  provide: config.provide,
@@ -63,10 +75,10 @@ export const scene = <C extends UiContract>(
63
75
  * an invalid value silently falls back to the authored seed. `StateFamily`
64
76
  * entries are not covered.
65
77
  */
66
- export const seedScene = <C extends UiContract>(
67
- base: Scene<C>,
68
- seeds: Readonly<Record<string, unknown>>,
69
- ): Scene<C> =>
78
+ export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
79
+ base: Scene<C, S>,
80
+ seeds: SeedsOf<S>,
81
+ ): Scene<C, S> =>
70
82
  Object.keys(seeds).length === 0
71
83
  ? base
72
84
  : { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
@@ -1,11 +1,11 @@
1
1
  import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
2
2
  import { expect, test } from 'vitest'
3
3
  import {
4
- type CaptureSinkApi,
5
- CaptureSink,
6
4
  Composition,
7
5
  Engine,
8
6
  Event,
7
+ isStructure,
8
+ mount,
9
9
  provide,
10
10
  publish,
11
11
  Reducer,
@@ -18,13 +18,14 @@ import {
18
18
  Ui,
19
19
  ui,
20
20
  } from '../index'
21
+ import { CurrentSeedOverrides } from '../internal/seeds'
21
22
 
22
23
  // `seedScene` is the tooling seam over CLOSED scenes: the app bundle below seeds
23
24
  // `count: 1` itself (no open store), and the overlay must reach the nested
24
25
  // `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
25
26
  // pre-composed layers. The harness mirrors the dev-tool preview: merge the
26
27
  // scene's `provide`, mount it in a ManagedRuntime, and read the root
27
- // composition's rendered props through the headless CaptureSink.
28
+ // composition's rendered props through the headless Structure.
28
29
 
29
30
  class CountState extends State.make('count', S.Number) {}
30
31
  class MiniStates extends StateGroup.make(CountState) {}
@@ -46,8 +47,7 @@ class Counter extends Composition.make('Counter', {
46
47
  }) {}
47
48
  const CounterLive = Composition.live(Counter, function* () {
48
49
  const count = yield* StateGroup.select(MiniStates, 'count')
49
- const view = yield* CounterUi
50
- return view({ count })
50
+ return mount({ props: { count }, slots: {} })
51
51
  })
52
52
 
53
53
  // The closed app layer a scene provides: views + logic + Engine, with the state
@@ -61,37 +61,44 @@ const MiniApp = Views.pipe(Layer.provideMerge(Logic))
61
61
 
62
62
  const CounterScene = scene(Counter, { provide: [MiniApp] })
63
63
 
64
+ // The dev-tool inspector overlays raw, user-typed seeds (`Record<string, unknown>`)
65
+ // — the untyped seam that `seedScene` wraps once a composition's state tuple is
66
+ // known. These two cases (absent key / schema-invalid value) are compile errors
67
+ // through the now-typed `seedScene`, so they're exercised here at the raw overlay
68
+ // to prove `resolveSeed`'s runtime defensiveness still falls back to the seed.
69
+ const overlayRawSeeds = (base: Scene, seeds: Readonly<Record<string, unknown>>): Scene => ({
70
+ ...base,
71
+ provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
72
+ })
73
+
64
74
  const headlessEnv: RenderEnv = {
65
75
  props: {},
66
76
  tracker: { add: () => {} },
67
- slots: { slot: () => () => null },
68
77
  }
69
78
 
70
- // One ManagedRuntime per scene under test, with its own capture cell so two
71
- // runtimes in the same test (the no-leakage case) cannot bleed into each other.
72
- // Each `read` re-renders the root composition, repopulating the cell from the
73
- // stores that runtime actually built.
79
+ const isCountProps = (props: unknown): props is { readonly count: number } =>
80
+ typeof props === 'object' &&
81
+ props !== null &&
82
+ 'count' in props &&
83
+ typeof props.count === 'number'
84
+
85
+ // One ManagedRuntime per scene under test. Each `read` re-renders the root
86
+ // composition and reads the Structure built from the stores that runtime actually
87
+ // constructed.
74
88
  const makeHarness = (s: Scene) => {
75
- const captured: { count: number } = { count: -1 }
76
- const sink: CaptureSinkApi = {
77
- record: (capture) => {
78
- const props = capture.props
79
- if (
80
- typeof props === 'object' &&
81
- props !== null &&
82
- 'count' in props &&
83
- typeof props.count === 'number'
84
- ) {
85
- captured.count = props.count
86
- }
87
- },
88
- }
89
- const layer = s.provide
90
- .reduce((a, b) => Layer.merge(a, b))
91
- .pipe(Layer.provideMerge(Layer.succeed(CaptureSink, sink)))
89
+ const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
92
90
  const runtime = ManagedRuntime.make(layer)
93
91
  const read = Effect.flatMap(Counter.tag, (c) =>
94
- Composition.render(c, headlessEnv).pipe(Effect.map(() => captured.count)),
92
+ Composition.render(c, headlessEnv).pipe(
93
+ Effect.flatMap((frame) => {
94
+ if (!isStructure(frame)) {
95
+ return Effect.dieMessage('Counter must render a Structure')
96
+ }
97
+ return isCountProps(frame.props)
98
+ ? Effect.succeed(frame.props.count)
99
+ : Effect.dieMessage('Counter Structure props must include numeric count')
100
+ }),
101
+ ),
95
102
  )
96
103
  return { runtime, read }
97
104
  }
@@ -134,7 +141,7 @@ test('the live reducer drives the SAME overridden store', async () => {
134
141
  })
135
142
 
136
143
  test('an absent key leaves the authored seed in place', async () => {
137
- const { runtime, read } = makeHarness(seedScene(CounterScene, { other: 1 }))
144
+ const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { other: 1 }))
138
145
  try {
139
146
  expect(await runtime.runPromise(read)).toBe(1)
140
147
  } finally {
@@ -143,7 +150,7 @@ test('an absent key leaves the authored seed in place', async () => {
143
150
  })
144
151
 
145
152
  test('a schema-invalid override falls back to the authored seed', async () => {
146
- const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 'not-a-number' }))
153
+ const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { count: 'not-a-number' }))
147
154
  try {
148
155
  expect(await runtime.runPromise(read)).toBe(1)
149
156
  } finally {
@@ -51,6 +51,26 @@ export type GroupSeeds<G extends AnyStateGroup> =
51
51
  ? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
52
52
  : never
53
53
 
54
+ // Collapse a union of records into their intersection — merges every group's
55
+ // seed record into one. (The classic contravariant-position trick.)
56
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
57
+ k: infer I,
58
+ ) => void
59
+ ? I
60
+ : never
61
+
62
+ /** The seed record for one tuple entry: a group's `GroupSeeds`, else nothing. */
63
+ type EntrySeeds<G> = G extends AnyStateGroup ? GroupSeeds<G> : never
64
+
65
+ /**
66
+ * The optional seed overrides for a composition's whole `states` tuple, keyed by
67
+ * member name and typed to each member's value. Every key is optional (seeds are
68
+ * overrides over the authored initial). Tuple entries that aren't state groups
69
+ * contribute no keys, so a partially-grouped `states` still types its groups.
70
+ * Powers `seedScene` — a typo'd key or mistyped value is a compile error.
71
+ */
72
+ export type SeedsOf<St extends ReadonlyArray<unknown>> = Partial<UnionToIntersection<EntrySeeds<St[number]>>>
73
+
54
74
  /** Compose atomic States into a group provided (and addressed) as a unit. */
55
75
  export const make = <const Members extends ReadonlyArray<AnyState>>(
56
76
  ...members: Members & NoDuplicateNames<Members>