@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,208 @@
1
+ // Render bundle — the runtime's flat, pre-compiled representation of a scene.
2
+ //
3
+ // The bundle is content-addressed by `scene_version` (sha256 of the
4
+ // canonical JSON form). Lumencast fetches it once per `scene_version` and
5
+ // caches forever; the server serves it with long-TTL immutable cache headers.
6
+ //
7
+ // Note on shape: this `RenderBundle` is the flat, runtime-internal form. The
8
+ // canonical *authoring* format (LSML 1.0, see lumencast-protocol/spec/LSML-1.md)
9
+ // uses inline `bind: { value: "path" }` per primitive instead of a `bindings`
10
+ // map. A compiler step (forthcoming `@lumencast/compiler`) will translate
11
+ // LSML 1.0 → RenderBundle. For now, callers who want to feed an LSML 1.0
12
+ // bundle pre-compile or use a hand-rolled adapter.
13
+
14
+ import type { Transition } from "../animate/transitions.js";
15
+ import type { Keyframes } from "../animate/keyframes.js";
16
+
17
+ // --- bundle shape ----------------------------------------------------
18
+
19
+ export type RenderKind =
20
+ | "stack"
21
+ | "grid"
22
+ | "frame"
23
+ | "text"
24
+ | "image"
25
+ | "shape"
26
+ | "media"
27
+ | "repeat"
28
+ | "instance";
29
+
30
+ export interface RenderNode {
31
+ kind: RenderKind;
32
+ /** Stable identifier for keyed reconciliation. */
33
+ id?: string;
34
+ /** Static props (frozen at build/compile time). */
35
+ props?: Record<string, unknown>;
36
+ /** Prop name → state path. The render layer subscribes the path's signal
37
+ * and applies the value to the named prop on each change. */
38
+ bindings?: Record<string, string>;
39
+ /** Default transition per bound prop. Aligns with LSML 1.0 §6 `animate`
40
+ * directives. The runtime applies these as CSS transitions / Framer Motion
41
+ * configs at render time. */
42
+ transitions?: Record<string, Transition>;
43
+ /** LSML 1.1 §6.6 — multi-step keyframe sequence played on mount or
44
+ * whenever `keyframes.key` (LeafPath) changes. Coexists with
45
+ * `transitions` ; the runtime applies whichever was last triggered
46
+ * (no blending — see §6.6 last paragraph). */
47
+ keyframes?: Keyframes;
48
+ /** LSML 1.1 §6.7 — only meaningful on `repeat`. Each iteration's
49
+ * animations start `index * stagger_ms` after iteration 0. */
50
+ stagger_ms?: number;
51
+ /** Children — already-inlined primitives only. */
52
+ children?: RenderNode[];
53
+ }
54
+
55
+ export type OperatorInputType =
56
+ | "boolean"
57
+ | "number"
58
+ | "text"
59
+ | "select"
60
+ | "enum"
61
+ | "path-ref"
62
+ | "colour"
63
+ | "duration";
64
+
65
+ export interface OperatorInput {
66
+ path: string;
67
+ label: string;
68
+ type: OperatorInputType;
69
+ default?: unknown;
70
+ group?: string;
71
+ writable_by?: string[];
72
+ [extra: string]: unknown;
73
+ }
74
+
75
+ export interface ExternalAdapter {
76
+ key: string;
77
+ label: string;
78
+ kind: string;
79
+ target_paths: string[];
80
+ [extra: string]: unknown;
81
+ }
82
+
83
+ export interface Asset {
84
+ id: string;
85
+ url: string;
86
+ kind: string;
87
+ [extra: string]: unknown;
88
+ }
89
+
90
+ export interface RenderBundle {
91
+ scene_version: string;
92
+ root: RenderNode;
93
+ operator_inputs?: OperatorInput[];
94
+ external_adapters?: ExternalAdapter[];
95
+ assets?: Asset[];
96
+ /** LSML 1.1 §17.3 — capability profiles required for correct rendering.
97
+ * Each entry is a `<vendor>.<name>-<version>` string. The runtime
98
+ * checks every entry against its supported list ; an unrecognised
99
+ * profile raises BUNDLE_INCOMPATIBLE per §17.3.1. */
100
+ profiles?: string[];
101
+ }
102
+
103
+ /**
104
+ * Profiles the JS runtime advertises support for. Bundle authors who
105
+ * declare `profiles: [...]` get a hard `BUNDLE_INCOMPATIBLE` rejection
106
+ * when any entry is not in this set (LSML 1.1 §17.3.1).
107
+ *
108
+ * 1.1 ships with no standard profiles ; future minors / vendor specs
109
+ * register here. The `x-lumencast.color-srgb-1.0` entry is the
110
+ * default-color-space marker ; bundles that opt into a perceptual
111
+ * space (OKLCH) would request a different profile and currently
112
+ * reject.
113
+ */
114
+ export const SUPPORTED_PROFILES: ReadonlySet<string> = new Set<string>([
115
+ "x-lumencast.color-srgb-1.0",
116
+ ]);
117
+
118
+ export class BundleIncompatibleError extends Error {
119
+ public readonly code = "BUNDLE_INCOMPATIBLE" as const;
120
+ public readonly unsupportedProfiles: string[];
121
+ constructor(unsupportedProfiles: string[]) {
122
+ super(
123
+ `BUNDLE_INCOMPATIBLE: profile(s) not supported by this runtime: ${unsupportedProfiles.join(
124
+ ", ",
125
+ )}`,
126
+ );
127
+ this.name = "BundleIncompatibleError";
128
+ this.unsupportedProfiles = unsupportedProfiles;
129
+ }
130
+ }
131
+
132
+ /** Validate a bundle's `profiles[]` against the runtime's supported
133
+ * set. Throws `BundleIncompatibleError` listing every offending entry
134
+ * when at least one is not supported. */
135
+ export function validateBundleProfiles(
136
+ bundle: { profiles?: string[] },
137
+ supported: ReadonlySet<string> = SUPPORTED_PROFILES,
138
+ ): void {
139
+ const profiles = bundle.profiles;
140
+ if (!profiles || profiles.length === 0) return;
141
+ const missing = profiles.filter((p) => !supported.has(p));
142
+ if (missing.length > 0) {
143
+ throw new BundleIncompatibleError(missing);
144
+ }
145
+ }
146
+
147
+ // --- fetch + cache ---------------------------------------------------
148
+
149
+ export interface BundleFetcher {
150
+ /** Fetch the bundle for a scene version. Cached forever by hash. */
151
+ get(sceneId: string, sceneVersion: string): Promise<RenderBundle>;
152
+ /** Inject a bundle directly — used by tests and for the "scene already in
153
+ * flight" handoff path. */
154
+ preload(bundle: RenderBundle): void;
155
+ }
156
+
157
+ export interface BundleFetcherOptions {
158
+ /** Base URL of the server. The fetcher constructs
159
+ * `${baseUrl}/lsdp/v1/scenes/{id}/bundle?v={hash}`. */
160
+ baseUrl: string;
161
+ /** Path prefix for bundle resolution. Defaults to `/lsdp/v1/scenes`. */
162
+ pathPrefix?: string;
163
+ fetchImpl?: typeof fetch;
164
+ }
165
+
166
+ class FetcherImpl implements BundleFetcher {
167
+ private readonly cache = new Map<string, RenderBundle>();
168
+ private readonly baseUrl: string;
169
+ private readonly pathPrefix: string;
170
+ private readonly fetchImpl: typeof fetch;
171
+
172
+ constructor(opts: BundleFetcherOptions) {
173
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
174
+ this.pathPrefix = (opts.pathPrefix ?? "/lsdp/v1/scenes").replace(/\/$/, "");
175
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
176
+ }
177
+
178
+ preload(bundle: RenderBundle): void {
179
+ // LSML 1.1 §17.3.1 — reject early if any declared profile is
180
+ // unsupported by this runtime. Authors get an actionable error
181
+ // instead of a silent rendering glitch.
182
+ validateBundleProfiles(bundle);
183
+ this.cache.set(bundle.scene_version, bundle);
184
+ }
185
+
186
+ async get(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
187
+ const cached = this.cache.get(sceneVersion);
188
+ if (cached) return cached;
189
+ const url = `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(sceneId)}/bundle?v=${encodeURIComponent(sceneVersion)}`;
190
+ const response = await this.fetchImpl(url);
191
+ if (!response.ok) {
192
+ throw new Error(`bundle fetch failed: ${response.status} ${response.statusText}`);
193
+ }
194
+ const json = (await response.json()) as RenderBundle;
195
+ if (json.scene_version !== sceneVersion) {
196
+ throw new Error(
197
+ `bundle scene_version mismatch: expected ${sceneVersion}, got ${json.scene_version}`,
198
+ );
199
+ }
200
+ validateBundleProfiles(json);
201
+ this.cache.set(sceneVersion, json);
202
+ return json;
203
+ }
204
+ }
205
+
206
+ export function createBundleFetcher(opts: BundleFetcherOptions): BundleFetcher {
207
+ return new FetcherImpl(opts);
208
+ }
@@ -0,0 +1,163 @@
1
+ // Fill rendering helpers (LSML 1.1 §4.12).
2
+ //
3
+ // A Fill is a discriminated union :
4
+ // - solid : { kind: "solid", color, opacity? }
5
+ // - linear-gradient : { kind: "linear-gradient", angle_deg?, stops, opacity? }
6
+ // - radial-gradient : { kind: "radial-gradient", center?, radius?, stops, opacity? }
7
+ //
8
+ // shape.fills[] and frame.backgrounds[] both use this shape. Each fill
9
+ // renders as a separate SVG element layered top-to-bottom (first entry
10
+ // renders on top per §4.12).
11
+
12
+ import type { CSSProperties, ReactElement } from "react";
13
+
14
+ export interface FillStop {
15
+ offset: number;
16
+ color: string;
17
+ opacity?: number;
18
+ }
19
+
20
+ export type Fill =
21
+ | { kind: "solid"; color: string; opacity?: number }
22
+ | {
23
+ kind: "linear-gradient";
24
+ angle_deg?: number;
25
+ stops: FillStop[];
26
+ opacity?: number;
27
+ }
28
+ | {
29
+ kind: "radial-gradient";
30
+ center?: { x: number; y: number };
31
+ radius?: number;
32
+ stops: FillStop[];
33
+ opacity?: number;
34
+ };
35
+
36
+ let gradientIdSeq = 0;
37
+ function nextGradientId(): string {
38
+ gradientIdSeq = (gradientIdSeq + 1) % 1_000_000;
39
+ return `lumen-grad-${gradientIdSeq.toString(36)}`;
40
+ }
41
+
42
+ export interface FillRenderResult {
43
+ /** SVG <defs> contributions (gradient definitions). */
44
+ defs: ReactElement[];
45
+ /** Reference to use as the `fill` attribute on the shape. */
46
+ ref: string;
47
+ }
48
+
49
+ /** Compile a Fill into an SVG `<defs>` entry + a `fill="url(#…)"` ref.
50
+ * Solid fills produce no defs and return the colour directly. */
51
+ export function renderFill(fill: Fill): FillRenderResult {
52
+ if (fill.kind === "solid") {
53
+ // Solid fill — no defs needed, just hand the colour to fill.
54
+ // SVG fill-opacity composes with element opacity multiplicatively
55
+ // so we apply both consistently.
56
+ return { defs: [], ref: fill.color };
57
+ }
58
+ const id = nextGradientId();
59
+ if (fill.kind === "linear-gradient") {
60
+ // angle_deg : 0 = bottom-to-top per §4.12 (matches CSS `linear-gradient`)
61
+ const angle = fill.angle_deg ?? 0;
62
+ // Translate angle (degrees from up) to SVG x1/y1/x2/y2 in user space.
63
+ const rad = ((angle - 90) * Math.PI) / 180; // 0° → x1=0,y1=1 (bottom-up)
64
+ const x1 = 0.5 - 0.5 * Math.cos(rad);
65
+ const y1 = 0.5 - 0.5 * Math.sin(rad);
66
+ const x2 = 0.5 + 0.5 * Math.cos(rad);
67
+ const y2 = 0.5 + 0.5 * Math.sin(rad);
68
+ const defs = [
69
+ <linearGradient
70
+ key={id}
71
+ id={id}
72
+ x1={`${x1 * 100}%`}
73
+ y1={`${y1 * 100}%`}
74
+ x2={`${x2 * 100}%`}
75
+ y2={`${y2 * 100}%`}
76
+ >
77
+ {fill.stops.map((s, i) => (
78
+ <stop
79
+ key={i}
80
+ offset={s.offset}
81
+ stopColor={s.color}
82
+ {...(s.opacity !== undefined ? { stopOpacity: s.opacity } : {})}
83
+ />
84
+ ))}
85
+ </linearGradient>,
86
+ ];
87
+ return { defs, ref: `url(#${id})` };
88
+ }
89
+ // radial-gradient
90
+ const cx = fill.center?.x ?? 0.5;
91
+ const cy = fill.center?.y ?? 0.5;
92
+ const r = fill.radius ?? 0.5;
93
+ const defs = [
94
+ <radialGradient key={id} id={id} cx={`${cx * 100}%`} cy={`${cy * 100}%`} r={`${r * 100}%`}>
95
+ {fill.stops.map((s, i) => (
96
+ <stop
97
+ key={i}
98
+ offset={s.offset}
99
+ stopColor={s.color}
100
+ {...(s.opacity !== undefined ? { stopOpacity: s.opacity } : {})}
101
+ />
102
+ ))}
103
+ </radialGradient>,
104
+ ];
105
+ return { defs, ref: `url(#${id})` };
106
+ }
107
+
108
+ /** Compile an array of Fill into a CSS `background-image` value usable
109
+ * on a `<div>` (frame backgrounds — non-SVG context). Returns the CSS
110
+ * string + opacity. Stops use percentages in CSS gradient syntax. */
111
+ export function backgroundsToCss(fills: Fill[]): CSSProperties {
112
+ // Per §4.12, fills[0] renders on top — CSS background-image stacks
113
+ // first → top-most. Match by passing in the same order.
114
+ const layers = fills.map(fillToCss).filter(Boolean) as string[];
115
+ if (layers.length === 0) return {};
116
+ return { backgroundImage: layers.join(", ") };
117
+ }
118
+
119
+ function fillToCss(fill: Fill): string | null {
120
+ if (fill.kind === "solid") {
121
+ // Wrap solid in linear-gradient so it can stack with other layers.
122
+ return `linear-gradient(${fill.color}, ${fill.color})`;
123
+ }
124
+ const stops = fill.stops
125
+ .map((s) => {
126
+ const c = s.opacity !== undefined ? cssWithOpacity(s.color, s.opacity) : s.color;
127
+ return `${c} ${(s.offset * 100).toFixed(2)}%`;
128
+ })
129
+ .join(", ");
130
+ if (fill.kind === "linear-gradient") {
131
+ const angle = fill.angle_deg ?? 0;
132
+ return `linear-gradient(${angle}deg, ${stops})`;
133
+ }
134
+ // radial-gradient
135
+ const cx = (fill.center?.x ?? 0.5) * 100;
136
+ const cy = (fill.center?.y ?? 0.5) * 100;
137
+ return `radial-gradient(circle at ${cx}% ${cy}%, ${stops})`;
138
+ }
139
+
140
+ function cssWithOpacity(color: string, opacity: number): string {
141
+ // Best-effort wrapper — for hex/rgb we can append alpha. For
142
+ // unrecognised forms, fall back to color-mix.
143
+ const hex = color.match(/^#([0-9a-f]{6})$/i);
144
+ if (hex) {
145
+ const a = Math.round(opacity * 255)
146
+ .toString(16)
147
+ .padStart(2, "0");
148
+ return `#${hex[1]}${a}`;
149
+ }
150
+ return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`;
151
+ }
152
+
153
+ /** Coerce loose JSON into a Fill array. Returns [] for non-arrays. */
154
+ export function parseFills(value: unknown): Fill[] {
155
+ if (!Array.isArray(value)) return [];
156
+ return value.filter(isFill) as Fill[];
157
+ }
158
+
159
+ function isFill(v: unknown): v is Fill {
160
+ if (typeof v !== "object" || v === null) return false;
161
+ const k = (v as { kind?: unknown }).kind;
162
+ return k === "solid" || k === "linear-gradient" || k === "radial-gradient";
163
+ }
@@ -0,0 +1,89 @@
1
+ // LSML 1.1 §6.6 — keyframe sequence playback wrapper.
2
+ //
3
+ // Wraps a primitive subtree in a framer-motion `motion.div` that plays
4
+ // out the compiled keyframe arrays once on (re)mount, or whenever the
5
+ // bound `key` LeafPath changes. We trigger replay via React's `key=`
6
+ // reconciliation — bumping a counter when the keyframe key value flips
7
+ // remounts the motion subtree, restarting the animation from `at: 0`.
8
+ //
9
+ // LSML 1.1 §6.7 — when this player runs inside a `repeat` iteration, a
10
+ // `staggerDelay` (ms) is provided through `StaggerContext` and added to
11
+ // framer's transition.delay so each iteration starts `index * stagger_ms`
12
+ // after the previous one.
13
+
14
+ import { motion } from "framer-motion";
15
+ import { useContext, useEffect, useRef, type ReactNode } from "react";
16
+ import { useSignals } from "@preact/signals-react/runtime";
17
+ import type { Store } from "../state/store";
18
+ import { compileForFramer, type Keyframes } from "../animate/keyframes";
19
+ import { StaggerContext } from "./stagger-context";
20
+ import { scopedPath, usePathScope } from "./scope";
21
+
22
+ export interface KeyframePlayerProps {
23
+ keyframes: Keyframes;
24
+ store: Store;
25
+ children: ReactNode;
26
+ }
27
+
28
+ export function KeyframePlayer({ keyframes, store, children }: KeyframePlayerProps): ReactNode {
29
+ useSignals();
30
+ const scope = usePathScope();
31
+ const staggerDelayMs = useContext(StaggerContext);
32
+
33
+ // Pull the latest `key` LeafPath value and remount whenever it
34
+ // changes. We track via a ref + counter so React's reconciliation
35
+ // gives us a fresh motion.div (and thus a fresh animation pass).
36
+ const lastKeyValue = useRef<unknown>(undefined);
37
+ const replayTokenRef = useRef(0);
38
+ if (keyframes.key !== undefined) {
39
+ const v = store.signal(scopedPath(scope, keyframes.key)).value;
40
+ if (lastKeyValue.current !== v) {
41
+ lastKeyValue.current = v;
42
+ replayTokenRef.current += 1;
43
+ }
44
+ }
45
+
46
+ const compiled = compileForFramer(keyframes);
47
+ if (!compiled) {
48
+ return <>{children}</>;
49
+ }
50
+
51
+ const transition =
52
+ staggerDelayMs > 0
53
+ ? { ...compiled.transition, delay: staggerDelayMs / 1000 }
54
+ : compiled.transition;
55
+
56
+ return (
57
+ <motion.div
58
+ key={replayTokenRef.current}
59
+ style={{ display: "contents" }}
60
+ initial={firstFrame(compiled.animate)}
61
+ animate={compiled.animate}
62
+ transition={transition}
63
+ >
64
+ <ReplayOnMount />
65
+ {children}
66
+ </motion.div>
67
+ );
68
+ }
69
+
70
+ /** No-op effect placeholder — kept for symmetry / future hooks like
71
+ * reporting playback completion to the renderer. */
72
+ function ReplayOnMount(): null {
73
+ useEffect(() => {
74
+ // intentional no-op
75
+ }, []);
76
+ return null;
77
+ }
78
+
79
+ /** Pluck the `at: 0` waypoint values into a framer-motion `initial` prop
80
+ * so the very first frame matches the start of the keyframe path. Without
81
+ * this, framer interpolates from the element's current style which can
82
+ * produce a visible jump on mount. */
83
+ function firstFrame(animate: Record<string, (number | string)[]>): Record<string, number | string> {
84
+ const out: Record<string, number | string> = {};
85
+ for (const [k, arr] of Object.entries(animate)) {
86
+ if (arr.length > 0) out[k] = arr[0];
87
+ }
88
+ return out;
89
+ }
@@ -0,0 +1,78 @@
1
+ import { motion } from "framer-motion";
2
+ import type { CSSProperties } from "react";
3
+ import type { PrimitiveProps } from "./index";
4
+ import { toFramer } from "../../animate/transitions";
5
+ import { backgroundsToCss, parseFills } from "../fill";
6
+
7
+ /** Absolute-positioned container with size + transform + opacity.
8
+ * Animatable on `transform` and `opacity` only — width/height/position
9
+ * changes are intentionally *not* animatable to keep the broadcast
10
+ * off the layout path.
11
+ *
12
+ * LSML 1.1 §4.3 + §4.12 add `backgrounds[]` as an alternative to the
13
+ * legacy `background` (single color). The array form supports stacked
14
+ * fills with linear / radial gradients ; first entry renders on top.
15
+ */
16
+ export function Frame({ resolved, transitionFor, children }: PrimitiveProps) {
17
+ const x = numberOr(resolved.x, 0);
18
+ const y = numberOr(resolved.y, 0);
19
+ const width = sizeProp(resolved.width);
20
+ const height = sizeProp(resolved.height);
21
+ const opacity = numberOr(resolved.opacity, 1);
22
+ const scale = numberOr(resolved.scale, 1);
23
+ const rotate = numberOr(resolved.rotate, 0);
24
+
25
+ // 1.0 single-fill prop — used as fallback when 1.1 `backgrounds[]`
26
+ // is empty.
27
+ const legacyBackground = (resolved.background as string | undefined) ?? undefined;
28
+ const backgrounds = parseFills(resolved.backgrounds);
29
+
30
+ // Pick the most expressive declared transition among the animated
31
+ // bindings (transform / opacity). If none, no animation.
32
+ const tx =
33
+ transitionFor("opacity") ??
34
+ transitionFor("scale") ??
35
+ transitionFor("rotate") ??
36
+ transitionFor("x") ??
37
+ transitionFor("y");
38
+
39
+ const style: CSSProperties = {
40
+ position: "absolute",
41
+ left: 0,
42
+ top: 0,
43
+ width,
44
+ height,
45
+ willChange: "transform, opacity",
46
+ };
47
+ if (backgrounds.length > 0) {
48
+ Object.assign(style, backgroundsToCss(backgrounds));
49
+ } else if (legacyBackground !== undefined) {
50
+ style.background = legacyBackground;
51
+ }
52
+
53
+ return (
54
+ <motion.div
55
+ style={style}
56
+ animate={{
57
+ opacity,
58
+ x,
59
+ y,
60
+ scale,
61
+ rotate,
62
+ }}
63
+ transition={toFramer(tx)}
64
+ >
65
+ {children}
66
+ </motion.div>
67
+ );
68
+ }
69
+
70
+ function numberOr(v: unknown, fallback: number): number {
71
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
72
+ }
73
+
74
+ function sizeProp(v: unknown): number | string | undefined {
75
+ if (typeof v === "number" && Number.isFinite(v)) return v;
76
+ if (typeof v === "string" && v.length > 0) return v;
77
+ return undefined;
78
+ }
@@ -0,0 +1,20 @@
1
+ import type { PrimitiveProps } from "./index";
2
+
3
+ /** CSS Grid container with declared rows / cols. */
4
+ export function Grid({ resolved, children }: PrimitiveProps) {
5
+ const cols = (resolved.cols as string) ?? "1fr";
6
+ const rows = (resolved.rows as string) ?? "auto";
7
+ const gap = (resolved.gap as number | string | undefined) ?? 0;
8
+ return (
9
+ <div
10
+ style={{
11
+ display: "grid",
12
+ gridTemplateColumns: cols,
13
+ gridTemplateRows: rows,
14
+ gap,
15
+ }}
16
+ >
17
+ {children}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,35 @@
1
+ import { motion } from "framer-motion";
2
+ import type { PrimitiveProps } from "./index";
3
+ import { toFramer } from "../../animate/transitions";
4
+
5
+ /** Image leaf. `src`, `fit` (cover/contain/fill), `position`,
6
+ * `opacity`. Opacity is animated when a transition is declared. */
7
+ export function Image({ resolved, transitionFor }: PrimitiveProps) {
8
+ const src = resolved.src as string | undefined;
9
+ if (!src) return null;
10
+ const fit = (resolved.fit as string | undefined) ?? "contain";
11
+ const position = (resolved.position as string | undefined) ?? "center";
12
+ const opacity = numberOr(resolved.opacity, 1);
13
+
14
+ const tx = transitionFor("opacity") ?? transitionFor("src");
15
+
16
+ return (
17
+ <motion.img
18
+ src={src}
19
+ style={{
20
+ objectFit: fit as React.CSSProperties["objectFit"],
21
+ objectPosition: position,
22
+ width: "100%",
23
+ height: "100%",
24
+ willChange: "opacity",
25
+ }}
26
+ animate={{ opacity }}
27
+ transition={toFramer(tx)}
28
+ draggable={false}
29
+ />
30
+ );
31
+ }
32
+
33
+ function numberOr(v: unknown, fallback: number): number {
34
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
35
+ }
@@ -0,0 +1,35 @@
1
+ // Primitive component registry. Tree dispatch uses this map to look
2
+ // up the React component for each `kind` ; user components are inlined
3
+ // at compile time so Lumencast's runtime never sees them.
4
+
5
+ import type { ComponentType, ReactNode } from "react";
6
+ import type { RenderKind } from "../bundle";
7
+ import type { Transition } from "../../animate/transitions";
8
+ import { Stack } from "./stack";
9
+ import { Grid } from "./grid";
10
+ import { Frame } from "./frame";
11
+ import { Text } from "./text";
12
+ import { Image } from "./image";
13
+ import { Shape } from "./shape";
14
+ import { Media } from "./media";
15
+ import { Instance } from "./instance";
16
+ // `repeat` is dispatched specially in the tree (it iterates a bound
17
+ // array and provides a path scope to its children) ; it does not
18
+ // appear here as a regular primitive.
19
+
20
+ export interface PrimitiveProps {
21
+ resolved: Record<string, unknown>;
22
+ transitionFor: (key: string) => Transition | undefined;
23
+ children?: ReactNode;
24
+ }
25
+
26
+ export const PRIMITIVES: Partial<Record<RenderKind, ComponentType<PrimitiveProps>>> = {
27
+ stack: Stack,
28
+ grid: Grid,
29
+ frame: Frame,
30
+ text: Text,
31
+ image: Image,
32
+ shape: Shape,
33
+ media: Media,
34
+ instance: Instance,
35
+ };