@lumencast/runtime 0.5.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/broadcast-DUYqvcgo.js +12 -0
- package/dist/broadcast-DUYqvcgo.js.map +1 -0
- package/dist/control-CL8TWXaE.js +17 -0
- package/dist/control-CL8TWXaE.js.map +1 -0
- package/dist/{index-CyOlpZAL.js → index-C6viWFcT.js} +216 -182
- package/dist/index-C6viWFcT.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/lumencast.js +1 -1
- package/dist/modes/broadcast.d.ts.map +1 -1
- package/dist/modes/broadcast.js +6 -1
- package/dist/modes/broadcast.js.map +1 -1
- package/dist/modes/control.d.ts.map +1 -1
- package/dist/modes/control.js +6 -1
- package/dist/modes/control.js.map +1 -1
- package/dist/modes/test.d.ts.map +1 -1
- package/dist/modes/test.js +2 -1
- package/dist/modes/test.js.map +1 -1
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +5 -0
- package/dist/mount.js.map +1 -1
- package/dist/render/allowed-hosts.d.ts +41 -0
- package/dist/render/allowed-hosts.d.ts.map +1 -0
- package/dist/render/allowed-hosts.js +88 -0
- package/dist/render/allowed-hosts.js.map +1 -0
- package/dist/render/blend-mode.d.ts +7 -0
- package/dist/render/blend-mode.d.ts.map +1 -0
- package/dist/render/blend-mode.js +49 -0
- package/dist/render/blend-mode.js.map +1 -0
- package/dist/render/bundle.d.ts +17 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js +15 -1
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/fill.d.ts +36 -3
- package/dist/render/fill.d.ts.map +1 -1
- package/dist/render/fill.js +222 -23
- package/dist/render/fill.js.map +1 -1
- package/dist/render/mask.d.ts +87 -0
- package/dist/render/mask.d.ts.map +1 -0
- package/dist/render/mask.js +243 -0
- package/dist/render/mask.js.map +1 -0
- package/dist/render/primitives/frame.d.ts.map +1 -1
- package/dist/render/primitives/frame.js +91 -5
- package/dist/render/primitives/frame.js.map +1 -1
- package/dist/render/primitives/grid.d.ts +1 -1
- package/dist/render/primitives/grid.d.ts.map +1 -1
- package/dist/render/primitives/grid.js +4 -1
- package/dist/render/primitives/grid.js.map +1 -1
- package/dist/render/primitives/image.d.ts +8 -1
- package/dist/render/primitives/image.d.ts.map +1 -1
- package/dist/render/primitives/image.js +17 -3
- package/dist/render/primitives/image.js.map +1 -1
- package/dist/render/primitives/index.d.ts +7 -0
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/primitives/shape.d.ts.map +1 -1
- package/dist/render/primitives/shape.js +29 -26
- package/dist/render/primitives/shape.js.map +1 -1
- package/dist/render/primitives/stack.d.ts +1 -1
- package/dist/render/primitives/stack.d.ts.map +1 -1
- package/dist/render/primitives/stack.js +5 -1
- package/dist/render/primitives/stack.js.map +1 -1
- package/dist/render/primitives/text.d.ts.map +1 -1
- package/dist/render/primitives/text.js +0 -1
- package/dist/render/primitives/text.js.map +1 -1
- package/dist/render/prop-allowlist.d.ts.map +1 -1
- package/dist/render/prop-allowlist.js +25 -2
- package/dist/render/prop-allowlist.js.map +1 -1
- package/dist/render/shape-geometry.d.ts +81 -0
- package/dist/render/shape-geometry.d.ts.map +1 -0
- package/dist/render/shape-geometry.js +199 -0
- package/dist/render/shape-geometry.js.map +1 -0
- package/dist/render/shape-index.d.ts +28 -0
- package/dist/render/shape-index.d.ts.map +1 -0
- package/dist/render/shape-index.js +77 -0
- package/dist/render/shape-index.js.map +1 -0
- package/dist/render/tree.d.ts.map +1 -1
- package/dist/render/tree.js +175 -3
- package/dist/render/tree.js.map +1 -1
- package/dist/render/universal-wrapper.d.ts +27 -1
- package/dist/render/universal-wrapper.d.ts.map +1 -1
- package/dist/render/universal-wrapper.js +98 -22
- package/dist/render/universal-wrapper.js.map +1 -1
- package/dist/{status-pill-DIpXc5du.js → status-pill-jJT54n07.js} +2 -2
- package/dist/{status-pill-DIpXc5du.js.map → status-pill-jJT54n07.js.map} +1 -1
- package/dist/{test-ByRec1kd.js → test-84XodL1c.js} +51 -51
- package/dist/{test-ByRec1kd.js.map → test-84XodL1c.js.map} +1 -1
- package/dist/transport/ws.d.ts +5 -0
- package/dist/transport/ws.d.ts.map +1 -1
- package/dist/transport/ws.js +7 -0
- package/dist/transport/ws.js.map +1 -1
- package/dist/tree-BIimahCf.js +1777 -0
- package/dist/tree-BIimahCf.js.map +1 -0
- package/package.json +4 -4
- package/src/modes/broadcast.tsx +12 -1
- package/src/modes/control.tsx +10 -1
- package/src/modes/test.tsx +4 -1
- package/src/mount.ts +5 -0
- package/src/render/allowed-hosts.tsx +100 -0
- package/src/render/blend-mode.ts +50 -0
- package/src/render/bundle.ts +28 -2
- package/src/render/fill.tsx +266 -24
- package/src/render/mask.tsx +389 -0
- package/src/render/primitives/frame.tsx +101 -5
- package/src/render/primitives/grid.tsx +4 -1
- package/src/render/primitives/image.tsx +17 -3
- package/src/render/primitives/index.ts +7 -0
- package/src/render/primitives/shape.tsx +39 -75
- package/src/render/primitives/stack.tsx +5 -1
- package/src/render/primitives/text.tsx +0 -1
- package/src/render/prop-allowlist.ts +25 -2
- package/src/render/shape-geometry.tsx +315 -0
- package/src/render/shape-index.tsx +90 -0
- package/src/render/tree.tsx +214 -12
- package/src/render/universal-wrapper.tsx +128 -21
- package/src/transport/ws.ts +8 -0
- package/dist/broadcast-3vYij4k-.js +0 -11
- package/dist/broadcast-3vYij4k-.js.map +0 -1
- package/dist/control-BFNkY7-6.js +0 -16
- package/dist/control-BFNkY7-6.js.map +0 -1
- package/dist/index-CyOlpZAL.js.map +0 -1
- package/dist/tree-D5wYHpPu.js +0 -1230
- package/dist/tree-D5wYHpPu.js.map +0 -1
package/src/render/tree.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|
package/src/transport/ws.ts
CHANGED
|
@@ -126,6 +126,14 @@ export class WsClient {
|
|
|
126
126
|
void this.openSocket();
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** Resolve the current session token (the one used for the WS
|
|
130
|
+
* subscription). Mirrors `setToken` swaps. Used by the bundle fetcher to
|
|
131
|
+
* authenticate the render-bundle GET with the same credential. A
|
|
132
|
+
* `LumencastTokenProvider` is awaited. */
|
|
133
|
+
resolveCurrentToken(): Promise<string> {
|
|
134
|
+
return resolveToken(this.token);
|
|
135
|
+
}
|
|
136
|
+
|
|
129
137
|
/** Send `input` patches to the server. No-op if not connected. */
|
|
130
138
|
sendInput(patches: Patch[]): void {
|
|
131
139
|
if (!this.socket || this.socket.readyState !== this.WebSocketCtor.OPEN) return;
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { jsx as t } from "react/jsx-runtime";
|
|
2
|
-
import { T as e } from "./tree-D5wYHpPu.js";
|
|
3
|
-
import { u as m } from "./index-CyOlpZAL.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-3vYij4k-.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"broadcast-3vYij4k-.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;"}
|
package/dist/control-BFNkY7-6.js
DELETED
|
@@ -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-D5wYHpPu.js";
|
|
3
|
-
import { S as m, C as a } from "./status-pill-DIpXc5du.js";
|
|
4
|
-
import { u as i } from "./index-CyOlpZAL.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-BFNkY7-6.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"control-BFNkY7-6.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;"}
|