@lumencast/runtime 0.6.0 → 0.7.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 (113) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/broadcast-DUYqvcgo.js +12 -0
  3. package/dist/broadcast-DUYqvcgo.js.map +1 -0
  4. package/dist/control-CL8TWXaE.js +17 -0
  5. package/dist/control-CL8TWXaE.js.map +1 -0
  6. package/dist/{index-Crkij3C4.js → index-C6viWFcT.js} +42 -26
  7. package/dist/index-C6viWFcT.js.map +1 -0
  8. package/dist/index.html +1 -1
  9. package/dist/lumencast.js +1 -1
  10. package/dist/modes/broadcast.d.ts.map +1 -1
  11. package/dist/modes/broadcast.js +6 -1
  12. package/dist/modes/broadcast.js.map +1 -1
  13. package/dist/modes/control.d.ts.map +1 -1
  14. package/dist/modes/control.js +6 -1
  15. package/dist/modes/control.js.map +1 -1
  16. package/dist/modes/test.d.ts.map +1 -1
  17. package/dist/modes/test.js +2 -1
  18. package/dist/modes/test.js.map +1 -1
  19. package/dist/render/allowed-hosts.d.ts +41 -0
  20. package/dist/render/allowed-hosts.d.ts.map +1 -0
  21. package/dist/render/allowed-hosts.js +88 -0
  22. package/dist/render/allowed-hosts.js.map +1 -0
  23. package/dist/render/blend-mode.d.ts +7 -0
  24. package/dist/render/blend-mode.d.ts.map +1 -0
  25. package/dist/render/blend-mode.js +49 -0
  26. package/dist/render/blend-mode.js.map +1 -0
  27. package/dist/render/bundle.d.ts +9 -1
  28. package/dist/render/bundle.d.ts.map +1 -1
  29. package/dist/render/bundle.js.map +1 -1
  30. package/dist/render/fill.d.ts +36 -3
  31. package/dist/render/fill.d.ts.map +1 -1
  32. package/dist/render/fill.js +222 -23
  33. package/dist/render/fill.js.map +1 -1
  34. package/dist/render/mask.d.ts +87 -0
  35. package/dist/render/mask.d.ts.map +1 -0
  36. package/dist/render/mask.js +243 -0
  37. package/dist/render/mask.js.map +1 -0
  38. package/dist/render/primitives/frame.d.ts.map +1 -1
  39. package/dist/render/primitives/frame.js +91 -5
  40. package/dist/render/primitives/frame.js.map +1 -1
  41. package/dist/render/primitives/grid.d.ts +1 -1
  42. package/dist/render/primitives/grid.d.ts.map +1 -1
  43. package/dist/render/primitives/grid.js +4 -1
  44. package/dist/render/primitives/grid.js.map +1 -1
  45. package/dist/render/primitives/image.d.ts +8 -1
  46. package/dist/render/primitives/image.d.ts.map +1 -1
  47. package/dist/render/primitives/image.js +17 -3
  48. package/dist/render/primitives/image.js.map +1 -1
  49. package/dist/render/primitives/index.d.ts +7 -0
  50. package/dist/render/primitives/index.d.ts.map +1 -1
  51. package/dist/render/primitives/index.js.map +1 -1
  52. package/dist/render/primitives/shape.d.ts.map +1 -1
  53. package/dist/render/primitives/shape.js +29 -26
  54. package/dist/render/primitives/shape.js.map +1 -1
  55. package/dist/render/primitives/stack.d.ts +1 -1
  56. package/dist/render/primitives/stack.d.ts.map +1 -1
  57. package/dist/render/primitives/stack.js +5 -1
  58. package/dist/render/primitives/stack.js.map +1 -1
  59. package/dist/render/primitives/text.d.ts.map +1 -1
  60. package/dist/render/primitives/text.js +0 -1
  61. package/dist/render/primitives/text.js.map +1 -1
  62. package/dist/render/prop-allowlist.d.ts.map +1 -1
  63. package/dist/render/prop-allowlist.js +25 -2
  64. package/dist/render/prop-allowlist.js.map +1 -1
  65. package/dist/render/shape-geometry.d.ts +81 -0
  66. package/dist/render/shape-geometry.d.ts.map +1 -0
  67. package/dist/render/shape-geometry.js +199 -0
  68. package/dist/render/shape-geometry.js.map +1 -0
  69. package/dist/render/shape-index.d.ts +28 -0
  70. package/dist/render/shape-index.d.ts.map +1 -0
  71. package/dist/render/shape-index.js +77 -0
  72. package/dist/render/shape-index.js.map +1 -0
  73. package/dist/render/tree.d.ts.map +1 -1
  74. package/dist/render/tree.js +175 -3
  75. package/dist/render/tree.js.map +1 -1
  76. package/dist/render/universal-wrapper.d.ts +27 -1
  77. package/dist/render/universal-wrapper.d.ts.map +1 -1
  78. package/dist/render/universal-wrapper.js +98 -22
  79. package/dist/render/universal-wrapper.js.map +1 -1
  80. package/dist/{status-pill-BT5b-yET.js → status-pill-jJT54n07.js} +2 -2
  81. package/dist/{status-pill-BT5b-yET.js.map → status-pill-jJT54n07.js.map} +1 -1
  82. package/dist/{test-_hh1JvAd.js → test-84XodL1c.js} +51 -51
  83. package/dist/{test-_hh1JvAd.js.map → test-84XodL1c.js.map} +1 -1
  84. package/dist/tree-BIimahCf.js +1777 -0
  85. package/dist/tree-BIimahCf.js.map +1 -0
  86. package/package.json +4 -4
  87. package/src/modes/broadcast.tsx +12 -1
  88. package/src/modes/control.tsx +10 -1
  89. package/src/modes/test.tsx +4 -1
  90. package/src/render/allowed-hosts.tsx +100 -0
  91. package/src/render/blend-mode.ts +50 -0
  92. package/src/render/bundle.ts +6 -1
  93. package/src/render/fill.tsx +266 -24
  94. package/src/render/mask.tsx +389 -0
  95. package/src/render/primitives/frame.tsx +101 -5
  96. package/src/render/primitives/grid.tsx +4 -1
  97. package/src/render/primitives/image.tsx +17 -3
  98. package/src/render/primitives/index.ts +7 -0
  99. package/src/render/primitives/shape.tsx +39 -75
  100. package/src/render/primitives/stack.tsx +5 -1
  101. package/src/render/primitives/text.tsx +0 -1
  102. package/src/render/prop-allowlist.ts +25 -2
  103. package/src/render/shape-geometry.tsx +315 -0
  104. package/src/render/shape-index.tsx +90 -0
  105. package/src/render/tree.tsx +214 -12
  106. package/src/render/universal-wrapper.tsx +128 -21
  107. package/dist/broadcast-DO7jEkix.js +0 -11
  108. package/dist/broadcast-DO7jEkix.js.map +0 -1
  109. package/dist/control-BSfl4_cO.js +0 -16
  110. package/dist/control-BSfl4_cO.js.map +0 -1
  111. package/dist/index-Crkij3C4.js.map +0 -1
  112. package/dist/tree-DBj9SJgs.js +0 -1230
  113. package/dist/tree-DBj9SJgs.js.map +0 -1
@@ -15,6 +15,15 @@ import { StaggerContext, computeStaggerDelayMs } from "./stagger-context";
15
15
  import { useBindAnimate } from "./bind-animate";
16
16
  import { checkNodeProps } from "./prop-allowlist";
17
17
  import { emitDiagnostic } from "./diagnostics";
18
+ import { buildMask, parseMaskSpec, MASK_FEATHER_PAD } from "./mask";
19
+ import { parseBlendMode } from "./blend-mode";
20
+ import { useAllowedHosts } from "./allowed-hosts";
21
+ import { useShapeIndex } from "./shape-index";
22
+ import {
23
+ buildMaskCoverageFromShape,
24
+ buildMaskCoverageFromGroup,
25
+ coverageIsFeathered,
26
+ } from "./shape-geometry";
18
27
 
19
28
  export interface TreeProps {
20
29
  node: RenderNode;
@@ -34,6 +43,12 @@ function Node({ node, store }: TreeProps): ReactNode {
34
43
  // re-renders only fire on touched paths.
35
44
  useSignals();
36
45
  const scope = usePathScope();
46
+ // ADR 002 §3.2 (#E) — the bundle's host allowlist, for gating an image
47
+ // mask source (T1/T2). `undefined` = deny every remote host.
48
+ const allowedHosts = useAllowedHosts();
49
+ // ADR 002 A2.1 (#K) — the bundle-wide `id → shape` index, for resolving a
50
+ // `mask.source.kind:"shape"` ref to inlined coverage geometry.
51
+ const shapeIndex = useShapeIndex();
37
52
 
38
53
  // Hooks must run unconditionally — the early-return for unknown
39
54
  // kinds happens *after* every hook has fired.
@@ -94,14 +109,67 @@ function Node({ node, store }: TreeProps): ReactNode {
94
109
  // primitives. Pulled out of `resolved` so primitives can ignore
95
110
  // them ; the wrapper composes with whatever transform/opacity the
96
111
  // primitive's own framer-motion may apply.
112
+ //
113
+ // ADR 002 §3.1 (D1) — absolute placement. The compiler flattens LSML
114
+ // `position:{x,y}` → `resolved.x`/`resolved.y` and `size:{w,h}` →
115
+ // `resolved.width`/`resolved.height` on EVERY primitive (compile.ts
116
+ // §universal-props). The wrapper consumes them as absolute placement,
117
+ // EXCEPT on `frame` : a frame already positions itself (it reads
118
+ // `x`/`y`/`width`/`height` into its own absolute box + transform), so
119
+ // letting the wrapper pin it too would double the offset. Every other
120
+ // kind (text/shape/image/media/instance/stack/grid) is placed by the
121
+ // wrapper. A node without `x`/`y` gets `position: undefined` → normal
122
+ // flow (RC#2 non-regression).
123
+ // A masked node with a real blend hoists that blend above the mask wrapper
124
+ // (the mask isolates an inner `mix-blend-mode`). Compute it once here so the
125
+ // universal prop and the wrapper below agree.
126
+ const maskHoistsBlend =
127
+ resolved.mask !== undefined &&
128
+ typeof resolved.blendMode === "string" &&
129
+ parseBlendMode(resolved.blendMode) !== undefined;
97
130
  const universal = {
98
131
  visible: typeof resolved.visible === "boolean" ? resolved.visible : undefined,
99
132
  opacity:
100
133
  typeof resolved.universal_opacity === "number" ? resolved.universal_opacity : undefined,
101
- rotation: typeof resolved.rotation === "number" ? resolved.rotation : undefined,
134
+ // A frame applies its own static rotation (frame.tsx) so it pivots around
135
+ // its centre ; the wrapper has no box for a self-positioning frame and would
136
+ // pivot around a collapsed (0-height) box. Non-frames keep it on the wrapper
137
+ // (they DO carry position/size there).
138
+ rotation:
139
+ node.kind === "frame"
140
+ ? undefined
141
+ : typeof resolved.rotation === "number"
142
+ ? resolved.rotation
143
+ : undefined,
144
+ // Mirror (Figma scaleY(-1)) — like rotation, a frame mirrors itself
145
+ // (frame.tsx) ; non-frames carry it on the wrapper, composed with rotation.
146
+ flipY: node.kind === "frame" ? undefined : resolved.flipY === true,
147
+ blur: typeof resolved.blur === "number" ? resolved.blur : undefined,
102
148
  sizing: extractSizing(resolved.sizing),
149
+ position: node.kind === "frame" ? undefined : extractPosition(resolved),
150
+ size: node.kind === "frame" ? undefined : extractSize(resolved),
151
+ // ADR 002 §3.2 (D2 / #D) — `blendMode` is a universal prop on every
152
+ // primitive ; the wrapper re-validates it against the closed enum
153
+ // before applying `mix-blend-mode` (T4 runtime gate). Pass the raw
154
+ // resolved value through ; the wrapper omits anything off the enum.
155
+ // A blend on a MASKED node is hoisted ABOVE the mask wrapper (see below) —
156
+ // a CSS mask forms an isolating group, so a `mix-blend-mode` left on the
157
+ // (inner) wrapper would fold over a transparent backdrop (the caramel
158
+ // hard-light showed the raw blue wave instead of compositing over the warm
159
+ // gradient). Drop it here when it will be hoisted.
160
+ blendMode:
161
+ typeof resolved.blendMode === "string" && !maskHoistsBlend ? resolved.blendMode : undefined,
103
162
  };
104
163
 
164
+ // ADR 002 §3.1 (D1) — a container holding at least one absolutely
165
+ // positioned child must establish the containing block so the child's
166
+ // `left/top` resolve against it (and not a distant ancestor). `Frame`
167
+ // is already `position:absolute` ; `Stack`/`Grid` flip to
168
+ // `position:relative` only when needed (no change for pure auto-layout
169
+ // boards — RC#2). Threaded to the primitive so the layout container
170
+ // decides ; a node without absolute children is untouched.
171
+ const hasAbsoluteChild = node.children?.some(childIsAbsolute) ?? false;
172
+
105
173
  // Merge live-interpolated colour values (§6.5) over the resolved
106
174
  // props — the primitive re-validates them through `parseCssColor`.
107
175
  const resolvedWithColors =
@@ -109,19 +177,113 @@ function Node({ node, store }: TreeProps): ReactNode {
109
177
  ? { ...resolved, ...bindAnimate.colorProps }
110
178
  : resolved;
111
179
 
112
- let body = (
113
- <UniversalWrapper {...universal}>
114
- <Primitive
115
- resolved={resolvedWithColors}
116
- nodeId={node.id}
117
- transitionFor={transitionFor}
118
- animateInitial={node.animate_initial}
119
- >
120
- {children}
121
- </Primitive>
122
- </UniversalWrapper>
180
+ const primitiveEl = (
181
+ <Primitive
182
+ resolved={resolvedWithColors}
183
+ nodeId={node.id}
184
+ transitionFor={transitionFor}
185
+ animateInitial={node.animate_initial}
186
+ establishesContainingBlock={hasAbsoluteChild}
187
+ >
188
+ {children}
189
+ </Primitive>
123
190
  );
124
191
 
192
+ // ADR 002 §3.2 (#E) — a typed `mask` lowered onto the node. Build it up-front
193
+ // so an IMAGE mask (CSS `mask-image`) can sit INSIDE the wrapper — it must
194
+ // rotate WITH the content under the wrapper's transform, else an outer
195
+ // un-rotated mask clips a mis-rotated crop (the caramel wave shrank off-box).
196
+ // A group/shape SVG mask stays OUTSIDE (its coverage is authored in the parent
197
+ // coordinate space). The mask is built ENTIRELY from typed fields (T3) ; enums
198
+ // re-validated (T4), image source host-gated (T1/T2) before any `href`.
199
+ let built: ReturnType<typeof buildMask> | null = null;
200
+ if (resolved.mask !== undefined) {
201
+ const spec = parseMaskSpec(resolved.mask, node.id);
202
+ // #K/#O — resolve a ref to its inlined coverage geometry, routed on the
203
+ // referenced node's `kind` (shape → own outline ; frame → visible children).
204
+ const resolveShape = (ref: string) => {
205
+ const target = shapeIndex.get(ref);
206
+ if (!target) return null;
207
+ return target.kind === "frame"
208
+ ? buildMaskCoverageFromGroup(target, target.id)
209
+ : buildMaskCoverageFromShape(target, target.id);
210
+ };
211
+ // A FEATHERED coverage (a blurred mask edge, e.g. the bg-texture ellipse) is
212
+ // the only case that needs the wrapper feather pad. Detect it from the mask
213
+ // SOURCE group's children so a sharp mask skips the pad entirely (no extra
214
+ // wrapper, no structural change).
215
+ let feather = false;
216
+ if (spec) {
217
+ const src = spec.source as { kind?: string; ref?: unknown };
218
+ if ((src.kind === "group" || src.kind === "shape") && typeof src.ref === "string") {
219
+ const t = shapeIndex.get(src.ref);
220
+ feather = t ? coverageIsFeathered(t) : false;
221
+ }
222
+ }
223
+ built = spec
224
+ ? buildMask(spec, allowedHosts, node.id, resolveShape, extractSize(resolved), feather)
225
+ : null;
226
+ }
227
+ const isImageMask =
228
+ built !== null && built.style != null && "maskImage" in (built.style as object);
229
+
230
+ let inner: ReactNode = primitiveEl;
231
+ if (built && isImageMask) {
232
+ // Image mask co-located with the content : the CSS mask-image is on a box the
233
+ // wrapper's transform rotates, keeping its alpha aligned to the wave.
234
+ inner = <div style={{ width: "100%", height: "100%", ...built.style }}>{inner}</div>;
235
+ }
236
+
237
+ let body = <UniversalWrapper {...universal}>{inner}</UniversalWrapper>;
238
+
239
+ if (built && !isImageMask) {
240
+ // The (group/shape) mask wrapper MUST own a real box for the CSS mask to clip
241
+ // anything : its content is `position:absolute`, so fill the parent's
242
+ // containing block to share the absolutely-placed body's coordinate space.
243
+ // `overflow:hidden` bounds the (oversized) masked content to this box — the
244
+ // CSS `mask` alone does NOT clip it (removing it leaked the 2786×1491 tile
245
+ // group everywhere).
246
+ // Only a FEATHERED mask grows the box (+ shifts the coverage, mask.tsx) ;
247
+ // a sharp mask uses pad 0 → inset:0, identical to the un-padded structure.
248
+ const pad = built.feather ? MASK_FEATHER_PAD : 0;
249
+ body = (
250
+ <div
251
+ style={{
252
+ position: "absolute",
253
+ inset: -pad,
254
+ overflow: "hidden",
255
+ ...built.style,
256
+ }}
257
+ >
258
+ <svg width={0} height={0} style={{ position: "absolute" }} aria-hidden>
259
+ <defs>{built.def}</defs>
260
+ </svg>
261
+ {/* Inner box inset by +PAD cancels the wrapper's −PAD grow for the
262
+ CONTENT — the body keeps its original coordinates while the masked
263
+ box (and its feathered rim) is the bigger one. */}
264
+ <div style={{ position: "absolute", inset: pad }}>{body}</div>
265
+ </div>
266
+ );
267
+ }
268
+ // Hoist the node's blend ABOVE the wrapper+mask : an outer box carrying ONLY
269
+ // `mix-blend-mode` (no transform / opacity / filter / mask) so it composites
270
+ // the masked result with the SCENE backdrop. The caramel 3d-render then
271
+ // hard-lights over the warm gradient (orange) instead of its raw image (blue).
272
+ if (built && maskHoistsBlend) {
273
+ const hoisted = parseBlendMode(resolved.blendMode);
274
+ body = (
275
+ <div
276
+ style={{
277
+ position: "absolute",
278
+ inset: 0,
279
+ mixBlendMode: hoisted as React.CSSProperties["mixBlendMode"],
280
+ }}
281
+ >
282
+ {body}
283
+ </div>
284
+ );
285
+ }
286
+
125
287
  // Scalar bindAnimate channels apply on a wrapping motion.div (same
126
288
  // composition model as UniversalWrapper). Motion values mutate the
127
289
  // style directly — zero React re-render per frame on the hot path.
@@ -156,6 +318,46 @@ function extractSizing(value: unknown): { x?: SizingMode; y?: SizingMode } | und
156
318
  return out.x !== undefined || out.y !== undefined ? out : undefined;
157
319
  }
158
320
 
321
+ function finite(v: unknown): number | undefined {
322
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
323
+ }
324
+
325
+ /** ADR 002 §3.1 (D1) — form the absolute-placement `position:{x,y}` from
326
+ * the compiler-flattened `resolved.x`/`resolved.y`. BOTH axes must be
327
+ * finite numbers : a partial or malformed pair yields `undefined` (the
328
+ * node stays in the normal flow — RC#3 mistyped-position is inert, not
329
+ * injected). Values are plain numbers, never untrusted strings. */
330
+ function extractPosition(resolved: Record<string, unknown>): { x: number; y: number } | undefined {
331
+ const x = finite(resolved.x);
332
+ const y = finite(resolved.y);
333
+ if (x === undefined || y === undefined) return undefined;
334
+ return { x, y };
335
+ }
336
+
337
+ /** ADR 002 §3.1 (D1) — the absolute box size from `resolved.width`/
338
+ * `resolved.height`. Only meaningful alongside `position` (the wrapper
339
+ * ignores it otherwise). Partial sizes are allowed (one axis hugs). */
340
+ function extractSize(resolved: Record<string, unknown>): { w?: number; h?: number } | undefined {
341
+ const w = finite(resolved.width);
342
+ const h = finite(resolved.height);
343
+ if (w === undefined && h === undefined) return undefined;
344
+ return { w, h };
345
+ }
346
+
347
+ /** True when a child node carries a finite `{x,y}` absolute position
348
+ * (static prop OR a bound `x`/`y`). Used by a container primitive to
349
+ * decide whether to establish a positioned containing block. A bound
350
+ * position is treated as "absolute" structurally — the key presence is
351
+ * static even if the value moves live. */
352
+ function childIsAbsolute(child: RenderNode): boolean {
353
+ if (child.kind === "frame") return false; // a frame positions itself
354
+ const props = child.props ?? {};
355
+ const bindings = child.bindings ?? {};
356
+ const hasX = finite(props.x) !== undefined || "x" in bindings;
357
+ const hasY = finite(props.y) !== undefined || "y" in bindings;
358
+ return hasX && hasY;
359
+ }
360
+
159
361
  function Repeat({ node, store }: TreeProps): ReactNode {
160
362
  useSignals();
161
363
  const scope = usePathScope();
@@ -1,6 +1,6 @@
1
1
  // Universal-props wrapper (LSML 1.1 §5.4).
2
2
  //
3
- // Every primitive renders inside this wrapper, which applies the four
3
+ // Every primitive renders inside this wrapper, which applies the
4
4
  // universal props uniformly :
5
5
  //
6
6
  // - `visible: false` → display: none (slot collapses in flex layouts)
@@ -9,6 +9,15 @@
9
9
  // - `rotation` → CSS transform: rotate(<deg>)
10
10
  // - `sizing.x`/`sizing.y` → flex shorthand on the wrapping div, lets
11
11
  // a primitive participate in its parent flex layout's auto-sizing
12
+ // - `position.{x,y}` → absolute placement relative to the nearest
13
+ // positioned ancestor (ADR 002 §3.1 / D1) : a child carrying
14
+ // `position` is taken out of the normal flow and pinned at
15
+ // `left:x; top:y`. A child WITHOUT `position` keeps the normal flow
16
+ // untouched (auto-layout intact) — this is the Figma free-form vs
17
+ // auto-layout duality, honoured at render. `size.{w,h}` fixes the
18
+ // absolute box (the rating square's 24×7 / 14×22 text boxes) ;
19
+ // omitted → hug the content. Position is a static layout property and
20
+ // never animates (it stays off the 0-layout-event broadcast hot path).
12
21
  //
13
22
  // `bindUniversal` is resolved by the Tree renderer before the wrapper
14
23
  // sees its values, so this component only deals with concrete numbers
@@ -16,13 +25,35 @@
16
25
 
17
26
  import type { ReactNode, CSSProperties } from "react";
18
27
 
28
+ import { parseBlendMode } from "./blend-mode";
29
+
19
30
  export type SizingMode = "fixed" | "hug" | "fill";
20
31
 
21
32
  export interface UniversalProps {
22
33
  visible?: boolean;
23
34
  opacity?: number;
24
35
  rotation?: number;
36
+ /** Mirror across the local X axis (Figma `scaleY(-1)`, negative-determinant
37
+ * transform). Composed with `rotation` on the wrapper so image/shape leaves
38
+ * mirror like frames do — without it the caramel 3d-render rendered as the
39
+ * un-mirrored wave (blue where Figma is orange). */
40
+ flipY?: boolean;
41
+ /** Figma LAYER_BLUR radius (px) → CSS `filter: blur()`. */
42
+ blur?: number;
25
43
  sizing?: { x?: SizingMode; y?: SizingMode };
44
+ /** ADR 002 §3.1 (D1) — parent-relative absolute placement. When set,
45
+ * the wrapper pins the primitive at `left:x; top:y` (position:absolute)
46
+ * instead of leaving it in the normal flow. Both axes are required
47
+ * (the Tree only forms this object from a finite `{x,y}` pair). */
48
+ position?: { x: number; y: number };
49
+ /** ADR 002 §3.1 (D1) — the absolute box's fixed size, applied only
50
+ * alongside `position`. Omitted → the box hugs its content. */
51
+ size?: { w?: number; h?: number };
52
+ /** ADR 002 §3.2 (D2 / #D) — a Figma blend mode → CSS `mix-blend-mode`.
53
+ * The value is re-validated against the closed enum at render
54
+ * (`parseBlendMode`, T4 double-gate) ; anything outside the allowlist
55
+ * is omitted, never written to the style. */
56
+ blendMode?: string;
26
57
  }
27
58
 
28
59
  export interface UniversalWrapperProps extends UniversalProps {
@@ -48,48 +79,124 @@ function flexFor(mode: SizingMode | undefined): string | undefined {
48
79
  }
49
80
  }
50
81
 
82
+ /** Collapse a {x,y} sizing pair to a single `flex` shorthand. When both axes
83
+ * agree, that value ; otherwise honour x (horizontal stacks dominate broadcast
84
+ * boards — the renderer doesn't know the parent's axis here). */
85
+ function sizingToFlex(sizing: { x?: SizingMode; y?: SizingMode } | undefined): string | undefined {
86
+ const x = flexFor(sizing?.x);
87
+ const y = flexFor(sizing?.y);
88
+ if (x === y && x !== undefined) return x;
89
+ return x ?? y;
90
+ }
91
+
51
92
  export function UniversalWrapper({
52
93
  visible,
53
94
  opacity,
54
95
  rotation,
96
+ flipY,
97
+ blur,
55
98
  sizing,
99
+ position,
100
+ size,
101
+ blendMode,
56
102
  children,
57
103
  }: UniversalWrapperProps) {
58
104
  if (visible === false) {
59
105
  return null; // slot collapses in flex/grid layouts (§5.4)
60
106
  }
61
107
 
108
+ // ADR 002 §3.2 (D2 / #D) — re-validate the blend mode against the
109
+ // closed enum at render (T4 runtime gate). A recognised mode yields a
110
+ // CSS `mix-blend-mode` keyword ; anything else is `undefined` and never
111
+ // reaches the style (no free CSS string, no passthrough).
112
+ const mixBlendMode = parseBlendMode(blendMode);
62
113
  // No-op fast path — when no universal props are set, render children
63
114
  // directly. Lets simple bundles avoid an extra DOM node per primitive.
115
+ // A child WITHOUT `position` never enters the absolute branch, so the
116
+ // normal flow (auto-layout) is left exactly as before (ADR 002 §3.1
117
+ // non-regression : RC#2).
64
118
  const hasOpacity = typeof opacity === "number" && opacity !== 1;
65
119
  const hasRotation = typeof rotation === "number" && rotation !== 0;
120
+ const hasFlipY = flipY === true;
121
+ const hasBlur = typeof blur === "number" && blur > 0;
66
122
  const hasSizing = sizing?.x !== undefined || sizing?.y !== undefined;
67
- if (!hasOpacity && !hasRotation && !hasSizing) {
123
+ const hasPosition = position !== undefined;
124
+ const hasBlendMode = mixBlendMode !== undefined;
125
+ if (
126
+ !hasOpacity &&
127
+ !hasRotation &&
128
+ !hasFlipY &&
129
+ !hasBlur &&
130
+ !hasSizing &&
131
+ !hasPosition &&
132
+ !hasBlendMode
133
+ ) {
68
134
  return <>{children}</>;
69
135
  }
70
136
 
71
- const style: CSSProperties = {};
72
- if (hasOpacity) style.opacity = opacity;
73
- if (hasRotation) style.transform = `rotate(${rotation}deg)`;
137
+ // Build the transform string (rotation + mirror). `rotate(θ) scaleY(-1)`
138
+ // applies the mirror first (rightmost), then the rotation — matching Figma's
139
+ // `rotate·scaleY(-1)` matrix (the caramel's −114° + mirror).
140
+ let transform: string | undefined;
141
+ if (hasRotation || hasFlipY) {
142
+ const parts: string[] = [];
143
+ if (hasRotation) parts.push(`rotate(${rotation}deg)`);
144
+ if (hasFlipY) parts.push("scaleY(-1)");
145
+ transform = parts.join(" ");
146
+ }
147
+ // Figma LAYER_BLUR → CSS blur (radius ≈ 2× the CSS sigma, measured on 817:3).
148
+ // (A gamma-correct linearRGB blur was tried to close the bg-shine corner's ~23 R
149
+ // deficit vs the Figma PNG ; it measured WORSE — the deficit is in the high-R
150
+ // channel, which is gamma-INVARIANT — and supersampling the render closed that
151
+ // corner anyway. The Chromium(sRGB)≠Figma(linearRGB) blur gap is else irreducible.)
152
+ const filter = hasBlur ? `blur(${blur / 2}px)` : undefined;
74
153
 
75
- // sizing.x / sizing.y map to flex / row-flex behaviour. The
76
- // x-axis applies along the main axis of a horizontal stack ; the
77
- // y-axis along a vertical stack. We emit `flex` (covers both via
78
- // CSS's flex-direction) and rely on the parent stack for orientation.
79
- if (hasSizing) {
80
- const x = flexFor(sizing?.x);
81
- const y = flexFor(sizing?.y);
82
- // Emit a single flex declaration when both axes agree, otherwise
83
- // ship explicit grow/shrink/basis based on the dominant intent.
84
- if (x === y && x !== undefined) {
85
- style.flex = x;
86
- } else {
87
- // Heuristic : honour x for horizontal stacks (most common in
88
- // broadcast UIs). Renderer doesn't know the parent's axis here ;
89
- // a future iteration could thread that through context.
90
- style.flex = x ?? y;
154
+ const sizingFlex = hasSizing ? sizingToFlex(sizing) : undefined;
155
+
156
+ // A `mix-blend-mode` composites with the SCENE backdrop only when its element
157
+ // does not also form an isolating group. `transform`, `opacity < 1` and
158
+ // `filter` each force the element into its own group, so the blend would fold
159
+ // over a TRANSPARENT backdrop instead — the caramel hard-light then shows the
160
+ // raw blue wave rather than compositing over the warm gradient, a screen layer
161
+ // silently no-ops. When the node needs BOTH a blend and one of those, SPLIT:
162
+ // the blend (+ absolute placement) lives on the OUTER box, the isolating
163
+ // transform/opacity/blur on an INNER box that carries the sized content.
164
+ if (hasBlendMode && (hasOpacity || transform !== undefined || filter !== undefined)) {
165
+ const outer: CSSProperties = { mixBlendMode: mixBlendMode as CSSProperties["mixBlendMode"] };
166
+ if (hasPosition) {
167
+ outer.position = "absolute";
168
+ outer.left = position.x;
169
+ outer.top = position.y;
91
170
  }
171
+ if (sizingFlex !== undefined) outer.flex = sizingFlex;
172
+ const inner: CSSProperties = {};
173
+ if (typeof size?.w === "number") inner.width = size.w;
174
+ if (typeof size?.h === "number") inner.height = size.h;
175
+ if (hasOpacity) inner.opacity = opacity;
176
+ if (transform !== undefined) inner.transform = transform;
177
+ if (filter !== undefined) inner.filter = filter;
178
+ return (
179
+ <div style={outer}>
180
+ <div style={inner}>{children}</div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ const style: CSSProperties = {};
186
+ if (hasOpacity) style.opacity = opacity;
187
+ if (transform !== undefined) style.transform = transform;
188
+ if (filter !== undefined) style.filter = filter;
189
+ if (hasBlendMode) style.mixBlendMode = mixBlendMode as CSSProperties["mixBlendMode"];
190
+ // ADR 002 §3.1 (D1) — absolute placement relative to the nearest positioned
191
+ // ancestor. `size` (when present) fixes the box ; otherwise it hugs content.
192
+ if (hasPosition) {
193
+ style.position = "absolute";
194
+ style.left = position.x;
195
+ style.top = position.y;
196
+ if (typeof size?.w === "number") style.width = size.w;
197
+ if (typeof size?.h === "number") style.height = size.h;
92
198
  }
199
+ if (sizingFlex !== undefined) style.flex = sizingFlex;
93
200
 
94
201
  return <div style={style}>{children}</div>;
95
202
  }
@@ -1,11 +0,0 @@
1
- import { jsx as t } from "react/jsx-runtime";
2
- import { T as e } from "./tree-DBj9SJgs.js";
3
- import { u as m } from "./index-Crkij3C4.js";
4
- function a() {
5
- const { store: o, bundle: r } = m();
6
- return /* @__PURE__ */ t(e, { node: r.root, store: o });
7
- }
8
- export {
9
- a as BroadcastMode
10
- };
11
- //# sourceMappingURL=broadcast-DO7jEkix.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"broadcast-DO7jEkix.js","sources":["../src/modes/broadcast.tsx"],"sourcesContent":["import { Tree } from \"../render/tree\";\nimport { useLumencastRuntime } from \"../overlay/runtime-context\";\n\n/** Broadcast mode : pure scene render, no UI chrome. */\nexport function BroadcastMode() {\n const { store, bundle } = useLumencastRuntime();\n return <Tree node={bundle.root} store={store} />;\n}\n"],"names":["BroadcastMode","store","bundle","useLumencastRuntime","jsx","Tree"],"mappings":";;;AAIO,SAASA,IAAgB;AAC9B,QAAM,EAAE,OAAAC,GAAO,QAAAC,EAAA,IAAWC,EAAA;AAC1B,SAAO,gBAAAC,EAACC,GAAA,EAAK,MAAMH,EAAO,MAAM,OAAAD,GAAc;AAChD;"}
@@ -1,16 +0,0 @@
1
- import { jsxs as e, Fragment as n, jsx as o } from "react/jsx-runtime";
2
- import { T as s } from "./tree-DBj9SJgs.js";
3
- import { S as m, C as a } from "./status-pill-BT5b-yET.js";
4
- import { u as i } from "./index-Crkij3C4.js";
5
- function c() {
6
- const { store: r, bundle: t } = i();
7
- return /* @__PURE__ */ e(n, { children: [
8
- /* @__PURE__ */ o(s, { node: t.root, store: r }),
9
- /* @__PURE__ */ o(m, {}),
10
- /* @__PURE__ */ o(a, {})
11
- ] });
12
- }
13
- export {
14
- c as ControlMode
15
- };
16
- //# sourceMappingURL=control-BSfl4_cO.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"control-BSfl4_cO.js","sources":["../src/modes/control.tsx"],"sourcesContent":["import { Tree } from \"../render/tree\";\nimport { ControlPanel } from \"../overlay/control\";\nimport { StatusPill } from \"../overlay/status-pill\";\nimport { useLumencastRuntime } from \"../overlay/runtime-context\";\n\n/** Control mode : scene + operator overlay (status pill + fields\n * panel from operator_inputs). */\nexport function ControlMode() {\n const { store, bundle } = useLumencastRuntime();\n return (\n <>\n <Tree node={bundle.root} store={store} />\n <StatusPill />\n <ControlPanel />\n </>\n );\n}\n"],"names":["ControlMode","store","bundle","useLumencastRuntime","jsxs","Fragment","jsx","Tree","StatusPill","ControlPanel"],"mappings":";;;;AAOO,SAASA,IAAc;AAC5B,QAAM,EAAE,OAAAC,GAAO,QAAAC,EAAA,IAAWC,EAAA;AAC1B,SACE,gBAAAC,EAAAC,GAAA,EACE,UAAA;AAAA,IAAA,gBAAAC,EAACC,GAAA,EAAK,MAAML,EAAO,MAAM,OAAAD,GAAc;AAAA,sBACtCO,GAAA,EAAW;AAAA,sBACXC,GAAA,CAAA,CAAa;AAAA,EAAA,GAChB;AAEJ;"}