@lumencast/runtime 0.1.0

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 (204) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +79 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/animate/crossfade.d.ts +13 -0
  5. package/dist/animate/crossfade.d.ts.map +1 -0
  6. package/dist/animate/crossfade.js +10 -0
  7. package/dist/animate/crossfade.js.map +1 -0
  8. package/dist/animate/keyframes.d.ts +42 -0
  9. package/dist/animate/keyframes.d.ts.map +1 -0
  10. package/dist/animate/keyframes.js +94 -0
  11. package/dist/animate/keyframes.js.map +1 -0
  12. package/dist/animate/transitions.d.ts +38 -0
  13. package/dist/animate/transitions.d.ts.map +1 -0
  14. package/dist/animate/transitions.js +81 -0
  15. package/dist/animate/transitions.js.map +1 -0
  16. package/dist/app.d.ts +16 -0
  17. package/dist/app.d.ts.map +1 -0
  18. package/dist/app.js +35 -0
  19. package/dist/app.js.map +1 -0
  20. package/dist/broadcast-BqOhSNsY.js +11 -0
  21. package/dist/broadcast-BqOhSNsY.js.map +1 -0
  22. package/dist/control-CRFn328D.js +16 -0
  23. package/dist/control-CRFn328D.js.map +1 -0
  24. package/dist/dev-entry.d.ts +2 -0
  25. package/dist/dev-entry.d.ts.map +1 -0
  26. package/dist/dev-entry.js +31 -0
  27. package/dist/dev-entry.js.map +1 -0
  28. package/dist/index-DUhPPRvw.js +583 -0
  29. package/dist/index-DUhPPRvw.js.map +1 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.html +46 -0
  33. package/dist/index.js +3 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/validate-options.d.ts +5 -0
  36. package/dist/internal/validate-options.d.ts.map +1 -0
  37. package/dist/internal/validate-options.js +19 -0
  38. package/dist/internal/validate-options.js.map +1 -0
  39. package/dist/lumencast.js +5 -0
  40. package/dist/lumencast.js.map +1 -0
  41. package/dist/modes/broadcast.d.ts +3 -0
  42. package/dist/modes/broadcast.d.ts.map +1 -0
  43. package/dist/modes/broadcast.js +9 -0
  44. package/dist/modes/broadcast.js.map +1 -0
  45. package/dist/modes/control.d.ts +4 -0
  46. package/dist/modes/control.d.ts.map +1 -0
  47. package/dist/modes/control.js +12 -0
  48. package/dist/modes/control.js.map +1 -0
  49. package/dist/modes/test.d.ts +4 -0
  50. package/dist/modes/test.d.ts.map +1 -0
  51. package/dist/modes/test.js +13 -0
  52. package/dist/modes/test.js.map +1 -0
  53. package/dist/mount.d.ts +3 -0
  54. package/dist/mount.d.ts.map +1 -0
  55. package/dist/mount.js +144 -0
  56. package/dist/mount.js.map +1 -0
  57. package/dist/overlay/control.d.ts +2 -0
  58. package/dist/overlay/control.d.ts.map +1 -0
  59. package/dist/overlay/control.js +127 -0
  60. package/dist/overlay/control.js.map +1 -0
  61. package/dist/overlay/runtime-context.d.ts +20 -0
  62. package/dist/overlay/runtime-context.d.ts.map +1 -0
  63. package/dist/overlay/runtime-context.js +14 -0
  64. package/dist/overlay/runtime-context.js.map +1 -0
  65. package/dist/overlay/status-pill.d.ts +2 -0
  66. package/dist/overlay/status-pill.d.ts.map +1 -0
  67. package/dist/overlay/status-pill.js +29 -0
  68. package/dist/overlay/status-pill.js.map +1 -0
  69. package/dist/overlay/test.d.ts +5 -0
  70. package/dist/overlay/test.d.ts.map +1 -0
  71. package/dist/overlay/test.js +116 -0
  72. package/dist/overlay/test.js.map +1 -0
  73. package/dist/render/bundle.d.ts +102 -0
  74. package/dist/render/bundle.d.ts.map +1 -0
  75. package/dist/render/bundle.js +86 -0
  76. package/dist/render/bundle.js.map +1 -0
  77. package/dist/render/fill.d.ts +41 -0
  78. package/dist/render/fill.d.ts.map +1 -0
  79. package/dist/render/fill.js +95 -0
  80. package/dist/render/fill.js.map +1 -0
  81. package/dist/render/keyframe-player.d.ts +10 -0
  82. package/dist/render/keyframe-player.d.ts.map +1 -0
  83. package/dist/render/keyframe-player.js +65 -0
  84. package/dist/render/keyframe-player.js.map +1 -0
  85. package/dist/render/primitives/frame.d.ts +12 -0
  86. package/dist/render/primitives/frame.d.ts.map +1 -0
  87. package/dist/render/primitives/frame.js +65 -0
  88. package/dist/render/primitives/frame.js.map +1 -0
  89. package/dist/render/primitives/grid.d.ts +4 -0
  90. package/dist/render/primitives/grid.d.ts.map +1 -0
  91. package/dist/render/primitives/grid.js +14 -0
  92. package/dist/render/primitives/grid.js.map +1 -0
  93. package/dist/render/primitives/image.d.ts +5 -0
  94. package/dist/render/primitives/image.d.ts.map +1 -0
  95. package/dist/render/primitives/image.js +25 -0
  96. package/dist/render/primitives/image.js.map +1 -0
  97. package/dist/render/primitives/index.d.ts +10 -0
  98. package/dist/render/primitives/index.d.ts.map +1 -0
  99. package/dist/render/primitives/index.js +22 -0
  100. package/dist/render/primitives/index.js.map +1 -0
  101. package/dist/render/primitives/instance.d.ts +4 -0
  102. package/dist/render/primitives/instance.d.ts.map +1 -0
  103. package/dist/render/primitives/instance.js +35 -0
  104. package/dist/render/primitives/instance.js.map +1 -0
  105. package/dist/render/primitives/media.d.ts +6 -0
  106. package/dist/render/primitives/media.d.ts.map +1 -0
  107. package/dist/render/primitives/media.js +19 -0
  108. package/dist/render/primitives/media.js.map +1 -0
  109. package/dist/render/primitives/shape.d.ts +12 -0
  110. package/dist/render/primitives/shape.d.ts.map +1 -0
  111. package/dist/render/primitives/shape.js +66 -0
  112. package/dist/render/primitives/shape.js.map +1 -0
  113. package/dist/render/primitives/stack.d.ts +13 -0
  114. package/dist/render/primitives/stack.d.ts.map +1 -0
  115. package/dist/render/primitives/stack.js +45 -0
  116. package/dist/render/primitives/stack.js.map +1 -0
  117. package/dist/render/primitives/text.d.ts +6 -0
  118. package/dist/render/primitives/text.d.ts.map +1 -0
  119. package/dist/render/primitives/text.js +27 -0
  120. package/dist/render/primitives/text.js.map +1 -0
  121. package/dist/render/scope.d.ts +10 -0
  122. package/dist/render/scope.d.ts.map +1 -0
  123. package/dist/render/scope.js +27 -0
  124. package/dist/render/scope.js.map +1 -0
  125. package/dist/render/stagger-context.d.ts +9 -0
  126. package/dist/render/stagger-context.d.ts.map +1 -0
  127. package/dist/render/stagger-context.js +22 -0
  128. package/dist/render/stagger-context.js.map +1 -0
  129. package/dist/render/tree.d.ts +9 -0
  130. package/dist/render/tree.d.ts.map +1 -0
  131. package/dist/render/tree.js +139 -0
  132. package/dist/render/tree.js.map +1 -0
  133. package/dist/render/universal-wrapper.d.ts +16 -0
  134. package/dist/render/universal-wrapper.d.ts.map +1 -0
  135. package/dist/render/universal-wrapper.js +58 -0
  136. package/dist/render/universal-wrapper.js.map +1 -0
  137. package/dist/state/apply-delta.d.ts +11 -0
  138. package/dist/state/apply-delta.d.ts.map +1 -0
  139. package/dist/state/apply-delta.js +23 -0
  140. package/dist/state/apply-delta.js.map +1 -0
  141. package/dist/state/apply-snapshot.d.ts +6 -0
  142. package/dist/state/apply-snapshot.d.ts.map +1 -0
  143. package/dist/state/apply-snapshot.js +6 -0
  144. package/dist/state/apply-snapshot.js.map +1 -0
  145. package/dist/state/store.d.ts +28 -0
  146. package/dist/state/store.d.ts.map +1 -0
  147. package/dist/state/store.js +119 -0
  148. package/dist/state/store.js.map +1 -0
  149. package/dist/status-pill-DCHvrd_y.js +241 -0
  150. package/dist/status-pill-DCHvrd_y.js.map +1 -0
  151. package/dist/test-DBCtwx_I.js +210 -0
  152. package/dist/test-DBCtwx_I.js.map +1 -0
  153. package/dist/transport/reconnect.d.ts +22 -0
  154. package/dist/transport/reconnect.d.ts.map +1 -0
  155. package/dist/transport/reconnect.js +60 -0
  156. package/dist/transport/reconnect.js.map +1 -0
  157. package/dist/transport/ws.d.ts +66 -0
  158. package/dist/transport/ws.d.ts.map +1 -0
  159. package/dist/transport/ws.js +270 -0
  160. package/dist/transport/ws.js.map +1 -0
  161. package/dist/tree-CnhX02kd.js +494 -0
  162. package/dist/tree-CnhX02kd.js.map +1 -0
  163. package/dist/types.d.ts +38 -0
  164. package/dist/types.d.ts.map +1 -0
  165. package/dist/types.js +3 -0
  166. package/dist/types.js.map +1 -0
  167. package/package.json +64 -0
  168. package/src/animate/crossfade.tsx +31 -0
  169. package/src/animate/keyframes.ts +142 -0
  170. package/src/animate/transitions.ts +116 -0
  171. package/src/app.tsx +84 -0
  172. package/src/dev-entry.tsx +38 -0
  173. package/src/index.ts +24 -0
  174. package/src/internal/validate-options.ts +20 -0
  175. package/src/modes/broadcast.tsx +8 -0
  176. package/src/modes/control.tsx +17 -0
  177. package/src/modes/test.tsx +19 -0
  178. package/src/mount.ts +169 -0
  179. package/src/overlay/control.tsx +239 -0
  180. package/src/overlay/runtime-context.tsx +37 -0
  181. package/src/overlay/status-pill.tsx +37 -0
  182. package/src/overlay/test.tsx +213 -0
  183. package/src/render/bundle.ts +208 -0
  184. package/src/render/fill.tsx +163 -0
  185. package/src/render/keyframe-player.tsx +89 -0
  186. package/src/render/primitives/frame.tsx +78 -0
  187. package/src/render/primitives/grid.tsx +20 -0
  188. package/src/render/primitives/image.tsx +35 -0
  189. package/src/render/primitives/index.ts +35 -0
  190. package/src/render/primitives/instance.tsx +70 -0
  191. package/src/render/primitives/media.tsx +28 -0
  192. package/src/render/primitives/shape.tsx +135 -0
  193. package/src/render/primitives/stack.tsx +48 -0
  194. package/src/render/primitives/text.tsx +38 -0
  195. package/src/render/scope.tsx +27 -0
  196. package/src/render/stagger-context.tsx +24 -0
  197. package/src/render/tree.tsx +182 -0
  198. package/src/render/universal-wrapper.tsx +95 -0
  199. package/src/state/apply-delta.ts +24 -0
  200. package/src/state/apply-snapshot.ts +8 -0
  201. package/src/state/store.ts +141 -0
  202. package/src/transport/reconnect.ts +83 -0
  203. package/src/transport/ws.ts +359 -0
  204. package/src/types.ts +54 -0
@@ -0,0 +1,70 @@
1
+ // LSML 1.1 §4.9 — `instance` primitive (composite-instance reuse).
2
+ //
3
+ // Mounts a sub-scene by `scene_id` + `scene_version`. The sub-scene's
4
+ // state is exposed to its tree under the `__params.*` reserved
5
+ // namespace ; resolution happens via the runtime's bundle fetcher.
6
+ //
7
+ // This implementation is a SCAFFOLD : the visual slot is rendered
8
+ // (size/position honoured) but the sub-tree is replaced by a
9
+ // "deferred load" placeholder until the async bundle-fetch path is
10
+ // wired. The composite-reuse rendering is the next iteration's work.
11
+ //
12
+ // What this primitive does today :
13
+ // - parse scene_id, scene_version, params, fit, size, position
14
+ // - reserve the slot in the parent layout
15
+ // - log a one-time warning so authors know it's a scaffold
16
+ //
17
+ // What it does NOT do (yet) :
18
+ // - fetch the inner bundle via the runtime's bundle resolver
19
+ // - render the inner tree with __params.* injected into the store
20
+ // - cycle detection (LSML 1.1 §4.9.2) — depth-8 limit applied at the
21
+ // resolver layer rather than the renderer
22
+
23
+ import type { ReactElement } from "react";
24
+ import type { PrimitiveProps } from "./index";
25
+
26
+ const warned = new Set<string>();
27
+
28
+ export function Instance({ resolved }: PrimitiveProps): ReactElement | null {
29
+ const sceneId = resolved.scene_id as string | undefined;
30
+ const sceneVersion = resolved.scene_version as string | undefined;
31
+ if (!sceneId || !sceneVersion) {
32
+ if (import.meta.env.DEV) {
33
+ console.warn("[lumencast/instance] missing scene_id or scene_version", resolved);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ // One-time DEV warning per (sceneId,version) so authors know the
39
+ // scaffold limitation.
40
+ if (import.meta.env.DEV) {
41
+ const key = `${sceneId}:${sceneVersion}`;
42
+ if (!warned.has(key)) {
43
+ warned.add(key);
44
+ console.warn(
45
+ `[lumencast/instance] scaffold render — async bundle fetch + ` +
46
+ `__params.* injection are not yet wired (LSML 1.1 §4.9). ` +
47
+ `scene_id=${sceneId}`,
48
+ );
49
+ }
50
+ }
51
+
52
+ const size = resolved.size as { w?: number; h?: number } | undefined;
53
+ const position = resolved.position as { x?: number; y?: number } | undefined;
54
+
55
+ return (
56
+ <div
57
+ data-lumencast-instance={sceneId}
58
+ data-lumencast-version={sceneVersion}
59
+ style={{
60
+ position: position ? "absolute" : "relative",
61
+ left: position?.x,
62
+ top: position?.y,
63
+ width: size?.w,
64
+ height: size?.h,
65
+ outline: import.meta.env.DEV ? "1px dashed rgba(255,180,0,0.5)" : "none",
66
+ boxSizing: "border-box",
67
+ }}
68
+ />
69
+ );
70
+ }
@@ -0,0 +1,28 @@
1
+ import type { PrimitiveProps } from "./index";
2
+
3
+ /** Embedded video. `src`, `loop`, `mute`, `autoplay`. Audio is muted
4
+ * by default — broadcast audio is Pulsar-side, not from the browser
5
+ * source. */
6
+ export function Media({ resolved }: PrimitiveProps) {
7
+ const src = resolved.src as string | undefined;
8
+ if (!src) return null;
9
+ const loop = (resolved.loop as boolean | undefined) ?? true;
10
+ const mute = (resolved.mute as boolean | undefined) ?? true;
11
+ const autoplay = (resolved.autoplay as boolean | undefined) ?? true;
12
+ const fit = (resolved.fit as string | undefined) ?? "cover";
13
+
14
+ return (
15
+ <video
16
+ src={src}
17
+ autoPlay={autoplay}
18
+ loop={loop}
19
+ muted={mute}
20
+ playsInline
21
+ style={{
22
+ width: "100%",
23
+ height: "100%",
24
+ objectFit: fit as React.CSSProperties["objectFit"],
25
+ }}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,135 @@
1
+ import { motion } from "framer-motion";
2
+ import type { ReactElement } from "react";
3
+ import type { PrimitiveProps } from "./index";
4
+ import { toFramer } from "../../animate/transitions";
5
+ import { parseFills, renderFill } from "../fill";
6
+
7
+ interface StrokeSpec {
8
+ color?: string;
9
+ width?: number;
10
+ }
11
+
12
+ /** Rectangle / circle / line. Renders as SVG so stroke + fill behave
13
+ * predictably across hosts. Opacity animatable.
14
+ *
15
+ * LSML 1.1 §4.6 + §4.12 add `fills[]` / `strokes[]` arrays as the
16
+ * preferred way to declare multi-layer fills with linear/radial
17
+ * gradients. The legacy single `fill` / `stroke` props remain
18
+ * accepted for 1.0 bundles ; when both are present the array form
19
+ * wins (the spec forbids mixing, but we tolerate to ease migration).
20
+ */
21
+ export function Shape({ resolved, transitionFor }: PrimitiveProps) {
22
+ const kind = (resolved.kind as string | undefined) ?? "rect";
23
+ const legacyFill = (resolved.fill as string | undefined) ?? "transparent";
24
+ const legacyStroke = (resolved.stroke as string | undefined) ?? "transparent";
25
+ const legacyStrokeWidth = numberOr(resolved.stroke_width, 0);
26
+ const width = numberOr(resolved.width, 100);
27
+ const height = numberOr(resolved.height, 100);
28
+ const radius = numberOr(resolved.radius, 0);
29
+ const opacity = numberOr(resolved.opacity, 1);
30
+
31
+ const tx = transitionFor("opacity");
32
+ const transition = toFramer(tx);
33
+
34
+ // LSML 1.1 §4.6 — `fills[]` is the preferred multi-fill form. Fall
35
+ // back to the singular `fill` for 1.0 bundles.
36
+ const fills = parseFills(resolved.fills);
37
+ const strokes = parseStrokes(resolved.strokes);
38
+
39
+ // Each fill compiles to a (defs, ref) pair. We render the shape
40
+ // outline once per fill, layered top-to-bottom (first entry → on
41
+ // top, per §4.12). The defs are aggregated for a single <defs>.
42
+ const fillRenders = fills.map(renderFill);
43
+ const allDefs = fillRenders.flatMap((r) => r.defs);
44
+ const fillRefs = fillRenders.length > 0 ? fillRenders.map((r) => r.ref) : [legacyFill];
45
+
46
+ // Strokes : same layered approach, but solid colours only (gradient
47
+ // strokes are out of scope for §4.6 1.1). Each stroke is rendered
48
+ // as an additional pass over the same shape outline.
49
+ const strokeLayers =
50
+ strokes.length > 0
51
+ ? strokes.map((s) => ({ color: s.color ?? "transparent", width: s.width ?? 0 }))
52
+ : [{ color: legacyStroke, width: legacyStrokeWidth }];
53
+
54
+ // Stack order : fillRefs are emitted top-to-bottom per §4.12. SVG
55
+ // paints later siblings on top, so we reverse here so the first
56
+ // entry in fills[] ends up rendered last (visually on top).
57
+ const stackedFills = [...fillRefs].reverse();
58
+ const stackedStrokes = [...strokeLayers].reverse();
59
+
60
+ const renderShape = (
61
+ fill: string,
62
+ stroke: { color: string; width: number },
63
+ keyPrefix: string,
64
+ ): ReactElement => {
65
+ if (kind === "circle") {
66
+ return (
67
+ <circle
68
+ key={keyPrefix}
69
+ cx={width / 2}
70
+ cy={height / 2}
71
+ r={Math.min(width, height) / 2 - stroke.width / 2}
72
+ fill={fill}
73
+ stroke={stroke.color}
74
+ strokeWidth={stroke.width}
75
+ />
76
+ );
77
+ }
78
+ if (kind === "line") {
79
+ return (
80
+ <line
81
+ key={keyPrefix}
82
+ x1="0"
83
+ y1={height / 2}
84
+ x2={width}
85
+ y2={height / 2}
86
+ stroke={stroke.color || fill}
87
+ strokeWidth={stroke.width || 1}
88
+ />
89
+ );
90
+ }
91
+ // rect default
92
+ return (
93
+ <rect
94
+ key={keyPrefix}
95
+ x={stroke.width / 2}
96
+ y={stroke.width / 2}
97
+ width={Math.max(0, width - stroke.width)}
98
+ height={Math.max(0, height - stroke.width)}
99
+ rx={radius}
100
+ ry={radius}
101
+ fill={fill}
102
+ stroke={stroke.color}
103
+ strokeWidth={stroke.width}
104
+ />
105
+ );
106
+ };
107
+
108
+ return (
109
+ <motion.svg
110
+ width={width}
111
+ height={height}
112
+ viewBox={`0 0 ${width} ${height}`}
113
+ animate={{ opacity }}
114
+ transition={transition}
115
+ style={{ willChange: "opacity" }}
116
+ >
117
+ {allDefs.length > 0 && <defs>{allDefs}</defs>}
118
+ {stackedFills.map((ref, i) =>
119
+ renderShape(ref, { color: "transparent", width: 0 }, `fill-${i}`),
120
+ )}
121
+ {stackedStrokes.map((s, i) => renderShape("none", s, `stroke-${i}`))}
122
+ </motion.svg>
123
+ );
124
+ }
125
+
126
+ function parseStrokes(value: unknown): StrokeSpec[] {
127
+ if (!Array.isArray(value)) return [];
128
+ return value.filter(
129
+ (v): v is StrokeSpec => typeof v === "object" && v !== null && ("color" in v || "width" in v),
130
+ );
131
+ }
132
+
133
+ function numberOr(v: unknown, fallback: number): number {
134
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
135
+ }
@@ -0,0 +1,48 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { PrimitiveProps } from "./index";
3
+
4
+ /** Vertical or horizontal flex container. Layout-only — bindings
5
+ * here are unusual but tolerated.
6
+ *
7
+ * LSML 1.1 §4.1 adds `wrap` (boolean) and `crossGap` (number) :
8
+ * - wrap: true sets `flex-wrap: wrap` so children flow onto the
9
+ * next row / column when they overflow the main axis.
10
+ * - crossGap is the spacing between rows / columns when wrapping.
11
+ * Mapped to CSS `row-gap` (horizontal stack) or `column-gap`
12
+ * (vertical stack). Ignored when `wrap` is false.
13
+ */
14
+ export function Stack({ resolved, children }: PrimitiveProps) {
15
+ const direction = (resolved.direction as string) ?? "vertical";
16
+ const gap = numberOr(resolved.gap, 0);
17
+ const wrap = resolved.wrap === true;
18
+ const crossGap = numberOr(resolved.crossGap, 0);
19
+ const align = (resolved.align as string) ?? "stretch";
20
+ const justify = (resolved.justify as string) ?? "flex-start";
21
+ const isHorizontal = direction === "horizontal";
22
+
23
+ const style: CSSProperties = {
24
+ display: "flex",
25
+ flexDirection: isHorizontal ? "row" : "column",
26
+ alignItems: align,
27
+ justifyContent: justify,
28
+ };
29
+
30
+ if (wrap) {
31
+ style.flexWrap = "wrap";
32
+ if (isHorizontal) {
33
+ style.columnGap = gap;
34
+ style.rowGap = crossGap;
35
+ } else {
36
+ style.rowGap = gap;
37
+ style.columnGap = crossGap;
38
+ }
39
+ } else {
40
+ style.gap = gap;
41
+ }
42
+
43
+ return <div style={style}>{children}</div>;
44
+ }
45
+
46
+ function numberOr(v: unknown, fallback: number): number {
47
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
48
+ }
@@ -0,0 +1,38 @@
1
+ import { motion } from "framer-motion";
2
+ import type { PrimitiveProps } from "./index";
3
+ import { toFramer } from "../../animate/transitions";
4
+
5
+ /** Text leaf. Value renders as the displayed string ; style props
6
+ * cover size / weight / colour / alignment. Opacity is animated when
7
+ * a transition is declared on `opacity` or `value`. */
8
+ export function Text({ resolved, transitionFor }: PrimitiveProps) {
9
+ const value = resolved.value === undefined ? "" : String(resolved.value);
10
+ const size = (resolved.size as string | number | undefined) ?? "1rem";
11
+ const weight = (resolved.weight as number | undefined) ?? 400;
12
+ const colour = (resolved.colour as string | undefined) ?? "currentColor";
13
+ const align = (resolved.align as string | undefined) ?? "start";
14
+ const opacity = numberOr(resolved.opacity, 1);
15
+
16
+ const tx = transitionFor("opacity") ?? transitionFor("value");
17
+
18
+ return (
19
+ <motion.span
20
+ style={{
21
+ display: "inline-block",
22
+ fontSize: size,
23
+ fontWeight: weight,
24
+ color: colour,
25
+ textAlign: align as React.CSSProperties["textAlign"],
26
+ willChange: "opacity",
27
+ }}
28
+ animate={{ opacity }}
29
+ transition={toFramer(tx)}
30
+ >
31
+ {value}
32
+ </motion.span>
33
+ );
34
+ }
35
+
36
+ function numberOr(v: unknown, fallback: number): number {
37
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
38
+ }
@@ -0,0 +1,27 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ /** Path-scope context. Children inside a `repeat` get a `prefix` that
4
+ * is prepended to their declared bindings, so a single template can
5
+ * bind to per-item paths like `items.{i}.score`. */
6
+ const PathScopeContext = createContext<string>("");
7
+
8
+ export function PathScopeProvider({ prefix, children }: { prefix: string; children: ReactNode }) {
9
+ const parent = useContext(PathScopeContext);
10
+ const next = parent ? `${parent}.${prefix}` : prefix;
11
+ return <PathScopeContext.Provider value={next}>{children}</PathScopeContext.Provider>;
12
+ }
13
+
14
+ /** Returns the current path prefix, or "" if there is no scope. */
15
+ export function usePathScope(): string {
16
+ return useContext(PathScopeContext);
17
+ }
18
+
19
+ /** Resolve a binding path under the current scope. */
20
+ export function scopedPath(prefix: string, path: string): string {
21
+ if (!prefix) return path;
22
+ // Path may itself start with a literal prefix (e.g. `__system.*`),
23
+ // which should NOT be scoped — only paths that are clearly relative
24
+ // get prefixed.
25
+ if (path.startsWith("__")) return path;
26
+ return `${prefix}.${path}`;
27
+ }
@@ -0,0 +1,24 @@
1
+ // LSML 1.1 §6.7 — stagger context.
2
+ //
3
+ // `repeat.stagger_ms` produces wave-like reveals : iteration N's
4
+ // animations start `N * stagger_ms` after iteration 0. The Repeat
5
+ // renderer computes the per-iteration delay and threads it through
6
+ // React context so KeyframePlayer (and future animate-aware primitives)
7
+ // can pick it up without per-primitive wiring.
8
+
9
+ import { createContext } from "react";
10
+
11
+ /** Per-iteration stagger delay in milliseconds. `0` means no offset
12
+ * (the implicit default outside a staggered repeat). */
13
+ export const StaggerContext = createContext<number>(0);
14
+
15
+ /** Spec hint : runtimes MAY cap effective stagger to avoid pathological
16
+ * wait times on large lists. We cap at 2 s. */
17
+ export const STAGGER_CAP_MS = 2000;
18
+
19
+ /** Compute the effective per-iteration delay, applying the runtime cap. */
20
+ export function computeStaggerDelayMs(index: number, staggerMs: number): number {
21
+ if (staggerMs <= 0) return 0;
22
+ const raw = index * staggerMs;
23
+ return raw > STAGGER_CAP_MS ? STAGGER_CAP_MS : raw;
24
+ }
@@ -0,0 +1,182 @@
1
+ // Recursive tree renderer — resolves bindings, dispatches to
2
+ // primitives, handles `repeat` specially.
3
+
4
+ import { useSignals } from "@preact/signals-react/runtime";
5
+ import { useMemo, type ReactNode } from "react";
6
+ import type { Store } from "../state/store";
7
+ import type { Transition } from "../animate/transitions";
8
+ import { PRIMITIVES } from "./primitives";
9
+ import { PathScopeProvider, scopedPath, usePathScope } from "./scope";
10
+ import type { RenderNode } from "./bundle";
11
+ import { UniversalWrapper, type SizingMode } from "./universal-wrapper";
12
+ import { KeyframePlayer } from "./keyframe-player";
13
+ import { StaggerContext, computeStaggerDelayMs } from "./stagger-context";
14
+
15
+ export interface TreeProps {
16
+ node: RenderNode;
17
+ store: Store;
18
+ }
19
+
20
+ export function Tree({ node, store }: TreeProps): ReactNode {
21
+ if (node.kind === "repeat") {
22
+ return <Repeat node={node} store={store} />;
23
+ }
24
+ return <Node node={node} store={store} />;
25
+ }
26
+
27
+ function Node({ node, store }: TreeProps): ReactNode {
28
+ // useSignals() lets the surrounding component subscribe to any
29
+ // signal read during render. Each leaf path has its own signal so
30
+ // re-renders only fire on touched paths.
31
+ useSignals();
32
+ const scope = usePathScope();
33
+
34
+ // Hooks must run unconditionally — the early-return for unknown
35
+ // kinds happens *after* every hook has fired.
36
+ const resolved = useMemo(
37
+ () => resolveProps(node, store, scope),
38
+ // We re-build per render — signals re-render cheaply, and the
39
+ // resolution itself is O(bindings) which is small. The memo is a
40
+ // micro-optimisation to keep object identity stable across renders
41
+ // when the inputs haven't changed.
42
+ [node, store, scope, ...readBindingValues(node, store, scope)],
43
+ );
44
+
45
+ const Primitive = PRIMITIVES[node.kind as keyof typeof PRIMITIVES];
46
+ if (!Primitive) {
47
+ if (import.meta.env.DEV) {
48
+ console.warn(`[lumencast] unknown render kind : ${node.kind}`);
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // LSDP/1.1 §3.2.2 — a per-leaf transition on the most recent delta
54
+ // takes precedence over the bundle-level default. Only bound props
55
+ // can carry a wire transition (a static prop never moves). Snapshots
56
+ // clear the directive, so the bundle default reapplies after a reset.
57
+ //
58
+ // We resolve here in the parent's render (useSignals() above tracks
59
+ // these reads) rather than inside the primitive's callback — that way
60
+ // a transition signal change re-renders this Node, which in turn re-
61
+ // renders the primitive with the new transition prop.
62
+ const liveTransitions: Record<string, Transition | undefined> = {};
63
+ if (node.bindings) {
64
+ for (const [key, path] of Object.entries(node.bindings)) {
65
+ const ts = store.transitionSignal(scopedPath(scope, path)).value;
66
+ if (ts !== undefined) liveTransitions[key] = ts;
67
+ }
68
+ }
69
+ const transitionFor = (key: string): Transition | undefined => {
70
+ if (key in liveTransitions) return liveTransitions[key];
71
+ return node.transitions?.[key];
72
+ };
73
+
74
+ const children = node.children?.map((child, idx) => (
75
+ <Tree key={child.id ?? idx} node={child} store={store} />
76
+ ));
77
+
78
+ // LSML 1.1 §5.4 — universal props applied uniformly across all
79
+ // primitives. Pulled out of `resolved` so primitives can ignore
80
+ // them ; the wrapper composes with whatever transform/opacity the
81
+ // primitive's own framer-motion may apply.
82
+ const universal = {
83
+ visible: typeof resolved.visible === "boolean" ? resolved.visible : undefined,
84
+ opacity:
85
+ typeof resolved.universal_opacity === "number" ? resolved.universal_opacity : undefined,
86
+ rotation: typeof resolved.rotation === "number" ? resolved.rotation : undefined,
87
+ sizing: extractSizing(resolved.sizing),
88
+ };
89
+
90
+ const body = (
91
+ <UniversalWrapper {...universal}>
92
+ <Primitive resolved={resolved} transitionFor={transitionFor}>
93
+ {children}
94
+ </Primitive>
95
+ </UniversalWrapper>
96
+ );
97
+
98
+ // LSML 1.1 §6.6 — when a primitive declares keyframes, wrap the
99
+ // rendered subtree in a player that drives framer-motion through the
100
+ // step path. The player handles replay-on-key-change and reads any
101
+ // ambient stagger delay from StaggerContext (§6.7).
102
+ if (node.keyframes) {
103
+ return (
104
+ <KeyframePlayer keyframes={node.keyframes} store={store}>
105
+ {body}
106
+ </KeyframePlayer>
107
+ );
108
+ }
109
+ return body;
110
+ }
111
+
112
+ function extractSizing(value: unknown): { x?: SizingMode; y?: SizingMode } | undefined {
113
+ if (typeof value !== "object" || value === null) return undefined;
114
+ const obj = value as { x?: unknown; y?: unknown };
115
+ const out: { x?: SizingMode; y?: SizingMode } = {};
116
+ if (obj.x === "fixed" || obj.x === "hug" || obj.x === "fill") out.x = obj.x;
117
+ if (obj.y === "fixed" || obj.y === "hug" || obj.y === "fill") out.y = obj.y;
118
+ return out.x !== undefined || out.y !== undefined ? out : undefined;
119
+ }
120
+
121
+ function Repeat({ node, store }: TreeProps): ReactNode {
122
+ useSignals();
123
+ const scope = usePathScope();
124
+
125
+ const itemsBinding = node.bindings?.items;
126
+ const items =
127
+ itemsBinding === undefined
128
+ ? []
129
+ : ((store.signal(scopedPath(scope, itemsBinding)).value as unknown[] | undefined) ?? []);
130
+ if (!Array.isArray(items)) return null;
131
+
132
+ const template = node.children?.[0];
133
+ if (!template) return null;
134
+
135
+ // LSML 1.1 §6.7 — `stagger_ms` produces wave-like reveals across
136
+ // iterations. We compute the per-iteration delay (capped) and feed
137
+ // it to descendants via StaggerContext so the KeyframePlayer (and
138
+ // future animate-aware primitives) can pick it up without per-
139
+ // iteration scripting. `stagger_ms: 0` (or unset) is a no-op.
140
+ const staggerMs = typeof node.stagger_ms === "number" ? node.stagger_ms : 0;
141
+
142
+ return (
143
+ <>
144
+ {items.map((_item, idx) => {
145
+ const delayMs = computeStaggerDelayMs(idx, staggerMs);
146
+ const tree = (
147
+ <PathScopeProvider key={idx} prefix={`${itemsBinding ?? ""}.${idx}`}>
148
+ <Tree node={template} store={store} />
149
+ </PathScopeProvider>
150
+ );
151
+ if (delayMs <= 0) return tree;
152
+ return (
153
+ <StaggerContext.Provider key={idx} value={delayMs}>
154
+ {tree}
155
+ </StaggerContext.Provider>
156
+ );
157
+ })}
158
+ </>
159
+ );
160
+ }
161
+
162
+ function resolveProps(node: RenderNode, store: Store, scope: string): Record<string, unknown> {
163
+ const out: Record<string, unknown> = { ...(node.props ?? {}) };
164
+ if (node.bindings) {
165
+ for (const [propKey, path] of Object.entries(node.bindings)) {
166
+ const fullPath = scopedPath(scope, path);
167
+ out[propKey] = store.signal(fullPath).value;
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** Helper for the useMemo deps array — read each bound signal so the
174
+ * memo invalidates when any binding moves. */
175
+ function readBindingValues(node: RenderNode, store: Store, scope: string): unknown[] {
176
+ if (!node.bindings) return [];
177
+ const values: unknown[] = [];
178
+ for (const path of Object.values(node.bindings)) {
179
+ values.push(store.signal(scopedPath(scope, path)).value);
180
+ }
181
+ return values;
182
+ }
@@ -0,0 +1,95 @@
1
+ // Universal-props wrapper (LSML 1.1 §5.4).
2
+ //
3
+ // Every primitive renders inside this wrapper, which applies the four
4
+ // universal props uniformly :
5
+ //
6
+ // - `visible: false` → display: none (slot collapses in flex layouts)
7
+ // - `opacity` → CSS opacity, multiplicative with whatever animation
8
+ // a primitive may apply via framer-motion (browsers compose them)
9
+ // - `rotation` → CSS transform: rotate(<deg>)
10
+ // - `sizing.x`/`sizing.y` → flex shorthand on the wrapping div, lets
11
+ // a primitive participate in its parent flex layout's auto-sizing
12
+ //
13
+ // `bindUniversal` is resolved by the Tree renderer before the wrapper
14
+ // sees its values, so this component only deals with concrete numbers
15
+ // and booleans.
16
+
17
+ import type { ReactNode, CSSProperties } from "react";
18
+
19
+ export type SizingMode = "fixed" | "hug" | "fill";
20
+
21
+ export interface UniversalProps {
22
+ visible?: boolean;
23
+ opacity?: number;
24
+ rotation?: number;
25
+ sizing?: { x?: SizingMode; y?: SizingMode };
26
+ }
27
+
28
+ export interface UniversalWrapperProps extends UniversalProps {
29
+ children: ReactNode;
30
+ }
31
+
32
+ /**
33
+ * Maps a SizingMode onto a flex shorthand. Per LSML 1.1 §5.4.1 :
34
+ * - fixed : the primitive honours its declared size verbatim
35
+ * - hug : the primitive shrinks to its intrinsic content size
36
+ * - fill : the primitive grows to fill available space
37
+ */
38
+ function flexFor(mode: SizingMode | undefined): string | undefined {
39
+ switch (mode) {
40
+ case "fixed":
41
+ return "0 0 auto";
42
+ case "hug":
43
+ return "0 1 auto";
44
+ case "fill":
45
+ return "1 1 auto";
46
+ default:
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ export function UniversalWrapper({
52
+ visible,
53
+ opacity,
54
+ rotation,
55
+ sizing,
56
+ children,
57
+ }: UniversalWrapperProps) {
58
+ if (visible === false) {
59
+ return null; // slot collapses in flex/grid layouts (§5.4)
60
+ }
61
+
62
+ // No-op fast path — when no universal props are set, render children
63
+ // directly. Lets simple bundles avoid an extra DOM node per primitive.
64
+ const hasOpacity = typeof opacity === "number" && opacity !== 1;
65
+ const hasRotation = typeof rotation === "number" && rotation !== 0;
66
+ const hasSizing = sizing?.x !== undefined || sizing?.y !== undefined;
67
+ if (!hasOpacity && !hasRotation && !hasSizing) {
68
+ return <>{children}</>;
69
+ }
70
+
71
+ const style: CSSProperties = {};
72
+ if (hasOpacity) style.opacity = opacity;
73
+ if (hasRotation) style.transform = `rotate(${rotation}deg)`;
74
+
75
+ // sizing.x / sizing.y map to flex / row-flex behaviour. The
76
+ // x-axis applies along the main axis of a horizontal stack ; the
77
+ // y-axis along a vertical stack. We emit `flex` (covers both via
78
+ // CSS's flex-direction) and rely on the parent stack for orientation.
79
+ if (hasSizing) {
80
+ const x = flexFor(sizing?.x);
81
+ const y = flexFor(sizing?.y);
82
+ // Emit a single flex declaration when both axes agree, otherwise
83
+ // ship explicit grow/shrink/basis based on the dominant intent.
84
+ if (x === y && x !== undefined) {
85
+ style.flex = x;
86
+ } else {
87
+ // Heuristic : honour x for horizontal stacks (most common in
88
+ // broadcast UIs). Renderer doesn't know the parent's axis here ;
89
+ // a future iteration could thread that through context.
90
+ style.flex = x ?? y;
91
+ }
92
+ }
93
+
94
+ return <div style={style}>{children}</div>;
95
+ }
@@ -0,0 +1,24 @@
1
+ import { batch } from "@preact/signals-react";
2
+ import type { DeltaFrame } from "@lumencast/protocol";
3
+ import type { Store } from "./store.js";
4
+ import { parseWireTransition } from "../animate/transitions";
5
+
6
+ /** Apply an LSDP/1 delta. All patches in the frame land in a single signals
7
+ * batch — components reading multiple paths see them flip in one render pass.
8
+ *
9
+ * LSDP/1.1 §3.2.2 — a patch may carry a `transition` directive overriding
10
+ * the bundle-level default for the next animation cycle on that leaf. We
11
+ * thread it through the store so the renderer reads the correct directive
12
+ * alongside the new value. */
13
+ export function applyDelta(store: Store, frame: DeltaFrame): void {
14
+ batch(() => {
15
+ for (const patch of frame.patches) {
16
+ const transition = parseWireTransition(patch.transition);
17
+ if (transition !== undefined) {
18
+ store.setWithTransition(patch.path, patch.value, transition);
19
+ } else {
20
+ store.set(patch.path, patch.value);
21
+ }
22
+ }
23
+ });
24
+ }