@lumencast/runtime 0.7.0 → 0.9.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 (80) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/animate/keyframes.js +8 -1
  3. package/dist/animate/keyframes.js.map +1 -1
  4. package/dist/app.d.ts +4 -1
  5. package/dist/app.d.ts.map +1 -1
  6. package/dist/app.js +2 -1
  7. package/dist/app.js.map +1 -1
  8. package/dist/{broadcast-DUYqvcgo.js → broadcast-ryjLRD5q.js} +3 -3
  9. package/dist/{broadcast-DUYqvcgo.js.map → broadcast-ryjLRD5q.js.map} +1 -1
  10. package/dist/{control-CL8TWXaE.js → control-AgxbXOVS.js} +4 -4
  11. package/dist/{control-CL8TWXaE.js.map → control-AgxbXOVS.js.map} +1 -1
  12. package/dist/{index-C6viWFcT.js → index-DrXsLYhe.js} +309 -212
  13. package/dist/index-DrXsLYhe.js.map +1 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.html +1 -1
  17. package/dist/index.js +13 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/lumencast.js +14 -9
  20. package/dist/mount.d.ts.map +1 -1
  21. package/dist/mount.js +5 -0
  22. package/dist/mount.js.map +1 -1
  23. package/dist/overlay/runtime-context.d.ts +11 -0
  24. package/dist/overlay/runtime-context.d.ts.map +1 -1
  25. package/dist/overlay/runtime-context.js +8 -0
  26. package/dist/overlay/runtime-context.js.map +1 -1
  27. package/dist/render/asset-resolve.d.ts +27 -0
  28. package/dist/render/asset-resolve.d.ts.map +1 -0
  29. package/dist/render/asset-resolve.js +86 -0
  30. package/dist/render/asset-resolve.js.map +1 -0
  31. package/dist/render/bundle.d.ts +1 -1
  32. package/dist/render/bundle.d.ts.map +1 -1
  33. package/dist/render/bundle.js +4 -0
  34. package/dist/render/bundle.js.map +1 -1
  35. package/dist/render/headless.d.ts +39 -0
  36. package/dist/render/headless.d.ts.map +1 -0
  37. package/dist/render/headless.js +83 -0
  38. package/dist/render/headless.js.map +1 -0
  39. package/dist/render/keyframe-player.d.ts.map +1 -1
  40. package/dist/render/keyframe-player.js +15 -1
  41. package/dist/render/keyframe-player.js.map +1 -1
  42. package/dist/render/primitives/capture.d.ts +40 -0
  43. package/dist/render/primitives/capture.d.ts.map +1 -0
  44. package/dist/render/primitives/capture.js +171 -0
  45. package/dist/render/primitives/capture.js.map +1 -0
  46. package/dist/render/primitives/index.d.ts.map +1 -1
  47. package/dist/render/primitives/index.js +3 -0
  48. package/dist/render/primitives/index.js.map +1 -1
  49. package/dist/render/primitives/media.d.ts +11 -2
  50. package/dist/render/primitives/media.d.ts.map +1 -1
  51. package/dist/render/primitives/media.js +14 -3
  52. package/dist/render/primitives/media.js.map +1 -1
  53. package/dist/render/prop-allowlist.d.ts.map +1 -1
  54. package/dist/render/prop-allowlist.js +5 -0
  55. package/dist/render/prop-allowlist.js.map +1 -1
  56. package/dist/{status-pill-jJT54n07.js → status-pill-BxCdj-KZ.js} +2 -2
  57. package/dist/{status-pill-jJT54n07.js.map → status-pill-BxCdj-KZ.js.map} +1 -1
  58. package/dist/{test-84XodL1c.js → test-CaRHj_J6.js} +4 -4
  59. package/dist/{test-84XodL1c.js.map → test-CaRHj_J6.js.map} +1 -1
  60. package/dist/{tree-BIimahCf.js → tree-BLIxJbD3.js} +515 -432
  61. package/dist/tree-BLIxJbD3.js.map +1 -0
  62. package/dist/types.d.ts +10 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +6 -5
  65. package/src/animate/keyframes.ts +8 -1
  66. package/src/app.tsx +5 -0
  67. package/src/index.ts +29 -0
  68. package/src/mount.ts +5 -0
  69. package/src/overlay/runtime-context.tsx +14 -0
  70. package/src/render/asset-resolve.ts +97 -0
  71. package/src/render/bundle.ts +9 -1
  72. package/src/render/headless.tsx +129 -0
  73. package/src/render/keyframe-player.tsx +14 -1
  74. package/src/render/primitives/capture.tsx +210 -0
  75. package/src/render/primitives/index.ts +3 -0
  76. package/src/render/primitives/media.tsx +14 -3
  77. package/src/render/prop-allowlist.ts +5 -0
  78. package/src/types.ts +10 -0
  79. package/dist/index-C6viWFcT.js.map +0 -1
  80. package/dist/tree-BIimahCf.js.map +0 -1
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ErrorCode } from "@lumencast/protocol";
2
+ import type { ResolveCaptureDevice } from "./render/primitives/capture";
2
3
  export type LumencastMode = "broadcast" | "control" | "test";
3
4
  export type LumencastStatus = "disconnected" | "connecting" | "live";
4
5
  export interface LumencastTokenProvider {
@@ -53,6 +54,15 @@ export interface MountOptions {
53
54
  * logs — `broadcast` builds stay console-silent. When omitted, the
54
55
  * runtime falls back to a DEV-only console.warn. */
55
56
  onDiagnostic?: (diagnostic: LumencastDiagnostic) => void;
57
+ /** ADR 004 §A1.3 — host resolver for the `x-zab.capture` primitive's ACQUIRE
58
+ * mode. Given the LOGICAL `(deviceRef, sourceKind)` from the bundle, return
59
+ * `{ deviceId }` to pin a physical device, or `null` for the host's default
60
+ * device. The runtime passes `deviceId` only as a live `getUserMedia`
61
+ * constraint — it NEVER enters the bundle or the content hash. Omit it and
62
+ * ACQUIRE uses the default device ("the cam traverses"), never throwing.
63
+ * Only consulted on a capture-capable host (e.g. the Electron preview
64
+ * webview) ; ignored on-air (CEF/Pulsar render the placeholder). */
65
+ resolveCaptureDevice?: ResolveCaptureDevice;
56
66
  }
57
67
  export interface LumencastHandle {
58
68
  /** Tear down the WS, unmount the React tree, release timers. Idempotent. */
@@ -1 +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;;+BAE+B;AAC/B,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;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;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IACrE,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;IAC7C;;;yDAGqD;IACrD,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,mBAAmB,KAAK,IAAI,CAAC;CAC1D;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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAExE,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;;+BAE+B;AAC/B,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;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;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IACrE,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;IAC7C;;;yDAGqD;IACrD,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACzD;;;;;;;yEAOqE;IACrE,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;CAC7C;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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumencast/runtime",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Browser runtime for Lumencast — mount(), LSDP/1 transport, leaf-grain store, LSML render, animations, overlays.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "framer-motion": "^12.0.0",
37
37
  "react": "^19.0.0",
38
38
  "react-dom": "^19.0.0",
39
- "@lumencast/protocol": "0.7.0"
39
+ "@lumencast/protocol": "0.9.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@playwright/test": "^1.49.1",
@@ -50,8 +50,8 @@
50
50
  "vite-plugin-dts": "^4.5.0",
51
51
  "vitest": "^4.1.5",
52
52
  "ws": "^8.18.0",
53
- "@lumencast/dev-server": "0.7.0",
54
- "@lumencast/server": "0.7.0"
53
+ "@lumencast/server": "0.9.0",
54
+ "@lumencast/dev-server": "0.9.0"
55
55
  },
56
56
  "scripts": {
57
57
  "dev": "vite",
@@ -60,6 +60,7 @@
60
60
  "test": "vitest run",
61
61
  "test:watch": "vitest",
62
62
  "test:e2e": "playwright test",
63
- "check:bundle": "node scripts/check-bundle-size.mjs"
63
+ "check:bundle": "node scripts/check-bundle-size.mjs",
64
+ "check:no-fetch": "node scripts/check-no-fetch.mjs"
64
65
  }
65
66
  }
@@ -151,7 +151,14 @@ function pullTransform(
151
151
  if (prop === "rotate") {
152
152
  out.rotate = values.map((n) => `${n}deg`);
153
153
  } else {
154
- out[prop] = values;
154
+ // ADR 011 I7 live-bug fix (2nd half): framer-motion animates transform
155
+ // through its shorthand motion keys `x`/`y`, NOT `translateX`/`translateY`
156
+ // — emitting the authored names verbatim left framer with unknown keys
157
+ // it silently dropped, so the box never translated at the antenna (only
158
+ // the opacity fade survived). Map the translate channels onto the framer
159
+ // keys. `scale`/`rotate` already match framer's vocabulary.
160
+ const framerKey = prop === "translateX" ? "x" : prop === "translateY" ? "y" : prop;
161
+ out[framerKey] = values;
155
162
  }
156
163
  }
157
164
  }
package/src/app.tsx CHANGED
@@ -18,6 +18,7 @@ import type { Store } from "./state/store.js";
18
18
  import type { RenderBundle } from "./render/bundle.js";
19
19
  import type { ConnectionStatus } from "./transport/ws.js";
20
20
  import { LumencastRuntimeProvider } from "./overlay/runtime-context.js";
21
+ import type { ResolveCaptureDevice } from "./render/primitives/capture.js";
21
22
  import type { LumencastMode } from "./types.js";
22
23
 
23
24
  const LazyBroadcastMode = lazy(() =>
@@ -35,6 +36,8 @@ export interface LumencastAppProps {
35
36
  statusSignal: Signal<ConnectionStatus>;
36
37
  crossfadeKeySignal: Signal<string>;
37
38
  sendInput: (patches: Patch[]) => void;
39
+ /** ADR 004 §A1.3 — host resolver for `x-zab.capture` ACQUIRE mode. */
40
+ resolveCaptureDevice?: ResolveCaptureDevice;
38
41
  }
39
42
 
40
43
  export function LumencastApp({
@@ -44,6 +47,7 @@ export function LumencastApp({
44
47
  statusSignal,
45
48
  crossfadeKeySignal,
46
49
  sendInput,
50
+ resolveCaptureDevice,
47
51
  }: LumencastAppProps) {
48
52
  useSignals();
49
53
 
@@ -72,6 +76,7 @@ export function LumencastApp({
72
76
  bundle,
73
77
  status,
74
78
  sendInput,
79
+ ...(resolveCaptureDevice !== undefined ? { resolveCaptureDevice } : {}),
75
80
  }}
76
81
  >
77
82
  <Suspense fallback={null}>
package/src/index.ts CHANGED
@@ -26,6 +26,11 @@ export {
26
26
  } from "./render/diagnostics.js";
27
27
  export { PRIMITIVE_PROP_ALLOWLIST } from "./render/prop-allowlist.js";
28
28
 
29
+ // ADR 004 §A1.3 — host resolver type for the `x-zab.capture` ACQUIRE mode,
30
+ // supplied via `MountOptions.resolveCaptureDevice`. Exported so the consuming
31
+ // app (Prism/Solar) types its injected resolver against the runtime's contract.
32
+ export type { ResolveCaptureDevice } from "./render/primitives/capture.js";
33
+
29
34
  // Bundle types are useful for hosts that want to typecheck pre-compiled scenes.
30
35
  export type {
31
36
  RenderBundle,
@@ -46,3 +51,27 @@ export {
46
51
  isAuthoringProfile,
47
52
  validateBundleProfiles,
48
53
  } from "./render/bundle.js";
54
+
55
+ // Headless render (ADR 003) — render an already-compiled RenderBundle into a
56
+ // live DOM node, no WS, ready when layout + fonts settle. Hosts (Solar headless
57
+ // entry, ZabCanvas render worker, the zero-loss harness) screenshot the target
58
+ // once `ready` resolves. The runtime does DOM + readiness only — no fetch, no
59
+ // screenshot. Dynamically pulls BroadcastMode so it adds no eager weight to the
60
+ // `mount`/broadcast path (RC6).
61
+ export { renderBundleHeadless } from "./render/headless.js";
62
+ export type { HeadlessRenderOptions, HeadlessRenderHandle } from "./render/headless.js";
63
+
64
+ // Asset / font resolution helpers for headless hosts (ADR 003 §3.2). No-fetch:
65
+ // they only rewrite a bundle's `src`s against a caller table and load fonts
66
+ // from caller-supplied `data:` URIs. The host-allow gate stays the sole
67
+ // authority. (`FontFace` is the public type name per ADR 003 RC5; it is the
68
+ // spec object — distinct from the DOM `FontFace` constructor.)
69
+ export {
70
+ resolveSrc,
71
+ rewriteLayoutSrcs,
72
+ rewriteDefaultsSrcs,
73
+ injectFonts,
74
+ type AssetTable,
75
+ type FontFaceSpec,
76
+ type FontFaceSpec as FontFace,
77
+ } from "./render/asset-resolve.js";
package/src/mount.ts CHANGED
@@ -118,6 +118,11 @@ export function mount(options: MountOptions): LumencastHandle {
118
118
  statusSignal,
119
119
  crossfadeKeySignal,
120
120
  sendInput: (patches) => ws.sendInput(patches),
121
+ // ADR 004 §A1.3 — thread the host capture resolver to the runtime context
122
+ // so the `x-zab.capture` primitive's ACQUIRE mode can pin a device.
123
+ ...(options.resolveCaptureDevice !== undefined
124
+ ? { resolveCaptureDevice: options.resolveCaptureDevice }
125
+ : {}),
121
126
  }),
122
127
  );
123
128
 
@@ -4,6 +4,7 @@ import type { Store } from "../state/store";
4
4
  import type { RenderBundle } from "../render/bundle";
5
5
  import type { ConnectionStatus } from "../transport/ws";
6
6
  import type { LumencastMode } from "../types";
7
+ import type { ResolveCaptureDevice } from "../render/primitives/capture";
7
8
 
8
9
  export interface LumencastRuntime {
9
10
  mode: LumencastMode;
@@ -12,6 +13,10 @@ export interface LumencastRuntime {
12
13
  status: ConnectionStatus;
13
14
  /** Send LSDP/1 input patches to the server. */
14
15
  sendInput: (patches: Patch[]) => void;
16
+ /** ADR 004 §A1.3 — host-provided resolver mapping a LOGICAL `deviceRef` to a
17
+ * physical `deviceId` for the `x-zab.capture` primitive's ACQUIRE mode.
18
+ * Injected from `MountOptions`, NOT the bundle. Absent → default device. */
19
+ resolveCaptureDevice?: ResolveCaptureDevice;
15
20
  }
16
21
 
17
22
  const Ctx = createContext<LumencastRuntime | null>(null);
@@ -35,3 +40,12 @@ export function useLumencastRuntime(): LumencastRuntime {
35
40
  }
36
41
  return v;
37
42
  }
43
+
44
+ /** Read the runtime context WITHOUT throwing when no provider is mounted.
45
+ * Render primitives (e.g. `x-zab.capture`) may render via `<Tree>` directly —
46
+ * embedded hosts, tooling, tests — outside `mount()`'s provider. They use this
47
+ * to pick up mount-level host config (the capture resolver) when present and
48
+ * fall back to defaults when not. */
49
+ export function useOptionalLumencastRuntime(): LumencastRuntime | null {
50
+ return useContext(Ctx);
51
+ }
@@ -0,0 +1,97 @@
1
+ // Public asset + font resolution helpers for headless / host render (ADR 003 §3.2).
2
+ //
3
+ // These utilities let a HOST (Solar headless entry, a ZabCanvas render worker,
4
+ // the zero-loss harness) resolve a bundle's content-addressed asset references
5
+ // to concrete `data:` URIs and inject brand `@font-face`s BEFORE the first
6
+ // frame — exactly as the zero-loss harness has always done (ADR 002 #J). They
7
+ // are promoted here verbatim so every host resolves identically and exercises
8
+ // the SAME host-allow gate the runtime applies internally.
9
+ //
10
+ // ── No-fetch contract (ADR 003 §3.2, D3, Bastion R2) ────────────────────────
11
+ // NONE of these helpers performs a network fetch. They only rewrite a bundle's
12
+ // `src` values against a caller-supplied table (`resolveSrc` /
13
+ // `rewriteLayoutSrcs` / `rewriteDefaultsSrcs`) and load `@font-face`s from
14
+ // caller-supplied `data:`/same-document URLs (`injectFonts`). The runtime never
15
+ // reaches the network; the host owns where bytes come from. Both the input
16
+ // table values and the font `src`s are expected to be `data:` (or otherwise
17
+ // already host-allowed) URIs — substituting one already-admitted scheme for
18
+ // another, so the deny-by-default `allowedHosts` gate stays the sole authority.
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ /** A table mapping a bundle `src` reference to its resolved `data:` URI.
22
+ * Keys may be either the full `assets/<hash>.ext` path or a bare `<hash>`. */
23
+ export type AssetTable = Readonly<Record<string, string>>;
24
+
25
+ /** Resolve a single `src` value against a table. Non-string or unmatched values
26
+ * pass through unchanged. Matches both `assets/<hash>.ext` and bare `<hash>`. */
27
+ export function resolveSrc(src: unknown, table: AssetTable): unknown {
28
+ if (typeof src !== "string") return src;
29
+ if (table[src]) return table[src];
30
+ const m = /^assets\/([A-Za-z0-9]+)\.[A-Za-z0-9]+$/.exec(src);
31
+ if (m && m[1] !== undefined && table[m[1]]) return table[m[1]];
32
+ return src;
33
+ }
34
+
35
+ /** Deep-rewrite every `src` (image-fill, mask source) in a layout subtree,
36
+ * in place, against the supplied asset table. */
37
+ export function rewriteLayoutSrcs(node: unknown, table: AssetTable): void {
38
+ if (node === null || typeof node !== "object") return;
39
+ if (Array.isArray(node)) {
40
+ for (const n of node) rewriteLayoutSrcs(n, table);
41
+ return;
42
+ }
43
+ const obj = node as Record<string, unknown>;
44
+ if ("src" in obj) obj["src"] = resolveSrc(obj["src"], table);
45
+ for (const v of Object.values(obj)) {
46
+ if (v && typeof v === "object") rewriteLayoutSrcs(v, table);
47
+ }
48
+ }
49
+
50
+ /** Rewrite the `__lit.image.*` defaults (image-primitive `bind.src` targets)
51
+ * against the asset table, returning a new defaults object. */
52
+ export function rewriteDefaultsSrcs(
53
+ defaults: Record<string, unknown>,
54
+ table: AssetTable,
55
+ ): Record<string, unknown> {
56
+ const out: Record<string, unknown> = { ...defaults };
57
+ for (const [k, v] of Object.entries(out)) {
58
+ if (k.startsWith("__lit.image.")) out[k] = resolveSrc(v, table);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** A brand `@font-face` to inject before render. `src` is a `data:` URI (or any
64
+ * same-document URL) so no network/host-allow surface is involved. */
65
+ export interface FontFaceSpec {
66
+ family: string;
67
+ weight: number | string;
68
+ style?: string;
69
+ /** `url(data:font/woff2;base64,…)` content (the value inside `src:`). */
70
+ src: string;
71
+ }
72
+
73
+ /** Inject `@font-face` rules and block until the faces are loaded, so the very
74
+ * first painted frame already uses the brand glyphs (no fallback-font flash).
75
+ * Returns the families that successfully loaded. Loads only from the supplied
76
+ * `src` (expected `data:`); never fetches a remote host on its own behalf. */
77
+ export async function injectFonts(faces: readonly FontFaceSpec[]): Promise<string[]> {
78
+ const loaded: string[] = [];
79
+ for (const f of faces) {
80
+ try {
81
+ const face = new FontFace(f.family, f.src, {
82
+ weight: String(f.weight),
83
+ style: f.style ?? "normal",
84
+ });
85
+ await face.load();
86
+ (document as Document & { fonts: FontFaceSet }).fonts.add(face);
87
+ loaded.push(f.family);
88
+ } catch {
89
+ // A font that fails to load is a documented gap, not a render-breaker:
90
+ // the fallback glyphs paint and the host can detect the missing family
91
+ // in the returned list. Never throw (would abort an otherwise-good
92
+ // render) and never log the value (R9) — the absent family name is in
93
+ // `f.family`, which the caller already holds.
94
+ }
95
+ }
96
+ return loaded;
97
+ }
@@ -25,7 +25,11 @@ export type RenderKind =
25
25
  | "shape"
26
26
  | "media"
27
27
  | "repeat"
28
- | "instance";
28
+ | "instance"
29
+ // Zab vendor primitive (RFC-0001, §17.1) — a transparent capture
30
+ // placeholder. Recognised by the Zab-plugin runtime ; reserves a box and
31
+ // renders nothing.
32
+ | "x-zab.capture";
29
33
 
30
34
  export interface RenderNode {
31
35
  kind: RenderKind;
@@ -139,6 +143,10 @@ export interface RenderBundle {
139
143
  */
140
144
  export const SUPPORTED_PROFILES: ReadonlySet<string> = new Set<string>([
141
145
  "x-lumencast.color-srgb-1.0",
146
+ // RFC-0001 / ADR 004 — this runtime ships the Zab capture plugin, so a
147
+ // bundle declaring `x-zab.capture/1` in `profiles[]` is compatible (it is
148
+ // NOT rejected as BUNDLE_INCOMPATIBLE, §17.3.1).
149
+ "x-zab.capture/1",
142
150
  ]);
143
151
 
144
152
  // LSML 1.1 §17.5.1 + ADR 001 RC#14 — authoring-profile detection.
@@ -0,0 +1,129 @@
1
+ // Public headless render entry — render an already-compiled `RenderBundle`
2
+ // into a live DOM node, no WebSocket, ready when layout + fonts have settled
3
+ // (ADR 003 §3.1). The host (Playwright / Chromium / a CEF offscreen surface)
4
+ // screenshots `target` once `ready` resolves. The runtime does DOM + readiness
5
+ // ONLY — no screenshot, no fetch (ADR 003 D5/D3).
6
+ //
7
+ // This is the zero-loss harness (ADR 002 #J) generalised: it mounts the EXACT
8
+ // production seam —
9
+ // LumencastRuntimeProvider{ mode:"broadcast", status:"live" } > BroadcastMode
10
+ // — into a real `createRoot(target)`, NOT `renderToStaticMarkup` (which yields
11
+ // unlaid-out markup: unmeasured fonts, uncomposited masks → an infidel PNG,
12
+ // ADR 003 §3.1). `BroadcastMode` is dynamically imported so the headless
13
+ // function adds no weight to the eager `mount`/broadcast path (ADR 003 §4,
14
+ // RC6); the heavy render code already lives in the broadcast/tree chunks.
15
+ //
16
+ // Asset resolution is the HOST's job, done in the bundle BEFORE this call
17
+ // (ADR 003 §3.2): the runtime renders the bundle as-is, gating every remaining
18
+ // `src` through the unchanged deny-by-default host-allow gate inside
19
+ // `BroadcastMode` (`AllowedHostsProvider`). A `src` on a host not in the
20
+ // bundle's `allowedHosts` is omitted + a diagnostic is emitted — never faked
21
+ // (ADR 002 borne, D4). Use `render/asset-resolve` helpers to pre-resolve.
22
+
23
+ import { StrictMode } from "react";
24
+ import { createRoot } from "react-dom/client";
25
+ import { createStore } from "../state/store.js";
26
+ import { LumencastRuntimeProvider } from "../overlay/runtime-context.js";
27
+ import { addDiagnosticsHandler, type DiagnosticHandler } from "./diagnostics.js";
28
+ import type { RenderBundle } from "./bundle.js";
29
+
30
+ /** Default stage size — the Figma 817:3 cover frame, the SSIM reference. */
31
+ const DEFAULT_STAGE = { width: 1920, height: 1080 } as const;
32
+
33
+ export interface HeadlessRenderOptions {
34
+ /** Already-compiled bundle (via `@lumencast/compiler` on the host side). */
35
+ bundle: RenderBundle;
36
+ /** A live, mounted DOM node. Its size is set from `stage` unless the host
37
+ * has already dimensioned it (see `stage`). */
38
+ target: HTMLElement;
39
+ /** Initial leaf-grain store state (`store.reset(defaults)`) — the bound
40
+ * values the bundle reads (`__lit.*`, score, names…). */
41
+ defaults?: Record<string, unknown>;
42
+ /** Stage dimensions in CSS px. Defaults to 1920×1080. Applied to `target`
43
+ * as `width`/`height`/`position:relative`/`overflow:hidden` so the
44
+ * screenshot frame matches the reference exactly. */
45
+ stage?: { width: number; height: number };
46
+ /** Anti-drop diagnostics channel (ADR 001 §3.4): omitted assets, unhonoured
47
+ * fields surface here as `{ nodeId, field, reason }` (never a value — R9).
48
+ * Wired to the same global channel `mount()` uses. */
49
+ onDiagnostic?: DiagnosticHandler;
50
+ }
51
+
52
+ export interface HeadlessRenderHandle {
53
+ /** Resolves after the scene has rendered, two animation frames have passed
54
+ * AND `document.fonts.ready` (ADR 003 §3.3) — i.e. the DOM is laid out and
55
+ * fonts are loaded, so a screenshot taken now is fidelity-faithful. */
56
+ ready: Promise<void>;
57
+ /** Tear down the React root and detach the diagnostics handler. */
58
+ unmount(): void;
59
+ }
60
+
61
+ const noop = (): void => {};
62
+
63
+ /**
64
+ * Render `bundle` into `target` through the production broadcast path and
65
+ * resolve `ready` once it is settled. The runtime performs NO network fetch and
66
+ * takes NO screenshot — it produces a settled live DOM and a readiness signal,
67
+ * nothing more (ADR 003 D5).
68
+ */
69
+ export function renderBundleHeadless(opts: HeadlessRenderOptions): HeadlessRenderHandle {
70
+ const stage = opts.stage ?? DEFAULT_STAGE;
71
+ const target = opts.target;
72
+ // Pose the stage so the screenshot frame is exact (mirrors harness.html).
73
+ target.style.position ||= "relative";
74
+ target.style.width = `${stage.width}px`;
75
+ target.style.height = `${stage.height}px`;
76
+ target.style.overflow = "hidden";
77
+
78
+ const removeDiagnostics = opts.onDiagnostic
79
+ ? addDiagnosticsHandler(opts.onDiagnostic)
80
+ : undefined;
81
+
82
+ const store = createStore();
83
+ store.reset(opts.defaults ?? {});
84
+
85
+ const root = createRoot(target);
86
+
87
+ const ready = new Promise<void>((resolve) => {
88
+ // BroadcastMode is dynamically imported so its (and the tree's) weight is
89
+ // not pulled into the eager `mount` entry chunk (RC6). It is already a
90
+ // separate chunk reused from the broadcast path.
91
+ void import("../modes/broadcast.js").then(({ BroadcastMode }) => {
92
+ root.render(
93
+ <StrictMode>
94
+ <LumencastRuntimeProvider
95
+ value={{
96
+ mode: "broadcast",
97
+ store,
98
+ bundle: opts.bundle,
99
+ status: "live",
100
+ sendInput: noop,
101
+ }}
102
+ >
103
+ <BroadcastMode />
104
+ </LumencastRuntimeProvider>
105
+ </StrictMode>,
106
+ );
107
+
108
+ // Settle: two animation frames (layout) AND fonts loaded (ADR 003 §3.3).
109
+ // Both must complete before `ready` resolves, so a screenshot taken on
110
+ // `ready` uses the brand glyphs, not the fallback font (no FOUT freeze).
111
+ const framesSettled = new Promise<void>((res) => {
112
+ requestAnimationFrame(() => requestAnimationFrame(() => res()));
113
+ });
114
+ const fontsReady =
115
+ typeof document !== "undefined" && document.fonts
116
+ ? document.fonts.ready.then(() => undefined)
117
+ : Promise.resolve();
118
+ void Promise.all([framesSettled, fontsReady]).then(() => resolve());
119
+ });
120
+ });
121
+
122
+ return {
123
+ ready,
124
+ unmount() {
125
+ removeDiagnostics?.();
126
+ root.unmount();
127
+ },
128
+ };
129
+ }
@@ -64,7 +64,20 @@ export function KeyframePlayer({
64
64
  return (
65
65
  <motion.div
66
66
  key={replayTokenRef.current}
67
- style={{ display: "contents" }}
67
+ // A `display:contents` element generates NO box, so the browser
68
+ // never composites the animated `transform`/`opacity`/`filter` this
69
+ // player writes — they are silently dropped and the subtree renders
70
+ // dead at its child's default origin (ADR 011 I7 live bug). The
71
+ // player must be a REAL compositing box. `position:absolute; inset:0`
72
+ // overlays the parent without disturbing sibling layout, and — being
73
+ // positioned — becomes the containing block for the absolutely-
74
+ // positioned primitive nested beneath it (Frame is `position:absolute;
75
+ // left:0; top:0`), so the child's authored `x`/`y` resolve against the
76
+ // player's (0,0) exactly as they did against the grandparent under
77
+ // `display:contents`. The animated channels now composite onto a live
78
+ // box and the whole subtree (the nested target's geometry + fill)
79
+ // moves and fades with the keyframes.
80
+ style={{ position: "absolute", inset: 0 }}
68
81
  initial={firstFrame(compiled.animate)}
69
82
  animate={compiled.animate}
70
83
  transition={transition}