@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,38 @@
1
+ import type { ErrorCode } from "@lumencast/protocol";
2
+ export type LumencastMode = "broadcast" | "control" | "test";
3
+ export type LumencastStatus = "disconnected" | "connecting" | "live";
4
+ export interface LumencastTokenProvider {
5
+ fetch: () => Promise<string>;
6
+ }
7
+ export type LumencastToken = string | LumencastTokenProvider;
8
+ export interface LumencastError {
9
+ code: ErrorCode;
10
+ message: string;
11
+ recoverable: boolean;
12
+ }
13
+ export interface LumencastMetric {
14
+ name: "delta_received" | "delta_applied" | "frame_dropped" | "reconnect" | "snapshot_received" | "scene_changed";
15
+ [key: string]: unknown;
16
+ }
17
+ export interface MountOptions {
18
+ target: HTMLElement;
19
+ /** WebSocket URL of the LSDP/1 server (wss://... in production). */
20
+ serverUrl: string;
21
+ token: LumencastToken;
22
+ mode: LumencastMode;
23
+ /** Required when mode === "test". */
24
+ testSession?: string;
25
+ /** Required when mode === "test". */
26
+ scene?: string;
27
+ onStatus?: (status: LumencastStatus) => void;
28
+ onError?: (err: LumencastError) => void;
29
+ onMetric?: (metric: LumencastMetric) => void;
30
+ }
31
+ export interface LumencastHandle {
32
+ /** Tear down the WS, unmount the React tree, release timers. Idempotent. */
33
+ disconnect: () => void;
34
+ /** Swap the auth token without unmounting the React tree. */
35
+ setToken: (token: LumencastToken) => void;
36
+ }
37
+ export type { ErrorCode };
38
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,CAAC;AAE7D,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,YAAY,GAAG,MAAM,CAAC;AAErE,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,sBAAsB,CAAC;AAE7D,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EACA,gBAAgB,GAChB,eAAe,GACf,eAAe,GACf,WAAW,GACX,mBAAmB,GACnB,eAAe,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,WAAW,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,cAAc,CAAC;IACtB,IAAI,EAAE,aAAa,CAAC;IACpB,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,IAAI,CAAC;IAC7C,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,IAAI,CAAC;CAC9C;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC3C;AAED,YAAY,EAAE,SAAS,EAAE,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Public types of @lumencast/runtime — must align with RUNTIME-API.md.
2
+ export {};
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,uEAAuE"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@lumencast/runtime",
3
+ "version": "0.1.0",
4
+ "description": "Browser runtime for Lumencast — mount(), LSDP/1 transport, leaf-grain store, LSML render, animations, overlays.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/lumencast.js",
8
+ "module": "./dist/lumencast.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/lumencast.js"
14
+ },
15
+ "./style.css": "./dist/lumencast.css"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "homepage": "https://github.com/Lumencast/lumencast-js/tree/main/packages/runtime",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Lumencast/lumencast-js.git",
25
+ "directory": "packages/runtime"
26
+ },
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "dependencies": {
35
+ "@preact/signals-react": "^3.2.1",
36
+ "framer-motion": "^12.0.0",
37
+ "react": "^19.0.0",
38
+ "react-dom": "^19.0.0",
39
+ "@lumencast/protocol": "0.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@playwright/test": "^1.49.1",
43
+ "@types/react": "^19.0.10",
44
+ "@types/react-dom": "^19.0.4",
45
+ "@types/ws": "^8.5.13",
46
+ "@vitejs/plugin-react": "^4.3.4",
47
+ "happy-dom": "^20.9.0",
48
+ "vite": "^6.1.0",
49
+ "vite-plugin-dts": "^4.5.0",
50
+ "vitest": "^4.1.5",
51
+ "ws": "^8.18.0",
52
+ "@lumencast/dev-server": "0.1.0",
53
+ "@lumencast/server": "0.1.0"
54
+ },
55
+ "scripts": {
56
+ "dev": "vite",
57
+ "build": "vite build && node scripts/build-host-html.mjs",
58
+ "typecheck": "tsc -b --pretty",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "test:e2e": "playwright test",
62
+ "check:bundle": "node scripts/check-bundle-size.mjs"
63
+ }
64
+ }
@@ -0,0 +1,31 @@
1
+ import { type ReactNode } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+
4
+ export interface CrossfadeProps {
5
+ /** Scene id or any stable key — children remount on key change. */
6
+ trackKey: string;
7
+ /** Duration in milliseconds. */
8
+ durationMs?: number;
9
+ children: ReactNode;
10
+ }
11
+
12
+ /** Crossfade two scene roots at key change. Both children are mounted
13
+ * during the transition window, one fading out as the other fades in.
14
+ * Animates opacity only (GPU-friendly). */
15
+ export function Crossfade({ trackKey, durationMs = 400, children }: CrossfadeProps) {
16
+ const transition = { duration: durationMs / 1000, ease: "easeInOut" } as const;
17
+ return (
18
+ <AnimatePresence mode="sync">
19
+ <motion.div
20
+ key={trackKey}
21
+ initial={{ opacity: 0 }}
22
+ animate={{ opacity: 1 }}
23
+ exit={{ opacity: 0 }}
24
+ transition={transition}
25
+ style={{ position: "absolute", inset: 0 }}
26
+ >
27
+ {children}
28
+ </motion.div>
29
+ </AnimatePresence>
30
+ );
31
+ }
@@ -0,0 +1,142 @@
1
+ // LSML 1.1 §6.6 — keyframe sequence playback.
2
+ //
3
+ // A primitive's `keyframes` block describes a path through animatable
4
+ // property values over time, applied once on (re)mount or whenever the
5
+ // `key` LeafPath value changes. The shapes here mirror the spec verbatim
6
+ // ; `compileForFramer` flattens them into the per-property arrays
7
+ // (`scale: [0.8, 1.05, 1]`) plus `times: [0, 0.6, 1]` that framer-motion
8
+ // expects on its `animate` / `transition` props.
9
+
10
+ export type KeyframeEasing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
11
+
12
+ export interface KeyframeStep {
13
+ /** Timeline position in [0, 1]. First step is 0 ; last step is 1. */
14
+ at: number;
15
+ /** Optional transform target at this waypoint. */
16
+ transform?: KeyframeTransform;
17
+ /** Optional opacity in [0, 1]. */
18
+ opacity?: number;
19
+ /** Optional CSS filter string. */
20
+ filter?: string;
21
+ }
22
+
23
+ export interface KeyframeTransform {
24
+ scale?: number;
25
+ translateX?: number;
26
+ translateY?: number;
27
+ rotate?: number;
28
+ }
29
+
30
+ export interface Keyframes {
31
+ /** LeafPath whose value-change replays the sequence. Omitted = mount-only. */
32
+ key?: string;
33
+ steps: KeyframeStep[];
34
+ duration_ms: number;
35
+ easing?: KeyframeEasing;
36
+ }
37
+
38
+ const FRAMER_EASE_MAP: Record<KeyframeEasing, "linear" | "easeIn" | "easeOut" | "easeInOut"> = {
39
+ linear: "linear",
40
+ "ease-in": "easeIn",
41
+ "ease-out": "easeOut",
42
+ "ease-in-out": "easeInOut",
43
+ };
44
+
45
+ export interface CompiledKeyframes {
46
+ /** Per-CSS-property animate target (array of values, one per step). */
47
+ animate: Record<string, (number | string)[]>;
48
+ /** Framer transition config — duration in seconds, ease curve, times[]. */
49
+ transition: {
50
+ duration: number;
51
+ ease: "linear" | "easeIn" | "easeOut" | "easeInOut";
52
+ times: number[];
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Flatten a 1.1 keyframe sequence into the per-property arrays + times[]
58
+ * shape framer-motion expects. Returns `undefined` when `steps` is empty
59
+ * or invariants are violated (first.at !== 0 or last.at !== 1) — the
60
+ * caller then falls back to no animation.
61
+ */
62
+ export function compileForFramer(kf: Keyframes): CompiledKeyframes | undefined {
63
+ const steps = kf.steps;
64
+ if (!Array.isArray(steps) || steps.length < 2) return undefined;
65
+ const first = steps[0];
66
+ const last = steps[steps.length - 1];
67
+ if (first.at !== 0 || last.at !== 1) return undefined;
68
+
69
+ const times = steps.map((s) => s.at);
70
+ const animate: Record<string, (number | string)[]> = {};
71
+
72
+ // For each animatable property, pull the value at every step. When a
73
+ // step omits the property, we fall back to the previous step's value
74
+ // (last-known-good) so framer-motion sees a coherent waypoint chain.
75
+ pullChannel(steps, "opacity", animate);
76
+ pullChannel(steps, "filter", animate);
77
+ pullTransform(steps, "scale", animate);
78
+ pullTransform(steps, "translateX", animate);
79
+ pullTransform(steps, "translateY", animate);
80
+ pullTransform(steps, "rotate", animate);
81
+
82
+ return {
83
+ animate,
84
+ transition: {
85
+ duration: kf.duration_ms / 1000,
86
+ ease: FRAMER_EASE_MAP[kf.easing ?? "linear"],
87
+ times,
88
+ },
89
+ };
90
+ }
91
+
92
+ function pullChannel(
93
+ steps: KeyframeStep[],
94
+ prop: "opacity" | "filter",
95
+ out: Record<string, (number | string)[]>,
96
+ ): void {
97
+ let any = false;
98
+ const values: (number | string)[] = [];
99
+ let last: number | string | undefined;
100
+ for (const s of steps) {
101
+ const v = s[prop];
102
+ if (v !== undefined) {
103
+ any = true;
104
+ last = v;
105
+ values.push(v);
106
+ } else {
107
+ values.push(last ?? (prop === "opacity" ? 1 : "none"));
108
+ }
109
+ }
110
+ if (any) out[prop] = values;
111
+ }
112
+
113
+ function pullTransform(
114
+ steps: KeyframeStep[],
115
+ prop: keyof KeyframeTransform,
116
+ out: Record<string, (number | string)[]>,
117
+ ): void {
118
+ let any = false;
119
+ const values: number[] = [];
120
+ let last: number | undefined;
121
+ for (const s of steps) {
122
+ const v = s.transform?.[prop];
123
+ if (typeof v === "number") {
124
+ any = true;
125
+ last = v;
126
+ values.push(v);
127
+ } else {
128
+ values.push(last ?? defaultFor(prop));
129
+ }
130
+ }
131
+ if (any) {
132
+ if (prop === "rotate") {
133
+ out.rotate = values.map((n) => `${n}deg`);
134
+ } else {
135
+ out[prop] = values;
136
+ }
137
+ }
138
+ }
139
+
140
+ function defaultFor(prop: keyof KeyframeTransform): number {
141
+ return prop === "scale" ? 1 : 0;
142
+ }
@@ -0,0 +1,116 @@
1
+ // Local Transition type + Framer Motion translation.
2
+ //
3
+ // LSML 1.0 §6 declares `animate` directives at the primitive level (transition,
4
+ // transform, opacity, filter). LSDP/1.1 §3.2.2 added per-leaf transition
5
+ // directives on delta patches — incoming deltas can carry a transition hint
6
+ // that overrides the bundle-level default for the next animation cycle.
7
+ // `parseWireTransition` ingests the wire shape ; `Store.lastTransition(path)`
8
+ // surfaces the most-recent directive to the renderer.
9
+ //
10
+ // We deliberately animate only GPU-friendly properties (transform, opacity,
11
+ // filter). Primitives enforce this at the DOM level by exposing those props as
12
+ // motion-bindable values rather than raw CSS.
13
+
14
+ export type TransitionKind = "none" | "tween" | "spring" | "crossfade";
15
+
16
+ export interface TweenTransition {
17
+ kind: "tween";
18
+ duration_ms: number;
19
+ ease?: "linear" | "cubic-in" | "cubic-out" | "cubic-in-out";
20
+ }
21
+
22
+ export interface SpringTransition {
23
+ kind: "spring";
24
+ stiffness?: number;
25
+ damping?: number;
26
+ }
27
+
28
+ export interface CrossfadeTransition {
29
+ kind: "crossfade";
30
+ duration_ms?: number;
31
+ }
32
+
33
+ export interface NoTransition {
34
+ kind: "none";
35
+ }
36
+
37
+ export type Transition = NoTransition | TweenTransition | SpringTransition | CrossfadeTransition;
38
+
39
+ export type FramerEasing = "linear" | "easeIn" | "easeOut" | "easeInOut";
40
+
41
+ export interface FramerTransition {
42
+ duration?: number;
43
+ ease?: FramerEasing;
44
+ type?: "tween" | "spring";
45
+ stiffness?: number;
46
+ damping?: number;
47
+ }
48
+
49
+ const NO_ANIMATION: FramerTransition = { duration: 0 };
50
+
51
+ const EASE_MAP: Record<string, FramerEasing> = {
52
+ linear: "linear",
53
+ "cubic-in": "easeIn",
54
+ "cubic-out": "easeOut",
55
+ "cubic-in-out": "easeInOut",
56
+ };
57
+
58
+ export function toFramer(t: Transition | undefined): FramerTransition {
59
+ if (!t || t.kind === "none") return NO_ANIMATION;
60
+ if (t.kind === "tween") {
61
+ return {
62
+ type: "tween",
63
+ duration: (t.duration_ms ?? 0) / 1000,
64
+ ease: t.ease ? (EASE_MAP[t.ease] ?? "easeOut") : "easeOut",
65
+ };
66
+ }
67
+ if (t.kind === "spring") {
68
+ return {
69
+ type: "spring",
70
+ ...(t.stiffness !== undefined ? { stiffness: t.stiffness } : {}),
71
+ ...(t.damping !== undefined ? { damping: t.damping } : {}),
72
+ };
73
+ }
74
+ // crossfade at the per-prop level degenerates into a tween on opacity.
75
+ return {
76
+ type: "tween",
77
+ duration: (t.duration_ms ?? 400) / 1000,
78
+ ease: "easeInOut",
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Parse a wire-format `TransitionSpec` (LSDP/1.1 §3.2.2) into the
84
+ * runtime's local Transition type. Returns `undefined` for malformed
85
+ * input so the caller falls back to whatever bundle-level default
86
+ * applies. The wire shape uses kebab-case `easing` values
87
+ * (`linear`, `ease-in`, `ease-out`, `ease-in-out`) which we map to
88
+ * the runtime's `cubic-*` vocabulary.
89
+ */
90
+ export function parseWireTransition(value: unknown): Transition | undefined {
91
+ if (typeof value !== "object" || value === null) return undefined;
92
+ const v = value as Record<string, unknown>;
93
+ const kind = v.kind;
94
+ if (kind === "snap") {
95
+ return { kind: "none" };
96
+ }
97
+ if (kind === "tween") {
98
+ const duration_ms = typeof v.duration_ms === "number" ? v.duration_ms : 0;
99
+ const easing = WIRE_EASING_MAP[v.easing as string] ?? "cubic-out";
100
+ return { kind: "tween", duration_ms, ease: easing };
101
+ }
102
+ if (kind === "spring") {
103
+ const out: SpringTransition = { kind: "spring" };
104
+ if (typeof v.stiffness === "number") out.stiffness = v.stiffness;
105
+ if (typeof v.damping === "number") out.damping = v.damping;
106
+ return out;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ const WIRE_EASING_MAP: Record<string, "linear" | "cubic-in" | "cubic-out" | "cubic-in-out"> = {
112
+ linear: "linear",
113
+ "ease-in": "cubic-in",
114
+ "ease-out": "cubic-out",
115
+ "ease-in-out": "cubic-in-out",
116
+ };
package/src/app.tsx ADDED
@@ -0,0 +1,84 @@
1
+ // Top-level React component for a mounted Lumencast instance. Reads the runtime
2
+ // signals (bundle / status) and dispatches to the right mode.
3
+ //
4
+ // Per-mode code splitting: BroadcastMode / ControlMode / TestMode live in
5
+ // separate chunks loaded only when the corresponding mode is requested. A
6
+ // broadcast mount never downloads the overlay or test code — the broadcast
7
+ // chunk is the bare minimum a CEF host needs to render the scene.
8
+ //
9
+ // Crossfade: AnimatePresence freezes the props of an exiting child so its render
10
+ // tree keeps using the values it held at the moment it started exiting.
11
+
12
+ import { useSignals } from "@preact/signals-react/runtime";
13
+ import type { Signal } from "@preact/signals-react";
14
+ import { AnimatePresence, motion } from "framer-motion";
15
+ import { lazy, Suspense } from "react";
16
+ import type { Patch } from "@lumencast/protocol";
17
+ import type { Store } from "./state/store.js";
18
+ import type { RenderBundle } from "./render/bundle.js";
19
+ import type { ConnectionStatus } from "./transport/ws.js";
20
+ import { LumencastRuntimeProvider } from "./overlay/runtime-context.js";
21
+ import type { LumencastMode } from "./types.js";
22
+
23
+ const LazyBroadcastMode = lazy(() =>
24
+ import("./modes/broadcast.js").then((m) => ({ default: m.BroadcastMode })),
25
+ );
26
+ const LazyControlMode = lazy(() =>
27
+ import("./modes/control.js").then((m) => ({ default: m.ControlMode })),
28
+ );
29
+ const LazyTestMode = lazy(() => import("./modes/test.js").then((m) => ({ default: m.TestMode })));
30
+
31
+ export interface LumencastAppProps {
32
+ mode: LumencastMode;
33
+ store: Store;
34
+ bundleSignal: Signal<RenderBundle | null>;
35
+ statusSignal: Signal<ConnectionStatus>;
36
+ crossfadeKeySignal: Signal<string>;
37
+ sendInput: (patches: Patch[]) => void;
38
+ }
39
+
40
+ export function LumencastApp({
41
+ mode,
42
+ store,
43
+ bundleSignal,
44
+ statusSignal,
45
+ crossfadeKeySignal,
46
+ sendInput,
47
+ }: LumencastAppProps) {
48
+ useSignals();
49
+
50
+ const bundle = bundleSignal.value;
51
+ const status = statusSignal.value;
52
+ const trackKey = crossfadeKeySignal.value;
53
+ if (!bundle) return null;
54
+
55
+ const ModeComponent =
56
+ mode === "broadcast" ? LazyBroadcastMode : mode === "control" ? LazyControlMode : LazyTestMode;
57
+
58
+ return (
59
+ <AnimatePresence mode="sync">
60
+ <motion.div
61
+ key={trackKey}
62
+ initial={{ opacity: 0 }}
63
+ animate={{ opacity: 1 }}
64
+ exit={{ opacity: 0 }}
65
+ transition={{ duration: 0.4, ease: "easeInOut" }}
66
+ style={{ position: "absolute", inset: 0 }}
67
+ >
68
+ <LumencastRuntimeProvider
69
+ value={{
70
+ mode,
71
+ store,
72
+ bundle,
73
+ status,
74
+ sendInput,
75
+ }}
76
+ >
77
+ <Suspense fallback={null}>
78
+ <ModeComponent />
79
+ </Suspense>
80
+ </LumencastRuntimeProvider>
81
+ </motion.div>
82
+ </AnimatePresence>
83
+ );
84
+ }
@@ -0,0 +1,38 @@
1
+ // Dev entry point — only used during `vite dev` and Playwright E2E tests.
2
+ // Reads URL query params, calls mount(). Mirrors the production
3
+ // build-host-html.mjs behavior so tests exercise a real bootstrap.
4
+
5
+ import { mount } from "./index.js";
6
+ import type { LumencastMode } from "./types.js";
7
+
8
+ const params = new URLSearchParams(window.location.search);
9
+ const serverUrl = params.get("server") ?? `ws://${location.host}/lsdp/v1`;
10
+ const token = params.get("token") ?? "any";
11
+ const modeParam = params.get("mode") ?? "broadcast";
12
+ const mode: LumencastMode = (["broadcast", "control", "test"] as const).includes(
13
+ modeParam as LumencastMode,
14
+ )
15
+ ? (modeParam as LumencastMode)
16
+ : "broadcast";
17
+ const scene = params.get("scene") ?? undefined;
18
+ const testSession = params.get("session") ?? undefined;
19
+
20
+ const target = document.getElementById("scene");
21
+ if (!(target instanceof HTMLElement)) {
22
+ document.body.textContent = "lumencast dev: #scene target missing";
23
+ throw new Error("dev-entry: #scene missing");
24
+ }
25
+
26
+ const handle = mount({
27
+ target,
28
+ serverUrl,
29
+ token,
30
+ mode,
31
+ ...(mode === "test" && scene ? { scene } : {}),
32
+ ...(mode === "test" && testSession ? { testSession } : {}),
33
+ onError: (err) => console.error("[lumencast]", err),
34
+ onStatus: (status) => target.setAttribute("data-status", status),
35
+ });
36
+
37
+ // Expose the handle to Playwright tests for setToken/disconnect drills.
38
+ (window as unknown as { __lumencast: typeof handle }).__lumencast = handle;
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Public surface of @lumencast/runtime.
2
+
3
+ export { mount } from "./mount.js";
4
+ export type {
5
+ MountOptions,
6
+ LumencastHandle,
7
+ LumencastMode,
8
+ LumencastStatus,
9
+ LumencastToken,
10
+ LumencastTokenProvider,
11
+ LumencastError,
12
+ LumencastMetric,
13
+ ErrorCode,
14
+ } from "./types.js";
15
+
16
+ // Bundle types are useful for hosts that want to typecheck pre-compiled scenes.
17
+ export type {
18
+ RenderBundle,
19
+ RenderNode,
20
+ RenderKind,
21
+ OperatorInput,
22
+ ExternalAdapter,
23
+ Asset,
24
+ } from "./render/bundle.js";
@@ -0,0 +1,20 @@
1
+ import type { MountOptions } from "../types";
2
+
3
+ /** Throws on invalid mount options. Exposed separately so unit tests
4
+ * can exercise it without mounting a real React root. */
5
+ export function validateOptions(options: MountOptions): void {
6
+ if (!(options.target instanceof HTMLElement)) {
7
+ throw new TypeError("mount: `target` must be an HTMLElement");
8
+ }
9
+ if (typeof options.serverUrl !== "string" || options.serverUrl.length === 0) {
10
+ throw new TypeError("mount: `serverUrl` must be a non-empty string");
11
+ }
12
+ if (options.mode === "test") {
13
+ if (!options.testSession) {
14
+ throw new TypeError("mount: `testSession` is required when mode === 'test'");
15
+ }
16
+ if (!options.scene) {
17
+ throw new TypeError("mount: `scene` is required when mode === 'test'");
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,8 @@
1
+ import { Tree } from "../render/tree";
2
+ import { useLumencastRuntime } from "../overlay/runtime-context";
3
+
4
+ /** Broadcast mode : pure scene render, no UI chrome. */
5
+ export function BroadcastMode() {
6
+ const { store, bundle } = useLumencastRuntime();
7
+ return <Tree node={bundle.root} store={store} />;
8
+ }
@@ -0,0 +1,17 @@
1
+ import { Tree } from "../render/tree";
2
+ import { ControlPanel } from "../overlay/control";
3
+ import { StatusPill } from "../overlay/status-pill";
4
+ import { useLumencastRuntime } from "../overlay/runtime-context";
5
+
6
+ /** Control mode : scene + operator overlay (status pill + fields
7
+ * panel from operator_inputs). */
8
+ export function ControlMode() {
9
+ const { store, bundle } = useLumencastRuntime();
10
+ return (
11
+ <>
12
+ <Tree node={bundle.root} store={store} />
13
+ <StatusPill />
14
+ <ControlPanel />
15
+ </>
16
+ );
17
+ }
@@ -0,0 +1,19 @@
1
+ import { Tree } from "../render/tree";
2
+ import { ControlPanel } from "../overlay/control";
3
+ import { TestPanel } from "../overlay/test";
4
+ import { StatusPill } from "../overlay/status-pill";
5
+ import { useLumencastRuntime } from "../overlay/runtime-context";
6
+
7
+ /** Test mode : scene + operator overlay + test extensions (adapter
8
+ * mocker, state inspector, time controls). */
9
+ export function TestMode() {
10
+ const { store, bundle } = useLumencastRuntime();
11
+ return (
12
+ <>
13
+ <Tree node={bundle.root} store={store} />
14
+ <StatusPill />
15
+ <ControlPanel />
16
+ <TestPanel />
17
+ </>
18
+ );
19
+ }