@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
package/src/mount.ts ADDED
@@ -0,0 +1,169 @@
1
+ // Public mount() entry — the only surface a host (browser, CEF, OBS plugin,
2
+ // iframe) interacts with. Lifecycle and contract: see RUNTIME-API.md.
3
+
4
+ import { signal } from "@preact/signals-react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { createElement } from "react";
7
+ import { LumencastApp } from "./app.js";
8
+ import { applyDelta } from "./state/apply-delta.js";
9
+ import { applySnapshot } from "./state/apply-snapshot.js";
10
+ import { createStore } from "./state/store.js";
11
+ import { createBundleFetcher, type BundleFetcher, type RenderBundle } from "./render/bundle.js";
12
+ import { WsClient, type ConnectionStatus, type TransportError } from "./transport/ws.js";
13
+ import { validateOptions } from "./internal/validate-options.js";
14
+ import type { LumencastError, LumencastHandle, LumencastToken, MountOptions } from "./types.js";
15
+
16
+ export function mount(options: MountOptions): LumencastHandle {
17
+ validateOptions(options);
18
+ options.onStatus?.("disconnected");
19
+
20
+ const store = createStore();
21
+ const baseUrl = deriveBaseUrl(options.serverUrl);
22
+ const bundleFetcher = createBundleFetcher({ baseUrl });
23
+
24
+ const bundleSignal = signal<RenderBundle | null>(null);
25
+ const statusSignal = signal<ConnectionStatus>("disconnected");
26
+ const crossfadeKeySignal = signal<string>("__initial__");
27
+
28
+ const setStatus = (status: ConnectionStatus): void => {
29
+ statusSignal.value = status;
30
+ options.onStatus?.(status);
31
+ };
32
+
33
+ const reportError = (err: LumencastError): void => {
34
+ options.onError?.(err);
35
+ };
36
+
37
+ let active = true;
38
+
39
+ const ws = new WsClient({
40
+ url: options.serverUrl,
41
+ token: options.token,
42
+ ...(options.scene !== undefined ? { scene: options.scene } : {}),
43
+ ...(options.testSession !== undefined ? { session: options.testSession } : {}),
44
+ onStatus: setStatus,
45
+ onSnapshot: (frame) => {
46
+ if (!active) return;
47
+ void onSnapshot(
48
+ bundleFetcher,
49
+ bundleSignal,
50
+ crossfadeKeySignal,
51
+ frame.scene_id,
52
+ frame.scene_version,
53
+ () => applySnapshot(store, frame),
54
+ reportError,
55
+ );
56
+ options.onMetric?.({
57
+ name: "snapshot_received",
58
+ scene_id: frame.scene_id,
59
+ path_count: Object.keys(frame.state).length,
60
+ });
61
+ },
62
+ onDelta: (frame) => {
63
+ if (!active) return;
64
+ const start = performance.now();
65
+ applyDelta(store, frame);
66
+ options.onMetric?.({
67
+ name: "delta_applied",
68
+ duration_ms: performance.now() - start,
69
+ });
70
+ options.onMetric?.({ name: "delta_received", count: 1, path_count: frame.patches.length });
71
+ },
72
+ onSceneChanged: (frame) => {
73
+ if (!active) return;
74
+ // The fresh snapshot that follows is the source of truth — it carries
75
+ // the new scene_version, drives the bundle fetch, and flips the
76
+ // crossfade key. Nothing eager to do here.
77
+ options.onMetric?.({
78
+ name: "scene_changed",
79
+ from: bundleSignal.value?.scene_version ?? null,
80
+ to: frame.scene_version,
81
+ });
82
+ },
83
+ onServerError: (frame) => {
84
+ reportError({
85
+ code: frame.code,
86
+ message: frame.message,
87
+ recoverable: frame.recoverable,
88
+ });
89
+ },
90
+ onTransportError: (err) => {
91
+ reportError(transportToLumencastError(err));
92
+ },
93
+ });
94
+
95
+ ws.start();
96
+
97
+ const root: Root = createRoot(options.target);
98
+ root.render(
99
+ createElement(LumencastApp, {
100
+ mode: options.mode,
101
+ store,
102
+ bundleSignal,
103
+ statusSignal,
104
+ crossfadeKeySignal,
105
+ sendInput: (patches) => ws.sendInput(patches),
106
+ }),
107
+ );
108
+
109
+ return {
110
+ disconnect() {
111
+ if (!active) return;
112
+ active = false;
113
+ ws.close();
114
+ root.unmount();
115
+ },
116
+ setToken(token: LumencastToken) {
117
+ if (!active) return;
118
+ ws.setToken(token);
119
+ },
120
+ };
121
+
122
+ // --- helpers ----------------------------------------------------------
123
+
124
+ async function onSnapshot(
125
+ fetcher: BundleFetcher,
126
+ bSignal: typeof bundleSignal,
127
+ cSignal: typeof crossfadeKeySignal,
128
+ sceneId: string,
129
+ sceneVersion: string,
130
+ applyState: () => void,
131
+ onErr: (err: LumencastError) => void,
132
+ ): Promise<void> {
133
+ let bundle: RenderBundle;
134
+ try {
135
+ bundle = await fetcher.get(sceneId, sceneVersion);
136
+ } catch (err) {
137
+ onErr({
138
+ code: "BUNDLE_FETCH_FAILED",
139
+ message: err instanceof Error ? err.message : "render bundle fetch failed",
140
+ recoverable: true,
141
+ });
142
+ return;
143
+ }
144
+ if (!active) return;
145
+ applyState();
146
+ bSignal.value = bundle;
147
+ cSignal.value = `${sceneId}::${sceneVersion}`;
148
+ }
149
+ }
150
+
151
+ function transportToLumencastError(err: TransportError): LumencastError {
152
+ return {
153
+ code: err.code,
154
+ message: err.message,
155
+ recoverable: err.recoverable,
156
+ };
157
+ }
158
+
159
+ function deriveBaseUrl(wsUrl: string): string {
160
+ // wss://<host>/lsdp/v1 → https://<host>
161
+ // ws://<host>/lsdp/v1 → http://<host>
162
+ try {
163
+ const u = new URL(wsUrl);
164
+ const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
165
+ return `${httpScheme}//${u.host}`;
166
+ } catch {
167
+ return "";
168
+ }
169
+ }
@@ -0,0 +1,239 @@
1
+ import { useSignals } from "@preact/signals-react/runtime";
2
+ import type { OperatorInput } from "../render/bundle";
3
+ import { useLumencastRuntime } from "./runtime-context";
4
+
5
+ const PANEL_STYLE: React.CSSProperties = {
6
+ position: "fixed",
7
+ bottom: 12,
8
+ left: 12,
9
+ zIndex: 100_000,
10
+ width: 320,
11
+ maxHeight: "70vh",
12
+ overflowY: "auto",
13
+ padding: 12,
14
+ fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
15
+ fontSize: 12,
16
+ color: "#e5e7eb",
17
+ background: "rgba(17, 24, 39, 0.92)",
18
+ border: "1px solid rgba(75, 85, 99, 0.6)",
19
+ borderRadius: 10,
20
+ boxShadow: "0 8px 32px rgba(0, 0, 0, 0.45)",
21
+ };
22
+
23
+ const ROW_STYLE: React.CSSProperties = {
24
+ display: "flex",
25
+ flexDirection: "column",
26
+ gap: 4,
27
+ padding: "6px 0",
28
+ borderBottom: "1px solid rgba(75, 85, 99, 0.35)",
29
+ };
30
+
31
+ const LABEL_STYLE: React.CSSProperties = {
32
+ color: "#9ca3af",
33
+ fontSize: 10.5,
34
+ letterSpacing: "0.02em",
35
+ textTransform: "uppercase",
36
+ };
37
+
38
+ const INPUT_STYLE: React.CSSProperties = {
39
+ background: "rgba(31, 41, 55, 0.8)",
40
+ border: "1px solid rgba(75, 85, 99, 0.6)",
41
+ borderRadius: 6,
42
+ color: "#f9fafb",
43
+ padding: "4px 6px",
44
+ fontSize: 12,
45
+ width: "100%",
46
+ };
47
+
48
+ export function ControlPanel() {
49
+ const { bundle, store, sendInput } = useLumencastRuntime();
50
+ useSignals();
51
+
52
+ const inputs = bundle.operator_inputs ?? [];
53
+ if (inputs.length === 0) return null;
54
+
55
+ // Group entries by `group` field for readability.
56
+ const groups = new Map<string, OperatorInput[]>();
57
+ for (const entry of inputs) {
58
+ const g = entry.group ?? "General";
59
+ const list = groups.get(g) ?? [];
60
+ list.push(entry);
61
+ groups.set(g, list);
62
+ }
63
+
64
+ return (
65
+ <div style={PANEL_STYLE} data-testid="lumencast-control-panel">
66
+ <div
67
+ style={{
68
+ fontWeight: 600,
69
+ fontSize: 11,
70
+ letterSpacing: "0.06em",
71
+ color: "#9ca3af",
72
+ textTransform: "uppercase",
73
+ marginBottom: 6,
74
+ }}
75
+ >
76
+ Operator inputs
77
+ </div>
78
+ {[...groups.entries()].map(([group, entries]) => (
79
+ <div key={group} style={{ marginBottom: 8 }}>
80
+ <div
81
+ style={{
82
+ color: "#6b7280",
83
+ fontSize: 10,
84
+ letterSpacing: "0.04em",
85
+ textTransform: "uppercase",
86
+ padding: "4px 0",
87
+ }}
88
+ >
89
+ {group}
90
+ </div>
91
+ {entries.map((entry) => (
92
+ <InputRow
93
+ key={entry.path}
94
+ entry={entry}
95
+ currentValue={store.signal(entry.path).value}
96
+ onCommit={(v) =>
97
+ // Operator-control values come from form widgets typed per
98
+ // OperatorInput.type; coerce to LeafValue at the boundary.
99
+ sendInput([
100
+ {
101
+ path: entry.path,
102
+ value: v as Parameters<typeof sendInput>[0][number]["value"],
103
+ },
104
+ ])
105
+ }
106
+ />
107
+ ))}
108
+ </div>
109
+ ))}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function InputRow({
115
+ entry,
116
+ currentValue,
117
+ onCommit,
118
+ }: {
119
+ entry: OperatorInput;
120
+ currentValue: unknown;
121
+ onCommit: (value: unknown) => void;
122
+ }) {
123
+ return (
124
+ <div style={ROW_STYLE}>
125
+ <span style={LABEL_STYLE}>{entry.label}</span>
126
+ <Editor entry={entry} currentValue={currentValue} onCommit={onCommit} />
127
+ </div>
128
+ );
129
+ }
130
+
131
+ function Editor({
132
+ entry,
133
+ currentValue,
134
+ onCommit,
135
+ }: {
136
+ entry: OperatorInput;
137
+ currentValue: unknown;
138
+ onCommit: (value: unknown) => void;
139
+ }) {
140
+ switch (entry.type) {
141
+ case "boolean": {
142
+ const checked = currentValue === true;
143
+ return (
144
+ <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
145
+ <input type="checkbox" checked={checked} onChange={(e) => onCommit(e.target.checked)} />
146
+ <span style={{ fontSize: 11, color: "#d1d5db" }}>{checked ? "on" : "off"}</span>
147
+ </label>
148
+ );
149
+ }
150
+ case "number": {
151
+ const min = entry.min as number | undefined;
152
+ const max = entry.max as number | undefined;
153
+ const step = entry.step as number | undefined;
154
+ return (
155
+ <input
156
+ type="number"
157
+ style={INPUT_STYLE}
158
+ value={typeof currentValue === "number" ? currentValue : ""}
159
+ min={min}
160
+ max={max}
161
+ step={step}
162
+ onChange={(e) => {
163
+ const n = Number(e.target.value);
164
+ if (Number.isFinite(n)) onCommit(n);
165
+ }}
166
+ />
167
+ );
168
+ }
169
+ case "text": {
170
+ const max = entry.max_length as number | undefined;
171
+ return (
172
+ <input
173
+ type="text"
174
+ style={INPUT_STYLE}
175
+ value={typeof currentValue === "string" ? currentValue : ""}
176
+ maxLength={max}
177
+ onChange={(e) => onCommit(e.target.value)}
178
+ />
179
+ );
180
+ }
181
+ case "colour": {
182
+ return (
183
+ <input
184
+ type="color"
185
+ style={INPUT_STYLE}
186
+ value={typeof currentValue === "string" ? currentValue : "#000000"}
187
+ onChange={(e) => onCommit(e.target.value)}
188
+ />
189
+ );
190
+ }
191
+ case "duration": {
192
+ return (
193
+ <input
194
+ type="number"
195
+ style={INPUT_STYLE}
196
+ value={typeof currentValue === "number" ? currentValue : ""}
197
+ min={0}
198
+ step={100}
199
+ onChange={(e) => {
200
+ const n = Number(e.target.value);
201
+ if (Number.isFinite(n) && n >= 0) onCommit(n);
202
+ }}
203
+ />
204
+ );
205
+ }
206
+ case "select":
207
+ case "enum": {
208
+ const options =
209
+ (entry.enum_values as string[] | undefined) ??
210
+ (entry.options as string[] | undefined) ??
211
+ [];
212
+ return (
213
+ <select
214
+ style={INPUT_STYLE}
215
+ value={typeof currentValue === "string" ? currentValue : ""}
216
+ onChange={(e) => onCommit(e.target.value)}
217
+ >
218
+ {options.map((opt) => (
219
+ <option key={opt} value={opt}>
220
+ {opt}
221
+ </option>
222
+ ))}
223
+ </select>
224
+ );
225
+ }
226
+ case "path-ref":
227
+ default:
228
+ // FIXME (v2) — `path-ref` UX is deferred ; for now show a plain
229
+ // text entry so the value is still editable.
230
+ return (
231
+ <input
232
+ type="text"
233
+ style={INPUT_STYLE}
234
+ value={typeof currentValue === "string" ? currentValue : ""}
235
+ onChange={(e) => onCommit(e.target.value)}
236
+ />
237
+ );
238
+ }
239
+ }
@@ -0,0 +1,37 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { Patch } from "@lumencast/protocol";
3
+ import type { Store } from "../state/store";
4
+ import type { RenderBundle } from "../render/bundle";
5
+ import type { ConnectionStatus } from "../transport/ws";
6
+ import type { LumencastMode } from "../types";
7
+
8
+ export interface LumencastRuntime {
9
+ mode: LumencastMode;
10
+ store: Store;
11
+ bundle: RenderBundle;
12
+ status: ConnectionStatus;
13
+ /** Send LSDP/1 input patches to the server. */
14
+ sendInput: (patches: Patch[]) => void;
15
+ }
16
+
17
+ const Ctx = createContext<LumencastRuntime | null>(null);
18
+
19
+ export function LumencastRuntimeProvider({
20
+ value,
21
+ children,
22
+ }: {
23
+ value: LumencastRuntime;
24
+ children: ReactNode;
25
+ }) {
26
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
27
+ }
28
+
29
+ export function useLumencastRuntime(): LumencastRuntime {
30
+ const v = useContext(Ctx);
31
+ if (!v) {
32
+ throw new Error(
33
+ "Lumencast overlay components must be rendered inside LumencastRuntimeProvider",
34
+ );
35
+ }
36
+ return v;
37
+ }
@@ -0,0 +1,37 @@
1
+ import { useLumencastRuntime } from "./runtime-context";
2
+
3
+ const COLOURS: Record<string, string> = {
4
+ live: "rgba(34, 197, 94, 0.85)",
5
+ connecting: "rgba(234, 179, 8, 0.85)",
6
+ disconnected: "rgba(239, 68, 68, 0.85)",
7
+ };
8
+
9
+ const LABELS: Record<string, string> = {
10
+ live: "live",
11
+ connecting: "reconnecting",
12
+ disconnected: "disconnected",
13
+ };
14
+
15
+ export function StatusPill() {
16
+ const { status } = useLumencastRuntime();
17
+ return (
18
+ <div
19
+ data-testid="lumencast-status-pill"
20
+ style={{
21
+ position: "fixed",
22
+ top: 12,
23
+ right: 12,
24
+ padding: "4px 10px",
25
+ fontSize: 11,
26
+ fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
27
+ color: "white",
28
+ background: COLOURS[status] ?? "#444",
29
+ borderRadius: 999,
30
+ userSelect: "none",
31
+ pointerEvents: "none",
32
+ }}
33
+ >
34
+ {LABELS[status] ?? status}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,213 @@
1
+ import { useSignals } from "@preact/signals-react/runtime";
2
+ import { useState } from "react";
3
+ import { useLumencastRuntime } from "./runtime-context";
4
+
5
+ const PANEL_STYLE: React.CSSProperties = {
6
+ position: "fixed",
7
+ bottom: 12,
8
+ right: 12,
9
+ zIndex: 100_001,
10
+ width: 360,
11
+ maxHeight: "70vh",
12
+ overflowY: "auto",
13
+ padding: 12,
14
+ fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
15
+ fontSize: 12,
16
+ color: "#e5e7eb",
17
+ background: "rgba(8, 47, 73, 0.92)",
18
+ border: "1px solid rgba(56, 189, 248, 0.4)",
19
+ borderRadius: 10,
20
+ boxShadow: "0 8px 32px rgba(0, 0, 0, 0.45)",
21
+ };
22
+
23
+ const SECTION_TITLE: React.CSSProperties = {
24
+ fontWeight: 600,
25
+ fontSize: 11,
26
+ letterSpacing: "0.06em",
27
+ color: "#7dd3fc",
28
+ textTransform: "uppercase",
29
+ marginBottom: 6,
30
+ };
31
+
32
+ const BUTTON_STYLE: React.CSSProperties = {
33
+ background: "rgba(14, 165, 233, 0.4)",
34
+ border: "1px solid rgba(125, 211, 252, 0.5)",
35
+ borderRadius: 6,
36
+ color: "#f0f9ff",
37
+ padding: "3px 8px",
38
+ fontSize: 11,
39
+ cursor: "pointer",
40
+ };
41
+
42
+ const ADAPTER_ROW: React.CSSProperties = {
43
+ display: "flex",
44
+ flexDirection: "column",
45
+ gap: 4,
46
+ padding: "6px 0",
47
+ borderBottom: "1px solid rgba(56, 189, 248, 0.2)",
48
+ };
49
+
50
+ /** Test-mode overlay : adapter mocker + state inspector + time
51
+ * controls. Drives the server's __test.* family via the same `sendInput`
52
+ * channel. */
53
+ export function TestPanel() {
54
+ const { bundle, store, sendInput } = useLumencastRuntime();
55
+ useSignals();
56
+ const [filter, setFilter] = useState("");
57
+
58
+ const adapters = bundle.external_adapters ?? [];
59
+ const stateRecord = store.toRecord();
60
+ const filteredEntries = Object.entries(stateRecord).filter(
61
+ ([k]) => filter === "" || k.includes(filter),
62
+ );
63
+
64
+ return (
65
+ <div style={PANEL_STYLE} data-testid="lumencast-test-panel">
66
+ {/* Time controls */}
67
+ <div style={SECTION_TITLE}>Time</div>
68
+ <div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
69
+ <button
70
+ type="button"
71
+ style={BUTTON_STYLE}
72
+ onClick={() => sendInput([{ path: "__test.tick", value: 100 }])}
73
+ >
74
+ tick +100ms
75
+ </button>
76
+ <button
77
+ type="button"
78
+ style={BUTTON_STYLE}
79
+ onClick={() => sendInput([{ path: "__test.tick", value: 1_000 }])}
80
+ >
81
+ tick +1s
82
+ </button>
83
+ <button
84
+ type="button"
85
+ style={BUTTON_STYLE}
86
+ onClick={() => sendInput([{ path: "__test.reset", value: true }])}
87
+ >
88
+ reset
89
+ </button>
90
+ </div>
91
+
92
+ {/* Adapter mocker */}
93
+ <div style={SECTION_TITLE}>External adapters</div>
94
+ {adapters.length === 0 && (
95
+ <div style={{ color: "#94a3b8", fontStyle: "italic", fontSize: 11 }}>
96
+ No external adapters declared in this scene.
97
+ </div>
98
+ )}
99
+ {adapters.map((adapter) => (
100
+ <AdapterRow
101
+ key={adapter.key}
102
+ adapter={adapter}
103
+ onMock={(payload) =>
104
+ // LSDP/1 patch values must be leaf — JSON-encode the structured payload.
105
+ sendInput([
106
+ {
107
+ path: "__test.mock_adapter",
108
+ value: JSON.stringify({ key: adapter.key, payload }),
109
+ },
110
+ ])
111
+ }
112
+ />
113
+ ))}
114
+
115
+ {/* State inspector */}
116
+ <div style={{ ...SECTION_TITLE, marginTop: 12 }}>State</div>
117
+ <input
118
+ type="text"
119
+ placeholder="filter paths…"
120
+ value={filter}
121
+ onChange={(e) => setFilter(e.target.value)}
122
+ style={{
123
+ background: "rgba(8, 47, 73, 0.6)",
124
+ border: "1px solid rgba(125, 211, 252, 0.4)",
125
+ borderRadius: 6,
126
+ color: "#e0f2fe",
127
+ padding: "4px 6px",
128
+ fontSize: 11,
129
+ width: "100%",
130
+ marginBottom: 6,
131
+ }}
132
+ />
133
+ <div style={{ fontFamily: "monospace", fontSize: 10.5 }}>
134
+ {filteredEntries.map(([path, value]) => (
135
+ <div
136
+ key={path}
137
+ style={{
138
+ display: "grid",
139
+ gridTemplateColumns: "1fr auto",
140
+ gap: 8,
141
+ padding: "2px 0",
142
+ borderBottom: "1px dashed rgba(125, 211, 252, 0.15)",
143
+ }}
144
+ >
145
+ <span style={{ color: "#bae6fd" }}>{path}</span>
146
+ <span style={{ color: "#fef3c7" }}>{formatValue(value)}</span>
147
+ </div>
148
+ ))}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function AdapterRow({
155
+ adapter,
156
+ onMock,
157
+ }: {
158
+ adapter: { key: string; label: string; kind: string };
159
+ onMock: (payload: unknown) => void;
160
+ }) {
161
+ const [draft, setDraft] = useState("{}");
162
+ return (
163
+ <div style={ADAPTER_ROW}>
164
+ <div
165
+ style={{
166
+ display: "flex",
167
+ justifyContent: "space-between",
168
+ alignItems: "center",
169
+ }}
170
+ >
171
+ <span style={{ color: "#e0f2fe" }}>{adapter.label}</span>
172
+ <span style={{ color: "#94a3b8", fontSize: 10 }}>{adapter.kind}</span>
173
+ </div>
174
+ <textarea
175
+ value={draft}
176
+ onChange={(e) => setDraft(e.target.value)}
177
+ rows={2}
178
+ style={{
179
+ fontFamily: "monospace",
180
+ fontSize: 10.5,
181
+ background: "rgba(8, 47, 73, 0.6)",
182
+ color: "#e0f2fe",
183
+ border: "1px solid rgba(125, 211, 252, 0.3)",
184
+ borderRadius: 4,
185
+ padding: 4,
186
+ resize: "vertical",
187
+ }}
188
+ />
189
+ <button
190
+ type="button"
191
+ style={BUTTON_STYLE}
192
+ onClick={() => {
193
+ try {
194
+ const parsed = JSON.parse(draft);
195
+ onMock(parsed);
196
+ } catch {
197
+ onMock(draft);
198
+ }
199
+ }}
200
+ >
201
+ fire
202
+ </button>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ function formatValue(value: unknown): string {
208
+ if (value === undefined) return "—";
209
+ if (value === null) return "null";
210
+ if (typeof value === "string") return JSON.stringify(value);
211
+ if (typeof value === "object") return JSON.stringify(value);
212
+ return String(value);
213
+ }