@playfast/reform 0.0.1 → 0.0.2

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.
Files changed (2) hide show
  1. package/README.md +551 -53
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,26 +1,85 @@
1
- # `reform` — core
2
-
3
- The renderer-neutral primitives and runtime. Renders nothing itself (that is
4
- `@reform/react`); the whole core is headless and testable without a DOM.
5
-
6
- ## What's here (v0)
7
-
8
- Every primitive follows the **definition / implementation split**: `X.make()`
9
- is a reflectable definition (a manifest + DI tag), provided separately by
10
- `.live`. Nothing self-registers on
11
- import.
12
-
13
- | primitive | definition | implementation |
14
- | ---------------------------------------- | ----------------------------------- | --------------------- |
15
- | `State` / `StateGroup` / `StateFamily` | `State.make(name, schema, opts)` | `.live` |
16
- | `Event` / `EventGroup` | `Event.make(name, schema)` | (pure data) |
17
- | `Reducer` | `Reducer.make(name, { states/family, events })` | `.live(fold)` |
18
- | `Calc` | `Calc.make(name, { inputs, output })` | `.live(fn)` |
19
- | `AsyncCalc` | `AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })` | `.live({ query })` |
20
- | `RemoteState` | `RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents })` | `.live({ query, send, apply, … })` |
21
- | `Procedure` | `Procedure.make(name, { events, concurrency })` | `.live(fn*)` |
22
- | `Composition` | `Composition.make(name, manifest)` | `.live(fn*)` |
23
- | `ui` / `slot` | `ui(name)<C>()` / `slot(name)<P>()` | `.make` / `provide` |
1
+ <div align="center">
2
+
3
+ # `@playfast/reform`
4
+
5
+ **The renderer-neutral core of the reform framework.**
6
+ State, events, reducers, derived values, async & remote data, and compositions — headless, typed end to end, and provable without a DOM.
7
+
8
+ [![npm](https://img.shields.io/npm/v/@playfast/reform.svg)](https://www.npmjs.com/package/@playfast/reform)
9
+ [![license](https://img.shields.io/npm/l/@playfast/reform.svg)](#license)
10
+ [![built with Effect](https://img.shields.io/badge/built%20with-Effect-5a67d8.svg)](https://effect.website)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/react`](https://www.npmjs.com/package/@playfast/react), [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native)) turns a closed *scene* into a live tree, and [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ bun add @playfast/reform effect
22
+ # npm install @playfast/reform effect · pnpm add @playfast/reform effect
23
+ ```
24
+
25
+ `effect` is a peer dependency. `react` is an optional peer (only the React-facing primitives need it).
26
+
27
+ ## Contents
28
+
29
+ - [The core idea: definition / implementation split](#the-core-idea-definition--implementation-split)
30
+ - [The model](#the-model)
31
+ - **Reference**
32
+ - [State, StateGroup, StateFamily](#state)
33
+ - [Event, EventGroup](#event)
34
+ - [Reducer](#reducer)
35
+ - [Calc, CalcFamily](#calc)
36
+ - [AsyncCalc & AsyncData](#asynccalc)
37
+ - [RemoteState](#remotestate)
38
+ - [Boundary](#boundary)
39
+ - [Procedure & Channel](#procedure)
40
+ - [Composition, ui, slot, provide](#composition)
41
+ - [Feature (lazy code-splitting)](#feature)
42
+ - [Scene](#scene)
43
+ - [Engine & runtime surface](#engine)
44
+ - [Putting it together](#putting-it-together)
45
+ - [The reform family](#the-reform-family)
46
+ - [License](#license)
47
+
48
+ ---
49
+
50
+ ## The core idea: definition / implementation split
51
+
52
+ Every primitive comes in two halves. `X.make(…)` is a **reflectable definition** — a manifest plus a DI tag, safe to import anywhere and to inspect. `X.live(…)` provides its **behavior** as a layer. Nothing self-registers on import, so the dependency graph is explicit, tree-shakeable, and fully testable.
53
+
54
+ ```ts
55
+ import { Schema as S } from 'effect'
56
+ import { State, Event, Reducer } from '@playfast/reform'
57
+
58
+ class Count extends State.make('count', S.Number) {}
59
+ class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
60
+
61
+ class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
62
+ const BumpLive = Reducer.live(Bump, (n, e) => n + e.by)
63
+ ```
64
+
65
+ A `State` lives in a plain reactive store; reducers only *return* values, so by construction they are the **only** writers. Reads return the current snapshot through Effect.
66
+
67
+ ### The primitives at a glance
68
+
69
+ | primitive | definition | implementation |
70
+ | --- | --- | --- |
71
+ | [`State` / `StateGroup` / `StateFamily`](#state) | `State.make(name, schema, opts?)` | `.live(initial)` |
72
+ | [`Event` / `EventGroup`](#event) | `Event.make(name, schema)` | — (pure data) |
73
+ | [`Reducer`](#reducer) | `Reducer.make(name, { states / family, events })` | `.live(fold)` |
74
+ | [`Calc` / `CalcFamily`](#calc) | `Calc.make(name, { inputs, output })` | `.live(fn)` |
75
+ | [`AsyncCalc`](#asynccalc) | `AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })` | `.live({ query, … })` |
76
+ | [`RemoteState`](#remotestate) | `RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents })` | `.live({ query, send, apply, … })` |
77
+ | [`Boundary`](#boundary) | `Boundary.make(name, { over })` | `.live({ once? })` |
78
+ | [`Procedure`](#procedure) | `Procedure.make(name, { events, channel })` | `.live(fn*)` |
79
+ | [`Channel`](#procedure) | `Channel.make(name, { policy })` | `.live()` |
80
+ | [`Composition`](#composition) | `Composition.make(name, manifest)` | `.live(fn*)` |
81
+ | [`ui` / `slot`](#composition) | `ui(name)<C>()` / `slot(name)<Comp>()` | `Ui.make` / `provide` |
82
+ | [`Feature`](#feature) | `Feature.make(name, config)` | (eager `module` / lazy `load`) |
24
83
 
25
84
  ## The model
26
85
 
@@ -32,33 +91,472 @@ procedure ──► Bus (Normal)┘ │
32
91
  └────────────── reads state, dispatches ◄──── calc (derived, memoized)
33
92
  ```
34
93
 
35
- - **State** lives in plain reactive stores outside Effect; `set` is internal, so
36
- reducers (which only *return* values) are the sole writers by construction.
37
- - **The bus** is one bounded `PubSub`. A central loop drains it and runs matching
38
- reducers per event; **procedures** consume it on their own forked fibers with a
39
- concurrency policy (`latest` = switch, `merge` = unbounded).
40
- - **Reads** (`yield* group.state('x')`, `yield* Calc`, `family.at(k).read`) return
41
- the current snapshot. The yieldable class trick (a class whose static prototype
42
- is an Effect) is what lets `yield* VisibleTodos` return the derived value.
43
- - **Derived reads** take any `Source` as input (a state member, a `Calc`, or an
44
- `AsyncCalc`) and accept an optional `invalidateBy` queryKey projection that
45
- bounds recompute/re-fetch to a value-equal key change. `AsyncCalc` yields an
46
- `AsyncData` query lifecycle (`Idle`/`Loading`/`Success`/`Error`), matched with
47
- `effect`'s `Match`.
48
- - **Remote state is derived-only.** `RemoteState` is server-owned state with
49
- optimistic mutations: the only writer is the server, the only write surface is
50
- dispatching one of the declared intent events, and the visible value is
51
- `pending.reduce(apply, serverTruth)`. A failed or settled intent leaves the
52
- queue and the derivation converges — no rollback machinery. Settles obey the
53
- generation rule (an intent acked at query-run generation g settles only on a
54
- later-generation `Success` run), so an optimistic change never flickers out
55
- between the RPC confirm and the refetch landing.
56
- - **Notifications** coalesce into one microtask flush (the React bridge will bind
57
- `getSnapshot`/`subscribe`).
58
-
59
- ## Status
60
-
61
- Engine complete and proven headless (see `examples/todo/src/app/engine.test.ts`):
62
- boot load → add → toggle → filter flows through events, procedures, reducers
63
- and the calc, and a composition renders against a capturing UI. React rendering,
64
- the editor and proofs are the next phases.
94
+ - **The bus** is one `PubSub`. A central drain loop batches events per microtask and runs matching reducers — `High`-priority (UI) folds before `Normal`-priority (procedure) folds within a frame. **Procedures** consume the bus on their own forked fibers, scheduled by their **channel**'s concurrency policy.
95
+ - **Derived reads** take any `Source` (a state member, a `Calc`, an `AsyncCalc`, a `RemoteState`) and accept an optional `invalidateBy` key projection that bounds recompute to a value-equal key change.
96
+ - **Notifications** coalesce into a single microtask flush, which the host's `useSyncExternalStore` bridge binds to.
97
+
98
+ ---
99
+
100
+ <a name="state"></a>
101
+ ## State · StateGroup · StateFamily
102
+
103
+ Reactive stores held outside Effect. Reducers are the sole writers; everything else reads.
104
+
105
+ ### `State`
106
+
107
+ ```ts
108
+ State.make(name, schema, options?) // StateClass
109
+ State.live(State, initial) // Layer<Store<value>>
110
+ ```
111
+
112
+ `options` is `{ title?, description? }` (reflectable metadata). A `State` class is itself yieldable `yield* Count` reads its current value.
113
+
114
+ ### `StateGroup`
115
+
116
+ A bundle of related states provided together. `StateGroup.select(Group, 'name')` returns a `StateToken` (a `Source`) used both as a calc input and as a yieldable read.
117
+
118
+ ```ts
119
+ import { State, StateGroup } from '@playfast/reform'
120
+
121
+ class Count extends State.make('count', S.Number) {}
122
+ class Step extends State.make('step', S.Number) {}
123
+ class Counter extends StateGroup.make(Count, Step) {}
124
+
125
+ const CounterLive = StateGroup.live(Counter, { count: 0, step: 1 }) // all members required
126
+
127
+ // read inside any Effect / composition:
128
+ const count = yield* StateGroup.select(Counter, 'count')
129
+ ```
130
+
131
+ ### `StateFamily`
132
+
133
+ A keyed collection — one store per key over a shared schema.
134
+
135
+ ```ts
136
+ StateFamily.make(name, keySchema, valueSchema, options?) // → StateFamilyClass
137
+ StateFamily.live(Family, initial | (key) => value, { evictWhenUnused? }?)
138
+
139
+ class Items extends StateFamily.make('items', S.String, ItemSchema) {}
140
+ const ItemsLive = StateFamily.live(Items, (id) => blankItem(id), { evictWhenUnused: true })
141
+
142
+ const item = yield* StateFamily.read(Items, id)
143
+ ```
144
+
145
+ `evictWhenUnused: true` ref-counts each key and drops it on the next microtask once its last subscriber leaves (re-access re-seeds from `initial`). A family reducer fold may return `StateFamily.Tombstone` to evict a key.
146
+
147
+ > **`StateToken` / `Source` / `AnySource`** — `Source<N, A>` is the structural shape (`{ name, store }`) that `StateToken`, `Calc`, `AsyncCalc`, and `RemoteState` all satisfy, so any of them can feed a calc's `inputs`. `AnySource = Source<string, any>`.
148
+
149
+ ---
150
+
151
+ <a name="event"></a>
152
+ ## Event · EventGroup
153
+
154
+ Events are pure tagged data — definitions only, no `.live`.
155
+
156
+ ```ts
157
+ Event.make(name, schema) // → EventClass; EventOf<N, P> = { _tag: N } & P
158
+ EventGroup.make(...events) // → bundle, used in Reducer/Composition manifests
159
+
160
+ class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(Todo) })) {}
161
+ ```
162
+
163
+ **Dispatching.** Two idioms, two priorities:
164
+
165
+ ```ts
166
+ // From a UI view — High priority, synchronous:
167
+ const toggle = yield* Event.trigger(ToggledTodo) // toggle: (payload) => void
168
+ // ...later: <input onChange={() => toggle({ id })} />
169
+
170
+ // From a procedure — Normal priority:
171
+ yield* Event.dispatch(TodoUpserted, { todo })
172
+ ```
173
+
174
+ `Event.trigger` returns a plain callback (`Trigger<P>`) you hand to the view; `Event.dispatch` returns an `Effect` you yield inside logic.
175
+
176
+ ---
177
+
178
+ <a name="reducer"></a>
179
+ ## Reducer
180
+
181
+ The only writers of state. A fold is a **pure, synchronous** `(value, event) => value` (async values throw at startup).
182
+
183
+ ```ts
184
+ Reducer.make(name, { states: [State], events: [...] }) // state reducer
185
+ Reducer.make(name, { family: Family, keyOf, events: [...] }) // family reducer
186
+ Reducer.live(Reducer, fold)
187
+ ```
188
+
189
+ State reducers receive and return the whole state value; family reducers receive and return one entry (or `StateFamily.Tombstone`). The idiomatic fold matches on the event tag with Effect's `Match`:
190
+
191
+ ```ts
192
+ import { Match } from 'effect'
193
+
194
+ const FeedReducerLive = Reducer.live(FeedReducer, (feed, event) =>
195
+ Match.value(event).pipe(
196
+ Match.tags({
197
+ StartedLoading: (): Feed => ({ _tag: 'Loading' }),
198
+ LoadedTodos: ({ todos }): Feed => ({ _tag: 'Ok', todos }),
199
+ FailedTodos: ({ message }): Feed => ({ _tag: 'Error', message }),
200
+ TodoRemoved: ({ id }) => onOk(feed, Array.filter((t) => t.id !== id)),
201
+ }),
202
+ Match.exhaustive,
203
+ ),
204
+ )
205
+ ```
206
+
207
+ An event outside the reducer's declared `events` never reaches the fold; the drain loop owns every `store.set`, so logic can never write state directly.
208
+
209
+ ---
210
+
211
+ <a name="calc"></a>
212
+ ## Calc · CalcFamily
213
+
214
+ Synchronous derived values — memoized projections over one or more sources.
215
+
216
+ ```ts
217
+ Calc.make(name, { inputs, output }) // inputs: ReadonlyArray<Source>
218
+ Calc.live(Calc, (inputs) => output, { invalidateBy?, reuse? }?)
219
+
220
+ class IsPositive extends Calc.make('IsPositive', {
221
+ inputs: [StateGroup.select(Counter, 'count')],
222
+ output: S.Boolean,
223
+ }) {}
224
+ const IsPositiveLive = Calc.live(IsPositive, ({ count }) => count > 0)
225
+
226
+ const positive = yield* IsPositive // read the memoized value
227
+ ```
228
+
229
+ `inputs` are keyed in the compute argument by each source's **name**. A calc recomputes only when its invalidation key changes (default: all input values; override with `invalidateBy: (inputs) => [...]`). `reuse: true` does structural sharing on the output, keeping unchanged subtree identities stable across recomputes.
230
+
231
+ **`CalcFamily`** parameterizes a calc by key, one memoized store per key over shared inputs — each member notifies only its own subscribers:
232
+
233
+ ```ts
234
+ class GroupView extends CalcFamily.make('GroupView', { key: GroupId, inputs: [Board], output: GroupSchema }) {}
235
+ const GroupViewLive = CalcFamily.live(GroupView, (id) => ({ Board }) => project(Board, id), { evictWhenUnused: true })
236
+
237
+ const view = yield* CalcFamily.read(GroupView, groupId)
238
+ ```
239
+
240
+ ---
241
+
242
+ <a name="asynccalc"></a>
243
+ ## AsyncCalc & AsyncData
244
+
245
+ Server reads with a stale-while-revalidate lifecycle. The query re-runs reactively from its inputs.
246
+
247
+ ```ts
248
+ AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })
249
+ AsyncCalc.live(AsyncCalc, {
250
+ query, // (inputs) => Effect<A, E, R>
251
+ invalidateBy?, // (inputs) => ReadonlyArray<unknown> — refetch only when this key changes
252
+ invalidateOn?, // ReadonlyArray<Event> — also refetch on these events
253
+ coalesce?, // 'switch' (default, latest-wins) | 'trailing' (one trailing refetch after a burst)
254
+ reuse?, // structural-share Success values across refetches
255
+ disabled?, // (inputs) => boolean — gate the query off (Idle); gatable calcs only
256
+ })
257
+ ```
258
+
259
+ - `error` omitted ⇒ the query is infallible (no `Error` arm). `alwaysOn: true` ⇒ no `Idle` arm and `disabled` is rejected.
260
+ - Reading `yield* MyAsyncCalc` yields an **`AsyncData<A, E, Gated>`**:
261
+
262
+ | arm | `_tag` | fields | when |
263
+ | --- | --- | --- | --- |
264
+ | `AsyncIdle` | `'Idle'` | — | gated query is `disabled` |
265
+ | `AsyncLoading` | `'Loading'` | — | first fetch, no value yet |
266
+ | `AsyncSuccess` | `'Success'` | `value`, `refetching` | succeeded (`refetching: true` while re-fetching) |
267
+ | `AsyncError` | `'Error'` | `error`, `refetching` | failed |
268
+
269
+ ```ts
270
+ class Doubled extends AsyncCalc.make('Doubled', {
271
+ inputs: [StateGroup.select(Counter, 'count')],
272
+ output: S.Number,
273
+ alwaysOn: true,
274
+ }) {}
275
+ const DoubledLive = AsyncCalc.live(Doubled, {
276
+ query: ({ count }) => Effect.succeed(count * 2),
277
+ })
278
+
279
+ const data = yield* Doubled
280
+ Match.value(data).pipe(
281
+ Match.tag('Loading', () => spinner),
282
+ Match.tag('Success', ({ value }) => render(value)),
283
+ Match.exhaustive,
284
+ )
285
+ ```
286
+
287
+ ---
288
+
289
+ <a name="remotestate"></a>
290
+ ## RemoteState
291
+
292
+ Server-owned state with optimistic mutations, fused into one primitive. **Remote state is derived-only:** the only writer is the server, the only write surface is dispatching a declared *intent*, and the visible value is `pending.reduce(apply, serverTruth)`.
293
+
294
+ ```ts
295
+ RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents }) // intents: Event definitions
296
+ RemoteState.live(Remote, {
297
+ query, // (inputs) => Effect<A, E, R> — same as AsyncCalc
298
+ send, // (intent) => Effect<_, _, R2> — deliver one intent to the server
299
+ apply, // (value, intent) => value — pure, total, idempotent overlay fold
300
+ channel?, // send lane (default: a generated `merge` channel)
301
+ invalidateBy?, invalidateOn?, coalesce?, reuse?, disabled?, // inherited
302
+ })
303
+ ```
304
+
305
+ Class surface:
306
+
307
+ - `yield* Board` → the **overlaid** `AsyncData<A, E, Gated>` (server truth with pending intents applied).
308
+ - `Board.truth` → the un-overlaid query lifecycle (a `Source`), for chrome that must show raw server state.
309
+ - `Board.pending` → read-only `Source` of `ReadonlyArray<PendingIntent<I>>` (`{ opId, intent, status: 'sending' | 'confirmed' }`).
310
+ - `Board.Failed` → a public `Event` carrying `FailedIntent` (`{ intent, error }`) for toasts / retry UX.
311
+
312
+ ```ts
313
+ class Board extends RemoteState.make('Board', {
314
+ inputs: [Session, StateGroup.select(Router, 'route')],
315
+ output: BoardSnapshot,
316
+ error: S.String,
317
+ intents: [ItemAddIntent, ItemRenameIntent],
318
+ }) {}
319
+
320
+ const BoardLive = RemoteState.live(Board, {
321
+ query: ({ route }) => loadBoard(route.boardId),
322
+ send: (intent) => Match.value(intent).pipe(
323
+ Match.tag('ItemAddIntent', ({ groupId, name }) => client.AddItem({ groupId, name })),
324
+ Match.tag('ItemRenameIntent', ({ itemId, name }) => client.RenameItem({ id: itemId, name })),
325
+ Match.exhaustive,
326
+ ),
327
+ apply: (snapshot, intent) => applyIntent(snapshot, intent), // idempotent
328
+ invalidateOn: [BoardChanged],
329
+ coalesce: 'trailing',
330
+ })
331
+ ```
332
+
333
+ **Semantics.** A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and `Board.Failed` fires — there is **no rollback machinery**, the derivation just converges. The **generation rule** guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation *g* is settled only by a later-generation `Success` run.
334
+
335
+ ---
336
+
337
+ <a name="boundary"></a>
338
+ ## Boundary
339
+
340
+ Merges the lifecycle arms of several async sources into one first-load signal, so a subtree shows a single fallback instead of per-query spinners.
341
+
342
+ ```ts
343
+ Boundary.make(name, { over }) // over: ReadonlyArray<AsyncCalc | RemoteState | ...>
344
+ Boundary.live(Boundary, { once? }?)
345
+
346
+ class BootBoundary extends Boundary.make('BootBoundary', { over: [Session, BootstrapQuery, Board] }) {}
347
+ const BootBoundaryLive = Boundary.live(BootBoundary, { once: true })
348
+
349
+ const boot = yield* BootBoundary // BoundaryState
350
+ ```
351
+
352
+ `yield* BootBoundary` yields a `BoundaryState`:
353
+
354
+ - **`BoundaryPending`** — some source is on its first load.
355
+ - **`BoundaryReady`** — every source has settled (`Success`, even `refetching`, or a deliberately gated `Idle`).
356
+ - **`BoundaryErrored`** — a source errored before first value; carries `errors: ReadonlyArray<unknown>`.
357
+
358
+ `once: true` latches: once `Ready`, it stays `Ready` (a boot boundary won't re-splash when a later navigation first-loads a route-gated query). It latches only on converged values, never mid-flush.
359
+
360
+ ---
361
+
362
+ <a name="procedure"></a>
363
+ ## Procedure & Channel
364
+
365
+ Procedures are side-effecting reactions to events, running on forked fibers. A **channel** is the named concurrency lane they run on.
366
+
367
+ ```ts
368
+ Channel.make(name, { policy }) // policy: 'merge' | 'latest' | 'debounce' | 'throttle' | 'exclusive'
369
+ Channel.live(Channel)
370
+
371
+ Procedure.make(name, { events, channel })
372
+ Procedure.live(Procedure, function* (event) { … })
373
+ ```
374
+
375
+ The generator body receives the matched event, can read services from context, and dispatches follow-up events with `Event.dispatch`:
376
+
377
+ ```ts
378
+ class ActionsChannel extends Channel.make('Actions', { policy: { _tag: 'merge' } }) {}
379
+
380
+ class CreateTodo extends Procedure.make('CreateTodo', { events: [SubmittedNewTodo], channel: ActionsChannel }) {}
381
+ const CreateTodoLive = Procedure.live(CreateTodo, function* (event) {
382
+ const client = yield* TodosClient
383
+ const result = yield* Effect.either(client.AddTodo({ text: event.text }))
384
+ yield* Match.value(result).pipe(
385
+ Match.tags({
386
+ Right: ({ right }) => Event.dispatch(TodoUpserted, { todo: right }),
387
+ Left: ({ left }) => Event.dispatch(FailedTodos, { message: String(left) }),
388
+ }),
389
+ Match.exhaustive,
390
+ )
391
+ })
392
+ ```
393
+
394
+ Policies: `merge` (unbounded concurrency), `latest` (new event cancels the in-flight run), `exclusive` (serialized), `debounce`/`throttle` (rate-shaping). Many procedures may share one channel.
395
+
396
+ ---
397
+
398
+ <a name="composition"></a>
399
+ ## Composition · ui · slot · provide
400
+
401
+ A **composition** is a unit of logic that reads state and renders a typed UI contract. The contract (`ui`) and its presentation are authored separately, so logic stays renderer-neutral.
402
+
403
+ ```ts
404
+ // 1. declare the contract — props the view receives, events it can fire
405
+ class CounterUi extends ui('Counter')<{
406
+ props: { count: number }
407
+ events: { bump: Trigger<{}> }
408
+ }>() {}
409
+
410
+ // 2. declare the composition and what it reads
411
+ class Counter extends Composition.make('Counter', { ui: CounterUi, states: [Count] }) {}
412
+
413
+ // 3. implement the logic — read sources, return the view applied to computed props
414
+ const CounterLive = Composition.live(Counter, function* () {
415
+ const count = yield* StateGroup.select(Counters, 'count')
416
+ const bump = yield* Event.trigger(Bumped)
417
+ const view = yield* CounterUi
418
+ return view({ count }, { bump })
419
+ })
420
+
421
+ // 4. provide a presentation for the contract (DOM/native/custom)
422
+ const CounterView = provide(CounterUi, Ui.make(CounterUi, ({ count }, _slots, { bump }) =>
423
+ <button onClick={() => bump({})}>{count}</button>
424
+ ))
425
+ ```
426
+
427
+ - **`Composition.make(name, manifest)`** — manifest declares `ui` plus the `states` / `calcs` / `events` / `slots` it touches (all reflectable).
428
+ - **`Composition.live(comp, fn*)`** — the generator reads sources and returns a `Node` (the view applied to props/events).
429
+ - **`slot(name)<Comp>()`** — a hole a parent composition declares (`slots: { body: BodySlot }`) and fills with `provide(BodySlot, ChildComposition)` (or a `Feature`). Children render through the host.
430
+ - **`provide(...)`** — binds a UI presentation to a contract, or fills a slot with a composition / feature. Returns a `Layer`.
431
+
432
+ ---
433
+
434
+ <a name="feature"></a>
435
+ ## Feature
436
+
437
+ The lazy/eager code-split unit. Definitions stay eager and reflectable; the heavy implementation can load on demand as an **Effect** (never a bare Promise), so code-splitting composes with the rest of the layer graph.
438
+
439
+ ```ts
440
+ import { Feature, featureModule, lazyImport, mountFeature } from '@playfast/reform'
441
+
442
+ // eager: ship the module with the definition
443
+ class Counter extends Feature.make('counter', {
444
+ composition: CounterComp,
445
+ module: featureModule([], CounterLive),
446
+ boot: [Event.construct(Tick, {})],
447
+ }) {}
448
+
449
+ // lazy: defer the module behind an import
450
+ class Reports extends Feature.make('reports', {
451
+ loadingStrategy: 'lazy',
452
+ load: lazyImport(() => import('./reports.module')),
453
+ placeholder: { loading: SpinnerComp, failed: RetryComp },
454
+ }) {}
455
+ ```
456
+
457
+ A feature shares the app's engine but gets its own scope — `mountFeature(binding, engineContext)` loads it, builds its layer, dispatches its boot events, and disposes everything when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`; the host paints the placeholders while it loads.
458
+
459
+ ---
460
+
461
+ <a name="scene"></a>
462
+ ## Scene
463
+
464
+ A scene bundles a root composition with the closed wiring that runs it — the single handle the React host, React Native host, and proofs all consume.
465
+
466
+ ```ts
467
+ scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
468
+ seedScene(base, seeds) // tooling: override seed values
469
+ isScene(value) // reflection guard
470
+
471
+ const AppScene = scene(AppRoot, {
472
+ provide: [Engine, AppStateLive, AppLogicLive, AppViews],
473
+ boot: [Event.construct(AppStarted, {})],
474
+ })
475
+ ```
476
+
477
+ `provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) to mount or to [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
478
+
479
+ ---
480
+
481
+ <a name="engine"></a>
482
+ ## Engine & runtime surface
483
+
484
+ `Engine` is the layer that ties a runtime together: it provides the `Bus` (one `PubSub`), the `Reducers` / `Channels` / `Procedures` registries, and forks the single drain loop. Merge it at the root of your app's layers:
485
+
486
+ ```ts
487
+ import { Engine } from '@playfast/reform'
488
+
489
+ const AppLayer = Layer.mergeAll(
490
+ Engine,
491
+ StateGroup.live(AppStates, AppSeeds),
492
+ AppReducersLive,
493
+ AppProceduresLive,
494
+ AppClientsLive,
495
+ )
496
+ ```
497
+
498
+ Also exported for hosts and headless tests: `Bus` / `publish(priority, event)` / `Priority` (`'High' | 'Normal'`); the `Reducers` / `Channels` / `Procedures` registries and their `ReducerEntry` / `ProcedureEntry` shapes; `CaptureSink` (the capturing UI sink proofs assert against); the tagged errors (`DuplicateRegistration`, `FeatureLoadFailed`, `InvalidProvideTarget`, `SlotRenderingUnavailable`, `UnknownGroupState`, `AsyncReducer`); and the **notification scheduler** — `Notifications` / `notificationsLayer` / `makeScheduler` / `defaultScheduler`, which hosts provide upstream for per-runtime isolation (concurrent SSR, multiple mounted roots). Everything else coalesces on the process-wide default scheduler.
499
+
500
+ ---
501
+
502
+ ## Putting it together
503
+
504
+ A minimal counter, end to end:
505
+
506
+ ```ts
507
+ import { Schema as S, Layer, Match } from 'effect'
508
+ import {
509
+ State, StateGroup, Event, Reducer, Composition, ui, provide, Ui, Engine, scene,
510
+ type Trigger,
511
+ } from '@playfast/reform'
512
+
513
+ // state + event + reducer
514
+ class Count extends State.make('count', S.Number) {}
515
+ class Counters extends StateGroup.make(Count) {}
516
+ class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
517
+ class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
518
+
519
+ // contract + composition
520
+ class CounterUi extends ui('Counter')<{
521
+ props: { count: number }
522
+ events: { bump: Trigger<{ by: number }> }
523
+ }>() {}
524
+ class Counter extends Composition.make('Counter', { ui: CounterUi, states: [Count] }) {}
525
+
526
+ const logic = Layer.mergeAll(
527
+ StateGroup.live(Counters, { count: 0 }),
528
+ Reducer.live(Bump, (n, e) => n + e.by),
529
+ Composition.live(Counter, function* () {
530
+ const count = yield* StateGroup.select(Counters, 'count')
531
+ const bump = yield* Event.trigger(Bumped)
532
+ return (yield* CounterUi)({ count }, { bump })
533
+ }),
534
+ )
535
+
536
+ // a DOM presentation (rendered by @playfast/react)
537
+ const view = provide(CounterUi, Ui.make(CounterUi, ({ count }, _s, { bump }) =>
538
+ <button onClick={() => bump({ by: 1 })}>count: {count}</button>
539
+ ))
540
+
541
+ export const CounterScene = scene(Counter, { provide: [Engine, logic, view] })
542
+ ```
543
+
544
+ Mount it with [`@playfast/react`](https://www.npmjs.com/package/@playfast/react), or prove it headlessly with [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) — the same `CounterScene` value.
545
+
546
+ ---
547
+
548
+ ## The reform family
549
+
550
+ | Package | Role |
551
+ | --- | --- |
552
+ | **`@playfast/reform`** | Renderer-neutral core (this package) |
553
+ | [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) | React / DOM host |
554
+ | [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native) | React Native host |
555
+ | [`@playfast/forms`](https://www.npmjs.com/package/@playfast/forms) | Headless form state |
556
+ | [`@playfast/forms-react`](https://www.npmjs.com/package/@playfast/forms-react) | Typed JSX mapping for forms |
557
+ | [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) | Headless testing toolkit |
558
+ | [`@playfast/eslint-plugin`](https://www.npmjs.com/package/@playfast/eslint-plugin) | Lint rules for reform conventions |
559
+
560
+ ## License
561
+
562
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "sideEffects": [],
6
6
  "license": "MIT",