@playfast/reform 0.0.7 → 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.
Files changed (241) hide show
  1. package/package.json +2 -2
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/boundary/boundary.ts +192 -0
  4. package/src/calc/asyncCalc.test.ts +556 -0
  5. package/src/calc/asyncCalc.ts +267 -0
  6. package/src/calc/asyncData.ts +96 -0
  7. package/src/calc/calc.test.ts +287 -0
  8. package/src/calc/calc.ts +120 -0
  9. package/src/calc/calcFamily.test.ts +206 -0
  10. package/src/calc/calcFamily.ts +194 -0
  11. package/src/calc/compose.test.ts +68 -0
  12. package/src/calc/compose.ts +69 -0
  13. package/src/channel/channel.ts +269 -0
  14. package/src/compose/composition.ts +146 -0
  15. package/src/compose/host.ts +12 -0
  16. package/{dist/compose/props.js → src/compose/props.ts} +7 -5
  17. package/src/compose/provide.ts +70 -0
  18. package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  19. package/src/compose/structure.test.ts +90 -0
  20. package/src/compose/structure.ts +145 -0
  21. package/src/compose/ui.test.ts +34 -0
  22. package/src/compose/ui.ts +204 -0
  23. package/src/compose/ui.typecheck.ts +56 -0
  24. package/src/definition/definition.ts +76 -0
  25. package/src/event/event.test.ts +23 -0
  26. package/src/event/event.ts +65 -0
  27. package/src/event/eventGroup.ts +14 -0
  28. package/src/feature/feature.mount.test.ts +82 -0
  29. package/src/feature/feature.test.ts +60 -0
  30. package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  31. package/src/feature/feature.typecheck.ts +108 -0
  32. package/src/index.ts +191 -0
  33. package/src/internal/capture.ts +38 -0
  34. package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  35. package/src/internal/errors.test.ts +33 -0
  36. package/src/internal/errors.ts +133 -0
  37. package/src/internal/inspect.test.ts +28 -0
  38. package/src/internal/inspect.ts +34 -0
  39. package/src/internal/queryDriver.ts +247 -0
  40. package/src/internal/reuse.test.ts +116 -0
  41. package/src/internal/reuse.ts +73 -0
  42. package/src/internal/scheduler.ts +93 -0
  43. package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  44. package/src/internal/sources.ts +104 -0
  45. package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
  46. package/src/internal/store.test.ts +80 -0
  47. package/src/internal/store.ts +105 -0
  48. package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
  49. package/src/procedure/procedure.ts +89 -0
  50. package/src/reducer/reducer.ts +137 -0
  51. package/src/remote/remoteState.test.ts +695 -0
  52. package/src/remote/remoteState.ts +571 -0
  53. package/src/remote/remoteState.typecheck.ts +195 -0
  54. package/src/runtime/bus.ts +40 -0
  55. package/src/runtime/hardening.test.ts +69 -0
  56. package/src/runtime/loop.test.ts +178 -0
  57. package/src/runtime/loop.ts +171 -0
  58. package/src/scene/scene.ts +88 -0
  59. package/src/scene/seedScene.test.ts +176 -0
  60. package/src/state/state.ts +94 -0
  61. package/src/state/stateFamily.test.ts +138 -0
  62. package/src/state/stateFamily.ts +174 -0
  63. package/src/state/stateGroup.ts +125 -0
  64. package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
  65. package/src/synced/syncedStore.ts +99 -0
  66. package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
  67. package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  68. package/src/wire/tree.test.ts +81 -0
  69. package/src/wire/tree.ts +129 -0
  70. package/src/wire/triggers.test.ts +76 -0
  71. package/src/wire/triggers.ts +98 -0
  72. package/dist/boundary/boundary.d.ts +0 -72
  73. package/dist/boundary/boundary.d.ts.map +0 -1
  74. package/dist/boundary/boundary.js +0 -83
  75. package/dist/boundary/boundary.js.map +0 -1
  76. package/dist/calc/asyncCalc.d.ts +0 -91
  77. package/dist/calc/asyncCalc.d.ts.map +0 -1
  78. package/dist/calc/asyncCalc.js +0 -95
  79. package/dist/calc/asyncCalc.js.map +0 -1
  80. package/dist/calc/asyncData.d.ts +0 -57
  81. package/dist/calc/asyncData.d.ts.map +0 -1
  82. package/dist/calc/asyncData.js +0 -34
  83. package/dist/calc/asyncData.js.map +0 -1
  84. package/dist/calc/calc.d.ts +0 -57
  85. package/dist/calc/calc.d.ts.map +0 -1
  86. package/dist/calc/calc.js +0 -58
  87. package/dist/calc/calc.js.map +0 -1
  88. package/dist/calc/calcFamily.d.ts +0 -57
  89. package/dist/calc/calcFamily.d.ts.map +0 -1
  90. package/dist/calc/calcFamily.js +0 -124
  91. package/dist/calc/calcFamily.js.map +0 -1
  92. package/dist/calc/compose.d.ts +0 -16
  93. package/dist/calc/compose.d.ts.map +0 -1
  94. package/dist/calc/compose.js +0 -7
  95. package/dist/calc/compose.js.map +0 -1
  96. package/dist/channel/channel.d.ts +0 -115
  97. package/dist/channel/channel.d.ts.map +0 -1
  98. package/dist/channel/channel.js +0 -138
  99. package/dist/channel/channel.js.map +0 -1
  100. package/dist/compose/composition.d.ts +0 -72
  101. package/dist/compose/composition.d.ts.map +0 -1
  102. package/dist/compose/composition.js +0 -46
  103. package/dist/compose/composition.js.map +0 -1
  104. package/dist/compose/host.d.ts +0 -17
  105. package/dist/compose/host.d.ts.map +0 -1
  106. package/dist/compose/host.js +0 -6
  107. package/dist/compose/host.js.map +0 -1
  108. package/dist/compose/props.d.ts +0 -13
  109. package/dist/compose/props.d.ts.map +0 -1
  110. package/dist/compose/props.js.map +0 -1
  111. package/dist/compose/provide.d.ts +0 -22
  112. package/dist/compose/provide.d.ts.map +0 -1
  113. package/dist/compose/provide.js +0 -28
  114. package/dist/compose/provide.js.map +0 -1
  115. package/dist/compose/slot.d.ts.map +0 -1
  116. package/dist/compose/slot.js +0 -23
  117. package/dist/compose/slot.js.map +0 -1
  118. package/dist/compose/ui.d.ts +0 -135
  119. package/dist/compose/ui.d.ts.map +0 -1
  120. package/dist/compose/ui.js +0 -63
  121. package/dist/compose/ui.js.map +0 -1
  122. package/dist/definition/definition.d.ts +0 -33
  123. package/dist/definition/definition.d.ts.map +0 -1
  124. package/dist/definition/definition.js +0 -42
  125. package/dist/definition/definition.js.map +0 -1
  126. package/dist/event/event.d.ts +0 -33
  127. package/dist/event/event.d.ts.map +0 -1
  128. package/dist/event/event.js +0 -30
  129. package/dist/event/event.js.map +0 -1
  130. package/dist/event/eventGroup.d.ts +0 -9
  131. package/dist/event/eventGroup.d.ts.map +0 -1
  132. package/dist/event/eventGroup.js +0 -4
  133. package/dist/event/eventGroup.js.map +0 -1
  134. package/dist/feature/feature.d.ts.map +0 -1
  135. package/dist/feature/feature.js +0 -98
  136. package/dist/feature/feature.js.map +0 -1
  137. package/dist/index.d.ts +0 -53
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js +0 -62
  140. package/dist/index.js.map +0 -1
  141. package/dist/internal/capture.d.ts +0 -28
  142. package/dist/internal/capture.d.ts.map +0 -1
  143. package/dist/internal/capture.js +0 -12
  144. package/dist/internal/capture.js.map +0 -1
  145. package/dist/internal/ctx.d.ts.map +0 -1
  146. package/dist/internal/ctx.js +0 -2
  147. package/dist/internal/ctx.js.map +0 -1
  148. package/dist/internal/errors.d.ts +0 -89
  149. package/dist/internal/errors.d.ts.map +0 -1
  150. package/dist/internal/errors.js +0 -102
  151. package/dist/internal/errors.js.map +0 -1
  152. package/dist/internal/inspect.d.ts +0 -17
  153. package/dist/internal/inspect.d.ts.map +0 -1
  154. package/dist/internal/inspect.js +0 -32
  155. package/dist/internal/inspect.js.map +0 -1
  156. package/dist/internal/queryDriver.d.ts +0 -65
  157. package/dist/internal/queryDriver.d.ts.map +0 -1
  158. package/dist/internal/queryDriver.js +0 -134
  159. package/dist/internal/queryDriver.js.map +0 -1
  160. package/dist/internal/reuse.d.ts +0 -10
  161. package/dist/internal/reuse.d.ts.map +0 -1
  162. package/dist/internal/reuse.js +0 -68
  163. package/dist/internal/reuse.js.map +0 -1
  164. package/dist/internal/scheduler.d.ts +0 -47
  165. package/dist/internal/scheduler.d.ts.map +0 -1
  166. package/dist/internal/scheduler.js +0 -70
  167. package/dist/internal/scheduler.js.map +0 -1
  168. package/dist/internal/seeds.d.ts.map +0 -1
  169. package/dist/internal/seeds.js +0 -17
  170. package/dist/internal/seeds.js.map +0 -1
  171. package/dist/internal/sources.d.ts +0 -39
  172. package/dist/internal/sources.d.ts.map +0 -1
  173. package/dist/internal/sources.js +0 -59
  174. package/dist/internal/sources.js.map +0 -1
  175. package/dist/internal/stateRegistry.d.ts +0 -2
  176. package/dist/internal/stateRegistry.d.ts.map +0 -1
  177. package/dist/internal/stateRegistry.js.map +0 -1
  178. package/dist/internal/store.d.ts +0 -47
  179. package/dist/internal/store.d.ts.map +0 -1
  180. package/dist/internal/store.js +0 -73
  181. package/dist/internal/store.js.map +0 -1
  182. package/dist/internal/track.d.ts.map +0 -1
  183. package/dist/internal/track.js +0 -19
  184. package/dist/internal/track.js.map +0 -1
  185. package/dist/procedure/procedure.d.ts +0 -40
  186. package/dist/procedure/procedure.d.ts.map +0 -1
  187. package/dist/procedure/procedure.js +0 -50
  188. package/dist/procedure/procedure.js.map +0 -1
  189. package/dist/reducer/reducer.d.ts +0 -44
  190. package/dist/reducer/reducer.d.ts.map +0 -1
  191. package/dist/reducer/reducer.js +0 -63
  192. package/dist/reducer/reducer.js.map +0 -1
  193. package/dist/remote/remoteState.d.ts +0 -119
  194. package/dist/remote/remoteState.d.ts.map +0 -1
  195. package/dist/remote/remoteState.js +0 -270
  196. package/dist/remote/remoteState.js.map +0 -1
  197. package/dist/runtime/bus.d.ts +0 -27
  198. package/dist/runtime/bus.d.ts.map +0 -1
  199. package/dist/runtime/bus.js +0 -21
  200. package/dist/runtime/bus.js.map +0 -1
  201. package/dist/runtime/loop.d.ts +0 -45
  202. package/dist/runtime/loop.d.ts.map +0 -1
  203. package/dist/runtime/loop.js +0 -117
  204. package/dist/runtime/loop.js.map +0 -1
  205. package/dist/scene/scene.d.ts +0 -44
  206. package/dist/scene/scene.d.ts.map +0 -1
  207. package/dist/scene/scene.js +0 -31
  208. package/dist/scene/scene.js.map +0 -1
  209. package/dist/state/state.d.ts +0 -37
  210. package/dist/state/state.d.ts.map +0 -1
  211. package/dist/state/state.js +0 -46
  212. package/dist/state/state.js.map +0 -1
  213. package/dist/state/stateFamily.d.ts +0 -79
  214. package/dist/state/stateFamily.d.ts.map +0 -1
  215. package/dist/state/stateFamily.js +0 -99
  216. package/dist/state/stateFamily.js.map +0 -1
  217. package/dist/state/stateGroup.d.ts +0 -50
  218. package/dist/state/stateGroup.d.ts.map +0 -1
  219. package/dist/state/stateGroup.js +0 -54
  220. package/dist/state/stateGroup.js.map +0 -1
  221. package/dist/state/token.d.ts.map +0 -1
  222. package/dist/state/token.js +0 -20
  223. package/dist/state/token.js.map +0 -1
  224. package/dist/synced/syncedStore.d.ts +0 -51
  225. package/dist/synced/syncedStore.d.ts.map +0 -1
  226. package/dist/synced/syncedStore.js +0 -43
  227. package/dist/synced/syncedStore.js.map +0 -1
  228. package/dist/ui/node.d.ts.map +0 -1
  229. package/dist/ui/node.js +0 -2
  230. package/dist/ui/node.js.map +0 -1
  231. package/dist/ui/trigger.d.ts.map +0 -1
  232. package/dist/ui/trigger.js +0 -2
  233. package/dist/ui/trigger.js.map +0 -1
  234. package/dist/wire/tree.d.ts +0 -66
  235. package/dist/wire/tree.d.ts.map +0 -1
  236. package/dist/wire/tree.js +0 -60
  237. package/dist/wire/tree.js.map +0 -1
  238. package/dist/wire/triggers.d.ts +0 -44
  239. package/dist/wire/triggers.d.ts.map +0 -1
  240. package/dist/wire/triggers.js +0 -31
  241. package/dist/wire/triggers.js.map +0 -1
@@ -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)
@@ -0,0 +1,34 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Schema as S } from 'effect'
3
+ import { isUi, ui } from '../index'
4
+
5
+ // `ui` has two authoring forms with one source of truth each: the type-only form
6
+ // (local, no schema) and the schema form (wired, the type is *derived*). Both are
7
+ // the same contract at runtime; only the wired form carries reflectable schemas.
8
+
9
+ test('type-only `ui` carries no wire schema', () => {
10
+ class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
11
+ expect(CounterUi.manifest.kind).toBe('Ui')
12
+ expect(CounterUi.manifest.name).toBe('Counter')
13
+ expect(CounterUi.manifest.props).toBeUndefined()
14
+ expect(CounterUi.manifest.events).toBeUndefined()
15
+ expect(isUi(CounterUi)).toBe(true)
16
+ })
17
+
18
+ test('schema `ui` carries props and per-event wire schemas', () => {
19
+ class CounterUi extends ui('Counter', {
20
+ props: S.Struct({ count: S.Number }),
21
+ events: { bump: S.Struct({ by: S.Number }) },
22
+ }) {}
23
+ expect(CounterUi.manifest.kind).toBe('Ui')
24
+ expect(CounterUi.manifest.name).toBe('Counter')
25
+ expect(S.isSchema(CounterUi.manifest.props)).toBe(true)
26
+ expect(S.isSchema(CounterUi.manifest.events?.['bump'])).toBe(true)
27
+ expect(isUi(CounterUi)).toBe(true)
28
+ })
29
+
30
+ test('schema `ui` with no events omits the events schema', () => {
31
+ class TitleUi extends ui('Title', { props: S.Struct({ text: S.String }) }) {}
32
+ expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
33
+ expect(TitleUi.manifest.events).toBeUndefined()
34
+ })
@@ -0,0 +1,204 @@
1
+ import type { FunctionComponent } from 'react'
2
+ import { Context, type Schema } from 'effect'
3
+ import { type Manifest, definitionClass } from '../definition/definition'
4
+ import type { Trigger } from '../ui/trigger'
5
+ import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './slot'
6
+
7
+ export interface UiContract {
8
+ props: unknown
9
+ slots?: Record<string, SlotInstance<any>>
10
+ events?: Record<string, Trigger<any>>
11
+ }
12
+
13
+ type PropsOf<C extends UiContract> = C['props']
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
+ // A slot is a React COMPONENT whose props are OPTIONAL (a `Partial`): a parent that
19
+ // drives a child supplies them (`<slots.Item id=… />` / `createElement(slots.Item, {…})`),
20
+ // but a placeholder render (the remote client, where the per-child props were captured
21
+ // server-side and the framework expands the children) omits them (`<slots.Item />`).
22
+ // One `Ui.make` view thus serves both renderers. The child still reads its own
23
+ // props/state — these are only the external props the parent feeds, advisory at the
24
+ // slot boundary. Typing it as `FunctionComponent` makes both JSX and `createElement` work.
25
+ //
26
+ // `slotKey` is the universal KEYED-SLOT selector (see `WireNode.key`): for a LIST slot
27
+ // invoked once per item (`<slots.Row slotKey={id} />`), the remote client renders only the
28
+ // one wire child whose React `key` matches, instead of every child of that slot at every
29
+ // call site (which duplicates the whole list under each call). It is framework-handled, not
30
+ // forwarded to the child, so it is allowed on EVERY slot and never collides with a child's
31
+ // own props. Omit it for singleton slots (`<slots.Create />`) — they render all children.
32
+ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
33
+ ? {
34
+ // `slotKey` goes INSIDE the `Partial<…>` (not intersected outside) so that when a slot's
35
+ // child props are `any` the whole thing still collapses to `any` (`any & X = any`), keeping
36
+ // the component assignable to a bare `(props: unknown) => Node` slot-renderer shape. An
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.
44
+ readonly [K in keyof S]: FunctionComponent<
45
+ Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
46
+ > & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
47
+ }
48
+ : Record<never, never>
49
+
50
+ /** What `.make` authors: the pure presentation. Slots are injected by the renderer. */
51
+ export type ViewImpl<C extends UiContract> = (
52
+ props: PropsOf<C>,
53
+ slots: SlotsOf<C>,
54
+ events: EventsOf<C>,
55
+ ) => Node
56
+
57
+ /** Nominal brand identifying a UI contract at runtime (Effect's `TypeId` idiom). */
58
+ export const UiTypeId: unique symbol = Symbol.for('reform/Ui')
59
+ export type UiTypeId = typeof UiTypeId
60
+
61
+ /**
62
+ * The reflectable descriptor a contract carries. The wire `props` / `events`
63
+ * schemas are present *only* on the schema (wired) form — the type-only form
64
+ * carries neither, so a local contract has no schema cost and no schema surface.
65
+ */
66
+ export interface UiManifest extends Manifest {
67
+ readonly kind: 'Ui'
68
+ // `any` in the encoded/context slots is required by Schema's variance for
69
+ // branded/Class schemas; only the decoded type is ever read off these.
70
+ readonly props?: Schema.Schema<any, any>
71
+ readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
72
+ }
73
+
74
+ /** A `UiManifest` whose wire schema is present — the runtime brand of a remote-capable contract. */
75
+ export interface WiredUiManifest extends UiManifest {
76
+ readonly props: Schema.Schema<any, any>
77
+ }
78
+
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> {
82
+ new (): {}
83
+ readonly [UiTypeId]: UiTypeId
84
+ readonly manifest: UiManifest
85
+ /** Internal DI tag the `Ui.make` presentation is provided under. */
86
+ readonly impl: Context.Tag<ViewImpl<C>, ViewImpl<C>>
87
+ }
88
+
89
+ /**
90
+ * Recover a contract's `UiContract` type from its `Ui` class — `Ui.Contract<typeof
91
+ * SomeUi>`. Lets a consumer (a proof, a reflection tool) name the contract derived
92
+ * from a wired `ui('X', {…})` without re-declaring it.
93
+ */
94
+ export type Contract<U> = U extends UiClass<infer C> ? C : never
95
+
96
+ /**
97
+ * A contract authored with wire schemas. Structurally a `UiClass<C>` whose
98
+ * manifest *carries* the schemas (`WiredUiManifest`), so it is the subtype the
99
+ * remote seam (`provideRemote`, the wire registry) demands while plain `provide`
100
+ * / `scene` / `Composition.make` keep accepting any `UiClass<C>`.
101
+ */
102
+ export type WiredUi<C extends UiContract> = UiClass<C> & { readonly manifest: WiredUiManifest }
103
+
104
+ /** The wire schemas a contract is authored from: props, a payload schema per event, and
105
+ * (type-only) the slots the view fills. Slots carry no wire schema — the children that
106
+ * fill them are their own contracts — but declaring them here threads typed slots into
107
+ * the derived contract, so a WIRED contract can also have slots (e.g. a parent whose
108
+ * props the client reads AND that renders slot children). */
109
+ export interface WireSchemas {
110
+ readonly props: Schema.Schema<any, any>
111
+ readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
112
+ // Slots are declared with their slot CLASSES (`slot('X')<typeof Child>()`), the
113
+ // same values `Composition.make({ slots })` takes — NOT `SlotInstance`s. The
114
+ // contract's slot facade is derived from them below.
115
+ readonly slots?: Readonly<Record<string, SlotClass>>
116
+ }
117
+
118
+ type DerivedEvents<Sch extends WireSchemas> = Sch extends {
119
+ readonly events: infer E extends Record<string, Schema.Schema<any, any>>
120
+ }
121
+ ? { readonly [K in keyof E]: Trigger<Schema.Schema.Type<E[K]>> }
122
+ : Record<never, never>
123
+
124
+ type DerivedSlots<Sch extends WireSchemas> = Sch extends {
125
+ readonly slots: infer S extends Record<string, SlotClass>
126
+ }
127
+ ? { readonly [K in keyof S]: InstanceType<S[K]> }
128
+ : Record<never, never>
129
+
130
+ /**
131
+ * The `UiContract` a set of wire schemas denotes — props and event-payload
132
+ * *types* derived from the schemas via `Schema.Type`, plus any declared slots. The
133
+ * schema is the single source of truth; the contract type is inferred, never written
134
+ * a second time.
135
+ */
136
+ export type DerivedContract<Sch extends WireSchemas> = {
137
+ readonly props: Schema.Schema.Type<Sch['props']>
138
+ readonly slots: DerivedSlots<Sch>
139
+ readonly events: DerivedEvents<Sch>
140
+ }
141
+
142
+ /** Whether a value is a UI contract — discriminates a `provide` target by brand. */
143
+ export const isUi = (u: unknown): u is UiClass<UiContract> =>
144
+ (typeof u === 'function' || typeof u === 'object') && u !== null && UiTypeId in u
145
+
146
+ const buildUi = <C extends UiContract, M extends UiManifest>(
147
+ name: string,
148
+ manifest: M,
149
+ ): UiClass<C> & { readonly manifest: M } => {
150
+ const impl = Context.GenericTag<ViewImpl<C>, ViewImpl<C>>(`reform/ui/${name}`)
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 }>({
154
+ [UiTypeId]: UiTypeId,
155
+ manifest,
156
+ impl,
157
+ })
158
+ }
159
+
160
+ /**
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.
163
+ *
164
+ * Two authoring forms, one source of truth each:
165
+ *
166
+ * - **Type-only (local):** `ui('Counter')<{ props: { count: number } }>()` — no
167
+ * schema, no ceremony, exactly as before.
168
+ * - **Schema (wired):** `ui('Counter', { props: S.Struct({ count: S.Number }) })`
169
+ * — the schema *is* the definition; the contract type is derived via
170
+ * `Schema.Type` (never re-written), and the result is a `WiredUi` the remote
171
+ * seam accepts. Make a local contract remote by rewriting this one line.
172
+ */
173
+ export function ui(name: string): <C extends UiContract>() => UiClass<C>
174
+ export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
175
+ export function ui(name: string, schemas?: WireSchemas): unknown {
176
+ if (schemas === undefined) {
177
+ return <C extends UiContract>(): UiClass<C> => buildUi<C, UiManifest>(name, { kind: 'Ui', name })
178
+ }
179
+ const eventFields = schemas.events === undefined ? {} : { events: schemas.events }
180
+ const manifest: WiredUiManifest = { kind: 'Ui', name, props: schemas.props, ...eventFields }
181
+ return buildUi<DerivedContract<WireSchemas>, WiredUiManifest>(name, manifest)
182
+ }
183
+
184
+ /** Brand under which a `Ui.make` view carries its own contract. */
185
+ export const UiViewContract: unique symbol = Symbol.for('reform/ui/view-contract')
186
+ export type UiViewContract = typeof UiViewContract
187
+
188
+ /**
189
+ * A `Ui.make` view that carries its contract, so a consumer can recover the
190
+ * contract (its wire name + props schema) straight from the view — no separate
191
+ * registration call. This is what makes the remote client view-set
192
+ * (`remoteViews<AppContract>({ Some: SomeView, … })`) work with the very same
193
+ * `Ui.make` views the local renderer uses.
194
+ */
195
+ export type MadeView<C extends UiContract> = ViewImpl<C> & { readonly [UiViewContract]: UiClass<C> }
196
+
197
+ /**
198
+ * Author the pure presentation for a contract — `Ui.make(TodoAppUi, (props,
199
+ * slots, events) => node)`. The contract argument fixes the view's types AND is
200
+ * carried on the returned view (`MadeView`), so the same view is reusable as a
201
+ * remote client presentation. Wire the result with `provide(contract, view)`.
202
+ */
203
+ export const make = <C extends UiContract>(contract: UiClass<C>, view: ViewImpl<C>): MadeView<C> =>
204
+ Object.assign(view, { [UiViewContract]: contract })
@@ -0,0 +1,56 @@
1
+ // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
+ // These guard the schema-first seam: the wired form is the single source of
3
+ // truth (props/event types derived, never re-written), a wired contract is a
4
+ // plain `UiClass` (so `provide` / `scene` / `Composition.make` accept it), and
5
+ // the remote seam (`WiredUi`) rejects a type-only contract. If a guarantee
6
+ // regresses, a `@ts-expect-error` goes unused or an assignment fails, and `tsc`
7
+ // breaks.
8
+
9
+ import { Schema as S } from 'effect'
10
+ import {
11
+ type Contract,
12
+ type DerivedContract,
13
+ type MadeView,
14
+ make,
15
+ ui,
16
+ type UiClass,
17
+ type UiContract,
18
+ type WiredUi,
19
+ } from './ui'
20
+
21
+ declare function acceptsAny<C extends UiContract>(contract: UiClass<C>): void
22
+ declare function acceptsWired<C extends UiContract>(contract: WiredUi<C>): void
23
+
24
+ class LocalUi extends ui('Local')<{ props: { n: number } }>() {}
25
+ const wiredSchemas: {
26
+ readonly props: S.Struct<{ n: typeof S.Number }>
27
+ readonly events: { readonly bump: S.Struct<{ by: typeof S.Number }> }
28
+ } = {
29
+ props: S.Struct({ n: S.Number }),
30
+ events: { bump: S.Struct({ by: S.Number }) },
31
+ }
32
+ const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas>> = ui('Wired', wiredSchemas)
33
+ class WiredUiClass extends WiredUiClassBase {}
34
+
35
+ // POSITIVE: both forms are a `UiClass` — plain wiring (provide/scene/Composition) accepts either.
36
+ acceptsAny(LocalUi)
37
+ acceptsAny(WiredUiClass)
38
+
39
+ // POSITIVE: the schema form is a `WiredUi` — the remote seam accepts it.
40
+ acceptsWired(WiredUiClass)
41
+
42
+ // NEGATIVE: a type-only contract has no wire schema, so the remote seam rejects it.
43
+ // @ts-expect-error -- LocalUi is `UiClass`, not `WiredUi`
44
+ acceptsWired(LocalUi)
45
+
46
+ // POSITIVE: props/event types are DERIVED from the schemas — authoring a presentation
47
+ // against the wired contract sees `n: number` and `bump: Trigger<{ by: number }>`,
48
+ // none of it written a second time.
49
+ export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
50
+ WiredUiClass,
51
+ ({ n }, _slots, { bump }) => {
52
+ const _n: number = n
53
+ bump({ by: _n })
54
+ return `${_n}`
55
+ },
56
+ )
@@ -0,0 +1,76 @@
1
+ import { Effect } from 'effect'
2
+ import { attachInspectable } from '../internal/inspect'
3
+
4
+ /** A definition's `toJSON`: its reflectable manifest, else its raw statics. */
5
+ const describe = (statics: object): unknown => (statics as { manifest?: unknown }).manifest ?? statics
6
+
7
+ /**
8
+ * The reflectable descriptor every primitive carries (principle #6). The editor
9
+ * and proof packages read these without running any logic.
10
+ */
11
+ export type Kind =
12
+ | 'State'
13
+ | 'StateGroup'
14
+ | 'StateFamily'
15
+ | 'Event'
16
+ | 'EventGroup'
17
+ | 'Reducer'
18
+ | 'Calc'
19
+ | 'CalcFamily'
20
+ | 'AsyncCalc'
21
+ | 'Resource'
22
+ | 'RemoteState'
23
+ | 'SyncedStore'
24
+ | 'Boundary'
25
+ | 'Procedure'
26
+ | 'Channel'
27
+ | 'Composition'
28
+ | 'Ui'
29
+ | 'Slot'
30
+ | 'Feature'
31
+
32
+ export interface Manifest {
33
+ readonly kind: Kind
34
+ readonly name: string
35
+ }
36
+
37
+ /**
38
+ * Build a class whose *static* side is itself an Effect, so `yield* TheClass`
39
+ * runs `read` and returns its value (the same mechanism `Context.Tag` uses).
40
+ * Subclasses inherit the protocol through the prototype chain, so the user's
41
+ * `class FeedState extends State.make(...) {}` is yieldable too.
42
+ *
43
+ * `statics` (manifest, `.live`, tag, …) are attached as own properties
44
+ * and likewise inherited by the subclass.
45
+ */
46
+ export const yieldableClass = <A, E, R, Statics extends object>(
47
+ read: Effect.Effect<A, E, R>,
48
+ statics: Statics,
49
+ ): { new (): {} } & Effect.Effect<A, E, R> & Statics => {
50
+ class Base {}
51
+ Object.setPrototypeOf(Base, read)
52
+ // `defineProperty` over `Reflect.ownKeys`, not `Object.assign` over
53
+ // `Object.entries`: a static can shadow a non-writable own property of the
54
+ // class function (notably `name`, which a `Source`-shaped definition like a
55
+ // Calc carries) and `assign` would throw on those; and `ownKeys` (unlike
56
+ // `entries`) carries symbol-keyed statics — the `TypeId` brands.
57
+ for (const key of Reflect.ownKeys(statics)) {
58
+ const value = (statics as Record<PropertyKey, unknown>)[key]
59
+ Object.defineProperty(Base, key, { value, writable: true, enumerable: true, configurable: true })
60
+ }
61
+ // Print like an Effect value (the manifest), not `[object Object]`.
62
+ attachInspectable(Base, () => describe(statics))
63
+ return Base as never
64
+ }
65
+
66
+ /**
67
+ * Build a *definition class*: an empty constructible carrying static marker data
68
+ * (manifest, tag, policy, members, …) that the editor and proof packages reflect
69
+ * on. Definition interfaces declare phantom, type-only fields — a composition's
70
+ * `Props`, a family instance's `Key` — that exist purely for inference and have
71
+ * no runtime value, so the assembled object cannot *structurally* satisfy the
72
+ * interface. The one assertion to `Class` lives here, documented, instead of a
73
+ * scattered `as never` / `as unknown as` at every `*.make`.
74
+ */
75
+ export const definitionClass = <Class>(statics: object): Class =>
76
+ attachInspectable(Object.assign(class {}, statics), () => describe(statics)) as unknown as Class
@@ -0,0 +1,23 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Schema as S } from 'effect'
3
+ import { Event } from '../index'
4
+
5
+ // An event is a pure data fact: `Event.construct` builds the tagged value the
6
+ // bus carries, and the class carries the reflectable `manifest` + `tag`.
7
+
8
+ test('Event.construct builds the tagged value `{ _tag, ...payload }`', () => {
9
+ class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
10
+ expect(Event.construct(Added, { id: 'x' })).toEqual({ _tag: 'Added', id: 'x' })
11
+ })
12
+
13
+ test('an event carries its reflectable manifest and tag', () => {
14
+ class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
15
+ expect(Added.tag).toBe('Added')
16
+ expect(Added.manifest.kind).toBe('Event')
17
+ expect(Added.manifest.name).toBe('Added')
18
+ })
19
+
20
+ test('empty-payload events construct to just their tag', () => {
21
+ class Pinged extends Event.make('Pinged', S.Struct({})) {}
22
+ expect(Event.construct(Pinged, {})).toEqual({ _tag: 'Pinged' })
23
+ })