@lumencast/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +79 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/animate/crossfade.d.ts +13 -0
- package/dist/animate/crossfade.d.ts.map +1 -0
- package/dist/animate/crossfade.js +10 -0
- package/dist/animate/crossfade.js.map +1 -0
- package/dist/animate/keyframes.d.ts +42 -0
- package/dist/animate/keyframes.d.ts.map +1 -0
- package/dist/animate/keyframes.js +94 -0
- package/dist/animate/keyframes.js.map +1 -0
- package/dist/animate/transitions.d.ts +38 -0
- package/dist/animate/transitions.d.ts.map +1 -0
- package/dist/animate/transitions.js +81 -0
- package/dist/animate/transitions.js.map +1 -0
- package/dist/app.d.ts +16 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/broadcast-BqOhSNsY.js +11 -0
- package/dist/broadcast-BqOhSNsY.js.map +1 -0
- package/dist/control-CRFn328D.js +16 -0
- package/dist/control-CRFn328D.js.map +1 -0
- package/dist/dev-entry.d.ts +2 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +31 -0
- package/dist/dev-entry.js.map +1 -0
- package/dist/index-DUhPPRvw.js +583 -0
- package/dist/index-DUhPPRvw.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.html +46 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/validate-options.d.ts +5 -0
- package/dist/internal/validate-options.d.ts.map +1 -0
- package/dist/internal/validate-options.js +19 -0
- package/dist/internal/validate-options.js.map +1 -0
- package/dist/lumencast.js +5 -0
- package/dist/lumencast.js.map +1 -0
- package/dist/modes/broadcast.d.ts +3 -0
- package/dist/modes/broadcast.d.ts.map +1 -0
- package/dist/modes/broadcast.js +9 -0
- package/dist/modes/broadcast.js.map +1 -0
- package/dist/modes/control.d.ts +4 -0
- package/dist/modes/control.d.ts.map +1 -0
- package/dist/modes/control.js +12 -0
- package/dist/modes/control.js.map +1 -0
- package/dist/modes/test.d.ts +4 -0
- package/dist/modes/test.d.ts.map +1 -0
- package/dist/modes/test.js +13 -0
- package/dist/modes/test.js.map +1 -0
- package/dist/mount.d.ts +3 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +144 -0
- package/dist/mount.js.map +1 -0
- package/dist/overlay/control.d.ts +2 -0
- package/dist/overlay/control.d.ts.map +1 -0
- package/dist/overlay/control.js +127 -0
- package/dist/overlay/control.js.map +1 -0
- package/dist/overlay/runtime-context.d.ts +20 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -0
- package/dist/overlay/runtime-context.js +14 -0
- package/dist/overlay/runtime-context.js.map +1 -0
- package/dist/overlay/status-pill.d.ts +2 -0
- package/dist/overlay/status-pill.d.ts.map +1 -0
- package/dist/overlay/status-pill.js +29 -0
- package/dist/overlay/status-pill.js.map +1 -0
- package/dist/overlay/test.d.ts +5 -0
- package/dist/overlay/test.d.ts.map +1 -0
- package/dist/overlay/test.js +116 -0
- package/dist/overlay/test.js.map +1 -0
- package/dist/render/bundle.d.ts +102 -0
- package/dist/render/bundle.d.ts.map +1 -0
- package/dist/render/bundle.js +86 -0
- package/dist/render/bundle.js.map +1 -0
- package/dist/render/fill.d.ts +41 -0
- package/dist/render/fill.d.ts.map +1 -0
- package/dist/render/fill.js +95 -0
- package/dist/render/fill.js.map +1 -0
- package/dist/render/keyframe-player.d.ts +10 -0
- package/dist/render/keyframe-player.d.ts.map +1 -0
- package/dist/render/keyframe-player.js +65 -0
- package/dist/render/keyframe-player.js.map +1 -0
- package/dist/render/primitives/frame.d.ts +12 -0
- package/dist/render/primitives/frame.d.ts.map +1 -0
- package/dist/render/primitives/frame.js +65 -0
- package/dist/render/primitives/frame.js.map +1 -0
- package/dist/render/primitives/grid.d.ts +4 -0
- package/dist/render/primitives/grid.d.ts.map +1 -0
- package/dist/render/primitives/grid.js +14 -0
- package/dist/render/primitives/grid.js.map +1 -0
- package/dist/render/primitives/image.d.ts +5 -0
- package/dist/render/primitives/image.d.ts.map +1 -0
- package/dist/render/primitives/image.js +25 -0
- package/dist/render/primitives/image.js.map +1 -0
- package/dist/render/primitives/index.d.ts +10 -0
- package/dist/render/primitives/index.d.ts.map +1 -0
- package/dist/render/primitives/index.js +22 -0
- package/dist/render/primitives/index.js.map +1 -0
- package/dist/render/primitives/instance.d.ts +4 -0
- package/dist/render/primitives/instance.d.ts.map +1 -0
- package/dist/render/primitives/instance.js +35 -0
- package/dist/render/primitives/instance.js.map +1 -0
- package/dist/render/primitives/media.d.ts +6 -0
- package/dist/render/primitives/media.d.ts.map +1 -0
- package/dist/render/primitives/media.js +19 -0
- package/dist/render/primitives/media.js.map +1 -0
- package/dist/render/primitives/shape.d.ts +12 -0
- package/dist/render/primitives/shape.d.ts.map +1 -0
- package/dist/render/primitives/shape.js +66 -0
- package/dist/render/primitives/shape.js.map +1 -0
- package/dist/render/primitives/stack.d.ts +13 -0
- package/dist/render/primitives/stack.d.ts.map +1 -0
- package/dist/render/primitives/stack.js +45 -0
- package/dist/render/primitives/stack.js.map +1 -0
- package/dist/render/primitives/text.d.ts +6 -0
- package/dist/render/primitives/text.d.ts.map +1 -0
- package/dist/render/primitives/text.js +27 -0
- package/dist/render/primitives/text.js.map +1 -0
- package/dist/render/scope.d.ts +10 -0
- package/dist/render/scope.d.ts.map +1 -0
- package/dist/render/scope.js +27 -0
- package/dist/render/scope.js.map +1 -0
- package/dist/render/stagger-context.d.ts +9 -0
- package/dist/render/stagger-context.d.ts.map +1 -0
- package/dist/render/stagger-context.js +22 -0
- package/dist/render/stagger-context.js.map +1 -0
- package/dist/render/tree.d.ts +9 -0
- package/dist/render/tree.d.ts.map +1 -0
- package/dist/render/tree.js +139 -0
- package/dist/render/tree.js.map +1 -0
- package/dist/render/universal-wrapper.d.ts +16 -0
- package/dist/render/universal-wrapper.d.ts.map +1 -0
- package/dist/render/universal-wrapper.js +58 -0
- package/dist/render/universal-wrapper.js.map +1 -0
- package/dist/state/apply-delta.d.ts +11 -0
- package/dist/state/apply-delta.d.ts.map +1 -0
- package/dist/state/apply-delta.js +23 -0
- package/dist/state/apply-delta.js.map +1 -0
- package/dist/state/apply-snapshot.d.ts +6 -0
- package/dist/state/apply-snapshot.d.ts.map +1 -0
- package/dist/state/apply-snapshot.js +6 -0
- package/dist/state/apply-snapshot.js.map +1 -0
- package/dist/state/store.d.ts +28 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +119 -0
- package/dist/state/store.js.map +1 -0
- package/dist/status-pill-DCHvrd_y.js +241 -0
- package/dist/status-pill-DCHvrd_y.js.map +1 -0
- package/dist/test-DBCtwx_I.js +210 -0
- package/dist/test-DBCtwx_I.js.map +1 -0
- package/dist/transport/reconnect.d.ts +22 -0
- package/dist/transport/reconnect.d.ts.map +1 -0
- package/dist/transport/reconnect.js +60 -0
- package/dist/transport/reconnect.js.map +1 -0
- package/dist/transport/ws.d.ts +66 -0
- package/dist/transport/ws.d.ts.map +1 -0
- package/dist/transport/ws.js +270 -0
- package/dist/transport/ws.js.map +1 -0
- package/dist/tree-CnhX02kd.js +494 -0
- package/dist/tree-CnhX02kd.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/animate/crossfade.tsx +31 -0
- package/src/animate/keyframes.ts +142 -0
- package/src/animate/transitions.ts +116 -0
- package/src/app.tsx +84 -0
- package/src/dev-entry.tsx +38 -0
- package/src/index.ts +24 -0
- package/src/internal/validate-options.ts +20 -0
- package/src/modes/broadcast.tsx +8 -0
- package/src/modes/control.tsx +17 -0
- package/src/modes/test.tsx +19 -0
- package/src/mount.ts +169 -0
- package/src/overlay/control.tsx +239 -0
- package/src/overlay/runtime-context.tsx +37 -0
- package/src/overlay/status-pill.tsx +37 -0
- package/src/overlay/test.tsx +213 -0
- package/src/render/bundle.ts +208 -0
- package/src/render/fill.tsx +163 -0
- package/src/render/keyframe-player.tsx +89 -0
- package/src/render/primitives/frame.tsx +78 -0
- package/src/render/primitives/grid.tsx +20 -0
- package/src/render/primitives/image.tsx +35 -0
- package/src/render/primitives/index.ts +35 -0
- package/src/render/primitives/instance.tsx +70 -0
- package/src/render/primitives/media.tsx +28 -0
- package/src/render/primitives/shape.tsx +135 -0
- package/src/render/primitives/stack.tsx +48 -0
- package/src/render/primitives/text.tsx +38 -0
- package/src/render/scope.tsx +27 -0
- package/src/render/stagger-context.tsx +24 -0
- package/src/render/tree.tsx +182 -0
- package/src/render/universal-wrapper.tsx +95 -0
- package/src/state/apply-delta.ts +24 -0
- package/src/state/apply-snapshot.ts +8 -0
- package/src/state/store.ts +141 -0
- package/src/transport/reconnect.ts +83 -0
- package/src/transport/ws.ts +359 -0
- package/src/types.ts +54 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// LSML 1.1 §4.9 — `instance` primitive (composite-instance reuse).
|
|
2
|
+
//
|
|
3
|
+
// Mounts a sub-scene by `scene_id` + `scene_version`. The sub-scene's
|
|
4
|
+
// state is exposed to its tree under the `__params.*` reserved
|
|
5
|
+
// namespace ; resolution happens via the runtime's bundle fetcher.
|
|
6
|
+
//
|
|
7
|
+
// This implementation is a SCAFFOLD : the visual slot is rendered
|
|
8
|
+
// (size/position honoured) but the sub-tree is replaced by a
|
|
9
|
+
// "deferred load" placeholder until the async bundle-fetch path is
|
|
10
|
+
// wired. The composite-reuse rendering is the next iteration's work.
|
|
11
|
+
//
|
|
12
|
+
// What this primitive does today :
|
|
13
|
+
// - parse scene_id, scene_version, params, fit, size, position
|
|
14
|
+
// - reserve the slot in the parent layout
|
|
15
|
+
// - log a one-time warning so authors know it's a scaffold
|
|
16
|
+
//
|
|
17
|
+
// What it does NOT do (yet) :
|
|
18
|
+
// - fetch the inner bundle via the runtime's bundle resolver
|
|
19
|
+
// - render the inner tree with __params.* injected into the store
|
|
20
|
+
// - cycle detection (LSML 1.1 §4.9.2) — depth-8 limit applied at the
|
|
21
|
+
// resolver layer rather than the renderer
|
|
22
|
+
|
|
23
|
+
import type { ReactElement } from "react";
|
|
24
|
+
import type { PrimitiveProps } from "./index";
|
|
25
|
+
|
|
26
|
+
const warned = new Set<string>();
|
|
27
|
+
|
|
28
|
+
export function Instance({ resolved }: PrimitiveProps): ReactElement | null {
|
|
29
|
+
const sceneId = resolved.scene_id as string | undefined;
|
|
30
|
+
const sceneVersion = resolved.scene_version as string | undefined;
|
|
31
|
+
if (!sceneId || !sceneVersion) {
|
|
32
|
+
if (import.meta.env.DEV) {
|
|
33
|
+
console.warn("[lumencast/instance] missing scene_id or scene_version", resolved);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One-time DEV warning per (sceneId,version) so authors know the
|
|
39
|
+
// scaffold limitation.
|
|
40
|
+
if (import.meta.env.DEV) {
|
|
41
|
+
const key = `${sceneId}:${sceneVersion}`;
|
|
42
|
+
if (!warned.has(key)) {
|
|
43
|
+
warned.add(key);
|
|
44
|
+
console.warn(
|
|
45
|
+
`[lumencast/instance] scaffold render — async bundle fetch + ` +
|
|
46
|
+
`__params.* injection are not yet wired (LSML 1.1 §4.9). ` +
|
|
47
|
+
`scene_id=${sceneId}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const size = resolved.size as { w?: number; h?: number } | undefined;
|
|
53
|
+
const position = resolved.position as { x?: number; y?: number } | undefined;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-lumencast-instance={sceneId}
|
|
58
|
+
data-lumencast-version={sceneVersion}
|
|
59
|
+
style={{
|
|
60
|
+
position: position ? "absolute" : "relative",
|
|
61
|
+
left: position?.x,
|
|
62
|
+
top: position?.y,
|
|
63
|
+
width: size?.w,
|
|
64
|
+
height: size?.h,
|
|
65
|
+
outline: import.meta.env.DEV ? "1px dashed rgba(255,180,0,0.5)" : "none",
|
|
66
|
+
boxSizing: "border-box",
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PrimitiveProps } from "./index";
|
|
2
|
+
|
|
3
|
+
/** Embedded video. `src`, `loop`, `mute`, `autoplay`. Audio is muted
|
|
4
|
+
* by default — broadcast audio is Pulsar-side, not from the browser
|
|
5
|
+
* source. */
|
|
6
|
+
export function Media({ resolved }: PrimitiveProps) {
|
|
7
|
+
const src = resolved.src as string | undefined;
|
|
8
|
+
if (!src) return null;
|
|
9
|
+
const loop = (resolved.loop as boolean | undefined) ?? true;
|
|
10
|
+
const mute = (resolved.mute as boolean | undefined) ?? true;
|
|
11
|
+
const autoplay = (resolved.autoplay as boolean | undefined) ?? true;
|
|
12
|
+
const fit = (resolved.fit as string | undefined) ?? "cover";
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<video
|
|
16
|
+
src={src}
|
|
17
|
+
autoPlay={autoplay}
|
|
18
|
+
loop={loop}
|
|
19
|
+
muted={mute}
|
|
20
|
+
playsInline
|
|
21
|
+
style={{
|
|
22
|
+
width: "100%",
|
|
23
|
+
height: "100%",
|
|
24
|
+
objectFit: fit as React.CSSProperties["objectFit"],
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { motion } from "framer-motion";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import type { PrimitiveProps } from "./index";
|
|
4
|
+
import { toFramer } from "../../animate/transitions";
|
|
5
|
+
import { parseFills, renderFill } from "../fill";
|
|
6
|
+
|
|
7
|
+
interface StrokeSpec {
|
|
8
|
+
color?: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Rectangle / circle / line. Renders as SVG so stroke + fill behave
|
|
13
|
+
* predictably across hosts. Opacity animatable.
|
|
14
|
+
*
|
|
15
|
+
* LSML 1.1 §4.6 + §4.12 add `fills[]` / `strokes[]` arrays as the
|
|
16
|
+
* preferred way to declare multi-layer fills with linear/radial
|
|
17
|
+
* gradients. The legacy single `fill` / `stroke` props remain
|
|
18
|
+
* accepted for 1.0 bundles ; when both are present the array form
|
|
19
|
+
* wins (the spec forbids mixing, but we tolerate to ease migration).
|
|
20
|
+
*/
|
|
21
|
+
export function Shape({ resolved, transitionFor }: PrimitiveProps) {
|
|
22
|
+
const kind = (resolved.kind as string | undefined) ?? "rect";
|
|
23
|
+
const legacyFill = (resolved.fill as string | undefined) ?? "transparent";
|
|
24
|
+
const legacyStroke = (resolved.stroke as string | undefined) ?? "transparent";
|
|
25
|
+
const legacyStrokeWidth = numberOr(resolved.stroke_width, 0);
|
|
26
|
+
const width = numberOr(resolved.width, 100);
|
|
27
|
+
const height = numberOr(resolved.height, 100);
|
|
28
|
+
const radius = numberOr(resolved.radius, 0);
|
|
29
|
+
const opacity = numberOr(resolved.opacity, 1);
|
|
30
|
+
|
|
31
|
+
const tx = transitionFor("opacity");
|
|
32
|
+
const transition = toFramer(tx);
|
|
33
|
+
|
|
34
|
+
// LSML 1.1 §4.6 — `fills[]` is the preferred multi-fill form. Fall
|
|
35
|
+
// back to the singular `fill` for 1.0 bundles.
|
|
36
|
+
const fills = parseFills(resolved.fills);
|
|
37
|
+
const strokes = parseStrokes(resolved.strokes);
|
|
38
|
+
|
|
39
|
+
// Each fill compiles to a (defs, ref) pair. We render the shape
|
|
40
|
+
// outline once per fill, layered top-to-bottom (first entry → on
|
|
41
|
+
// top, per §4.12). The defs are aggregated for a single <defs>.
|
|
42
|
+
const fillRenders = fills.map(renderFill);
|
|
43
|
+
const allDefs = fillRenders.flatMap((r) => r.defs);
|
|
44
|
+
const fillRefs = fillRenders.length > 0 ? fillRenders.map((r) => r.ref) : [legacyFill];
|
|
45
|
+
|
|
46
|
+
// Strokes : same layered approach, but solid colours only (gradient
|
|
47
|
+
// strokes are out of scope for §4.6 1.1). Each stroke is rendered
|
|
48
|
+
// as an additional pass over the same shape outline.
|
|
49
|
+
const strokeLayers =
|
|
50
|
+
strokes.length > 0
|
|
51
|
+
? strokes.map((s) => ({ color: s.color ?? "transparent", width: s.width ?? 0 }))
|
|
52
|
+
: [{ color: legacyStroke, width: legacyStrokeWidth }];
|
|
53
|
+
|
|
54
|
+
// Stack order : fillRefs are emitted top-to-bottom per §4.12. SVG
|
|
55
|
+
// paints later siblings on top, so we reverse here so the first
|
|
56
|
+
// entry in fills[] ends up rendered last (visually on top).
|
|
57
|
+
const stackedFills = [...fillRefs].reverse();
|
|
58
|
+
const stackedStrokes = [...strokeLayers].reverse();
|
|
59
|
+
|
|
60
|
+
const renderShape = (
|
|
61
|
+
fill: string,
|
|
62
|
+
stroke: { color: string; width: number },
|
|
63
|
+
keyPrefix: string,
|
|
64
|
+
): ReactElement => {
|
|
65
|
+
if (kind === "circle") {
|
|
66
|
+
return (
|
|
67
|
+
<circle
|
|
68
|
+
key={keyPrefix}
|
|
69
|
+
cx={width / 2}
|
|
70
|
+
cy={height / 2}
|
|
71
|
+
r={Math.min(width, height) / 2 - stroke.width / 2}
|
|
72
|
+
fill={fill}
|
|
73
|
+
stroke={stroke.color}
|
|
74
|
+
strokeWidth={stroke.width}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (kind === "line") {
|
|
79
|
+
return (
|
|
80
|
+
<line
|
|
81
|
+
key={keyPrefix}
|
|
82
|
+
x1="0"
|
|
83
|
+
y1={height / 2}
|
|
84
|
+
x2={width}
|
|
85
|
+
y2={height / 2}
|
|
86
|
+
stroke={stroke.color || fill}
|
|
87
|
+
strokeWidth={stroke.width || 1}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
// rect default
|
|
92
|
+
return (
|
|
93
|
+
<rect
|
|
94
|
+
key={keyPrefix}
|
|
95
|
+
x={stroke.width / 2}
|
|
96
|
+
y={stroke.width / 2}
|
|
97
|
+
width={Math.max(0, width - stroke.width)}
|
|
98
|
+
height={Math.max(0, height - stroke.width)}
|
|
99
|
+
rx={radius}
|
|
100
|
+
ry={radius}
|
|
101
|
+
fill={fill}
|
|
102
|
+
stroke={stroke.color}
|
|
103
|
+
strokeWidth={stroke.width}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<motion.svg
|
|
110
|
+
width={width}
|
|
111
|
+
height={height}
|
|
112
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
113
|
+
animate={{ opacity }}
|
|
114
|
+
transition={transition}
|
|
115
|
+
style={{ willChange: "opacity" }}
|
|
116
|
+
>
|
|
117
|
+
{allDefs.length > 0 && <defs>{allDefs}</defs>}
|
|
118
|
+
{stackedFills.map((ref, i) =>
|
|
119
|
+
renderShape(ref, { color: "transparent", width: 0 }, `fill-${i}`),
|
|
120
|
+
)}
|
|
121
|
+
{stackedStrokes.map((s, i) => renderShape("none", s, `stroke-${i}`))}
|
|
122
|
+
</motion.svg>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseStrokes(value: unknown): StrokeSpec[] {
|
|
127
|
+
if (!Array.isArray(value)) return [];
|
|
128
|
+
return value.filter(
|
|
129
|
+
(v): v is StrokeSpec => typeof v === "object" && v !== null && ("color" in v || "width" in v),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function numberOr(v: unknown, fallback: number): number {
|
|
134
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
135
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { PrimitiveProps } from "./index";
|
|
3
|
+
|
|
4
|
+
/** Vertical or horizontal flex container. Layout-only — bindings
|
|
5
|
+
* here are unusual but tolerated.
|
|
6
|
+
*
|
|
7
|
+
* LSML 1.1 §4.1 adds `wrap` (boolean) and `crossGap` (number) :
|
|
8
|
+
* - wrap: true sets `flex-wrap: wrap` so children flow onto the
|
|
9
|
+
* next row / column when they overflow the main axis.
|
|
10
|
+
* - crossGap is the spacing between rows / columns when wrapping.
|
|
11
|
+
* Mapped to CSS `row-gap` (horizontal stack) or `column-gap`
|
|
12
|
+
* (vertical stack). Ignored when `wrap` is false.
|
|
13
|
+
*/
|
|
14
|
+
export function Stack({ resolved, children }: PrimitiveProps) {
|
|
15
|
+
const direction = (resolved.direction as string) ?? "vertical";
|
|
16
|
+
const gap = numberOr(resolved.gap, 0);
|
|
17
|
+
const wrap = resolved.wrap === true;
|
|
18
|
+
const crossGap = numberOr(resolved.crossGap, 0);
|
|
19
|
+
const align = (resolved.align as string) ?? "stretch";
|
|
20
|
+
const justify = (resolved.justify as string) ?? "flex-start";
|
|
21
|
+
const isHorizontal = direction === "horizontal";
|
|
22
|
+
|
|
23
|
+
const style: CSSProperties = {
|
|
24
|
+
display: "flex",
|
|
25
|
+
flexDirection: isHorizontal ? "row" : "column",
|
|
26
|
+
alignItems: align,
|
|
27
|
+
justifyContent: justify,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (wrap) {
|
|
31
|
+
style.flexWrap = "wrap";
|
|
32
|
+
if (isHorizontal) {
|
|
33
|
+
style.columnGap = gap;
|
|
34
|
+
style.rowGap = crossGap;
|
|
35
|
+
} else {
|
|
36
|
+
style.rowGap = gap;
|
|
37
|
+
style.columnGap = crossGap;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
style.gap = gap;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return <div style={style}>{children}</div>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function numberOr(v: unknown, fallback: number): number {
|
|
47
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
48
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { motion } from "framer-motion";
|
|
2
|
+
import type { PrimitiveProps } from "./index";
|
|
3
|
+
import { toFramer } from "../../animate/transitions";
|
|
4
|
+
|
|
5
|
+
/** Text leaf. Value renders as the displayed string ; style props
|
|
6
|
+
* cover size / weight / colour / alignment. Opacity is animated when
|
|
7
|
+
* a transition is declared on `opacity` or `value`. */
|
|
8
|
+
export function Text({ resolved, transitionFor }: PrimitiveProps) {
|
|
9
|
+
const value = resolved.value === undefined ? "" : String(resolved.value);
|
|
10
|
+
const size = (resolved.size as string | number | undefined) ?? "1rem";
|
|
11
|
+
const weight = (resolved.weight as number | undefined) ?? 400;
|
|
12
|
+
const colour = (resolved.colour as string | undefined) ?? "currentColor";
|
|
13
|
+
const align = (resolved.align as string | undefined) ?? "start";
|
|
14
|
+
const opacity = numberOr(resolved.opacity, 1);
|
|
15
|
+
|
|
16
|
+
const tx = transitionFor("opacity") ?? transitionFor("value");
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<motion.span
|
|
20
|
+
style={{
|
|
21
|
+
display: "inline-block",
|
|
22
|
+
fontSize: size,
|
|
23
|
+
fontWeight: weight,
|
|
24
|
+
color: colour,
|
|
25
|
+
textAlign: align as React.CSSProperties["textAlign"],
|
|
26
|
+
willChange: "opacity",
|
|
27
|
+
}}
|
|
28
|
+
animate={{ opacity }}
|
|
29
|
+
transition={toFramer(tx)}
|
|
30
|
+
>
|
|
31
|
+
{value}
|
|
32
|
+
</motion.span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function numberOr(v: unknown, fallback: number): number {
|
|
37
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Path-scope context. Children inside a `repeat` get a `prefix` that
|
|
4
|
+
* is prepended to their declared bindings, so a single template can
|
|
5
|
+
* bind to per-item paths like `items.{i}.score`. */
|
|
6
|
+
const PathScopeContext = createContext<string>("");
|
|
7
|
+
|
|
8
|
+
export function PathScopeProvider({ prefix, children }: { prefix: string; children: ReactNode }) {
|
|
9
|
+
const parent = useContext(PathScopeContext);
|
|
10
|
+
const next = parent ? `${parent}.${prefix}` : prefix;
|
|
11
|
+
return <PathScopeContext.Provider value={next}>{children}</PathScopeContext.Provider>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Returns the current path prefix, or "" if there is no scope. */
|
|
15
|
+
export function usePathScope(): string {
|
|
16
|
+
return useContext(PathScopeContext);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve a binding path under the current scope. */
|
|
20
|
+
export function scopedPath(prefix: string, path: string): string {
|
|
21
|
+
if (!prefix) return path;
|
|
22
|
+
// Path may itself start with a literal prefix (e.g. `__system.*`),
|
|
23
|
+
// which should NOT be scoped — only paths that are clearly relative
|
|
24
|
+
// get prefixed.
|
|
25
|
+
if (path.startsWith("__")) return path;
|
|
26
|
+
return `${prefix}.${path}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// LSML 1.1 §6.7 — stagger context.
|
|
2
|
+
//
|
|
3
|
+
// `repeat.stagger_ms` produces wave-like reveals : iteration N's
|
|
4
|
+
// animations start `N * stagger_ms` after iteration 0. The Repeat
|
|
5
|
+
// renderer computes the per-iteration delay and threads it through
|
|
6
|
+
// React context so KeyframePlayer (and future animate-aware primitives)
|
|
7
|
+
// can pick it up without per-primitive wiring.
|
|
8
|
+
|
|
9
|
+
import { createContext } from "react";
|
|
10
|
+
|
|
11
|
+
/** Per-iteration stagger delay in milliseconds. `0` means no offset
|
|
12
|
+
* (the implicit default outside a staggered repeat). */
|
|
13
|
+
export const StaggerContext = createContext<number>(0);
|
|
14
|
+
|
|
15
|
+
/** Spec hint : runtimes MAY cap effective stagger to avoid pathological
|
|
16
|
+
* wait times on large lists. We cap at 2 s. */
|
|
17
|
+
export const STAGGER_CAP_MS = 2000;
|
|
18
|
+
|
|
19
|
+
/** Compute the effective per-iteration delay, applying the runtime cap. */
|
|
20
|
+
export function computeStaggerDelayMs(index: number, staggerMs: number): number {
|
|
21
|
+
if (staggerMs <= 0) return 0;
|
|
22
|
+
const raw = index * staggerMs;
|
|
23
|
+
return raw > STAGGER_CAP_MS ? STAGGER_CAP_MS : raw;
|
|
24
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Recursive tree renderer — resolves bindings, dispatches to
|
|
2
|
+
// primitives, handles `repeat` specially.
|
|
3
|
+
|
|
4
|
+
import { useSignals } from "@preact/signals-react/runtime";
|
|
5
|
+
import { useMemo, type ReactNode } from "react";
|
|
6
|
+
import type { Store } from "../state/store";
|
|
7
|
+
import type { Transition } from "../animate/transitions";
|
|
8
|
+
import { PRIMITIVES } from "./primitives";
|
|
9
|
+
import { PathScopeProvider, scopedPath, usePathScope } from "./scope";
|
|
10
|
+
import type { RenderNode } from "./bundle";
|
|
11
|
+
import { UniversalWrapper, type SizingMode } from "./universal-wrapper";
|
|
12
|
+
import { KeyframePlayer } from "./keyframe-player";
|
|
13
|
+
import { StaggerContext, computeStaggerDelayMs } from "./stagger-context";
|
|
14
|
+
|
|
15
|
+
export interface TreeProps {
|
|
16
|
+
node: RenderNode;
|
|
17
|
+
store: Store;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Tree({ node, store }: TreeProps): ReactNode {
|
|
21
|
+
if (node.kind === "repeat") {
|
|
22
|
+
return <Repeat node={node} store={store} />;
|
|
23
|
+
}
|
|
24
|
+
return <Node node={node} store={store} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Node({ node, store }: TreeProps): ReactNode {
|
|
28
|
+
// useSignals() lets the surrounding component subscribe to any
|
|
29
|
+
// signal read during render. Each leaf path has its own signal so
|
|
30
|
+
// re-renders only fire on touched paths.
|
|
31
|
+
useSignals();
|
|
32
|
+
const scope = usePathScope();
|
|
33
|
+
|
|
34
|
+
// Hooks must run unconditionally — the early-return for unknown
|
|
35
|
+
// kinds happens *after* every hook has fired.
|
|
36
|
+
const resolved = useMemo(
|
|
37
|
+
() => resolveProps(node, store, scope),
|
|
38
|
+
// We re-build per render — signals re-render cheaply, and the
|
|
39
|
+
// resolution itself is O(bindings) which is small. The memo is a
|
|
40
|
+
// micro-optimisation to keep object identity stable across renders
|
|
41
|
+
// when the inputs haven't changed.
|
|
42
|
+
[node, store, scope, ...readBindingValues(node, store, scope)],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const Primitive = PRIMITIVES[node.kind as keyof typeof PRIMITIVES];
|
|
46
|
+
if (!Primitive) {
|
|
47
|
+
if (import.meta.env.DEV) {
|
|
48
|
+
console.warn(`[lumencast] unknown render kind : ${node.kind}`);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// LSDP/1.1 §3.2.2 — a per-leaf transition on the most recent delta
|
|
54
|
+
// takes precedence over the bundle-level default. Only bound props
|
|
55
|
+
// can carry a wire transition (a static prop never moves). Snapshots
|
|
56
|
+
// clear the directive, so the bundle default reapplies after a reset.
|
|
57
|
+
//
|
|
58
|
+
// We resolve here in the parent's render (useSignals() above tracks
|
|
59
|
+
// these reads) rather than inside the primitive's callback — that way
|
|
60
|
+
// a transition signal change re-renders this Node, which in turn re-
|
|
61
|
+
// renders the primitive with the new transition prop.
|
|
62
|
+
const liveTransitions: Record<string, Transition | undefined> = {};
|
|
63
|
+
if (node.bindings) {
|
|
64
|
+
for (const [key, path] of Object.entries(node.bindings)) {
|
|
65
|
+
const ts = store.transitionSignal(scopedPath(scope, path)).value;
|
|
66
|
+
if (ts !== undefined) liveTransitions[key] = ts;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const transitionFor = (key: string): Transition | undefined => {
|
|
70
|
+
if (key in liveTransitions) return liveTransitions[key];
|
|
71
|
+
return node.transitions?.[key];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const children = node.children?.map((child, idx) => (
|
|
75
|
+
<Tree key={child.id ?? idx} node={child} store={store} />
|
|
76
|
+
));
|
|
77
|
+
|
|
78
|
+
// LSML 1.1 §5.4 — universal props applied uniformly across all
|
|
79
|
+
// primitives. Pulled out of `resolved` so primitives can ignore
|
|
80
|
+
// them ; the wrapper composes with whatever transform/opacity the
|
|
81
|
+
// primitive's own framer-motion may apply.
|
|
82
|
+
const universal = {
|
|
83
|
+
visible: typeof resolved.visible === "boolean" ? resolved.visible : undefined,
|
|
84
|
+
opacity:
|
|
85
|
+
typeof resolved.universal_opacity === "number" ? resolved.universal_opacity : undefined,
|
|
86
|
+
rotation: typeof resolved.rotation === "number" ? resolved.rotation : undefined,
|
|
87
|
+
sizing: extractSizing(resolved.sizing),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const body = (
|
|
91
|
+
<UniversalWrapper {...universal}>
|
|
92
|
+
<Primitive resolved={resolved} transitionFor={transitionFor}>
|
|
93
|
+
{children}
|
|
94
|
+
</Primitive>
|
|
95
|
+
</UniversalWrapper>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// LSML 1.1 §6.6 — when a primitive declares keyframes, wrap the
|
|
99
|
+
// rendered subtree in a player that drives framer-motion through the
|
|
100
|
+
// step path. The player handles replay-on-key-change and reads any
|
|
101
|
+
// ambient stagger delay from StaggerContext (§6.7).
|
|
102
|
+
if (node.keyframes) {
|
|
103
|
+
return (
|
|
104
|
+
<KeyframePlayer keyframes={node.keyframes} store={store}>
|
|
105
|
+
{body}
|
|
106
|
+
</KeyframePlayer>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractSizing(value: unknown): { x?: SizingMode; y?: SizingMode } | undefined {
|
|
113
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
114
|
+
const obj = value as { x?: unknown; y?: unknown };
|
|
115
|
+
const out: { x?: SizingMode; y?: SizingMode } = {};
|
|
116
|
+
if (obj.x === "fixed" || obj.x === "hug" || obj.x === "fill") out.x = obj.x;
|
|
117
|
+
if (obj.y === "fixed" || obj.y === "hug" || obj.y === "fill") out.y = obj.y;
|
|
118
|
+
return out.x !== undefined || out.y !== undefined ? out : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Repeat({ node, store }: TreeProps): ReactNode {
|
|
122
|
+
useSignals();
|
|
123
|
+
const scope = usePathScope();
|
|
124
|
+
|
|
125
|
+
const itemsBinding = node.bindings?.items;
|
|
126
|
+
const items =
|
|
127
|
+
itemsBinding === undefined
|
|
128
|
+
? []
|
|
129
|
+
: ((store.signal(scopedPath(scope, itemsBinding)).value as unknown[] | undefined) ?? []);
|
|
130
|
+
if (!Array.isArray(items)) return null;
|
|
131
|
+
|
|
132
|
+
const template = node.children?.[0];
|
|
133
|
+
if (!template) return null;
|
|
134
|
+
|
|
135
|
+
// LSML 1.1 §6.7 — `stagger_ms` produces wave-like reveals across
|
|
136
|
+
// iterations. We compute the per-iteration delay (capped) and feed
|
|
137
|
+
// it to descendants via StaggerContext so the KeyframePlayer (and
|
|
138
|
+
// future animate-aware primitives) can pick it up without per-
|
|
139
|
+
// iteration scripting. `stagger_ms: 0` (or unset) is a no-op.
|
|
140
|
+
const staggerMs = typeof node.stagger_ms === "number" ? node.stagger_ms : 0;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<>
|
|
144
|
+
{items.map((_item, idx) => {
|
|
145
|
+
const delayMs = computeStaggerDelayMs(idx, staggerMs);
|
|
146
|
+
const tree = (
|
|
147
|
+
<PathScopeProvider key={idx} prefix={`${itemsBinding ?? ""}.${idx}`}>
|
|
148
|
+
<Tree node={template} store={store} />
|
|
149
|
+
</PathScopeProvider>
|
|
150
|
+
);
|
|
151
|
+
if (delayMs <= 0) return tree;
|
|
152
|
+
return (
|
|
153
|
+
<StaggerContext.Provider key={idx} value={delayMs}>
|
|
154
|
+
{tree}
|
|
155
|
+
</StaggerContext.Provider>
|
|
156
|
+
);
|
|
157
|
+
})}
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveProps(node: RenderNode, store: Store, scope: string): Record<string, unknown> {
|
|
163
|
+
const out: Record<string, unknown> = { ...(node.props ?? {}) };
|
|
164
|
+
if (node.bindings) {
|
|
165
|
+
for (const [propKey, path] of Object.entries(node.bindings)) {
|
|
166
|
+
const fullPath = scopedPath(scope, path);
|
|
167
|
+
out[propKey] = store.signal(fullPath).value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Helper for the useMemo deps array — read each bound signal so the
|
|
174
|
+
* memo invalidates when any binding moves. */
|
|
175
|
+
function readBindingValues(node: RenderNode, store: Store, scope: string): unknown[] {
|
|
176
|
+
if (!node.bindings) return [];
|
|
177
|
+
const values: unknown[] = [];
|
|
178
|
+
for (const path of Object.values(node.bindings)) {
|
|
179
|
+
values.push(store.signal(scopedPath(scope, path)).value);
|
|
180
|
+
}
|
|
181
|
+
return values;
|
|
182
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Universal-props wrapper (LSML 1.1 §5.4).
|
|
2
|
+
//
|
|
3
|
+
// Every primitive renders inside this wrapper, which applies the four
|
|
4
|
+
// universal props uniformly :
|
|
5
|
+
//
|
|
6
|
+
// - `visible: false` → display: none (slot collapses in flex layouts)
|
|
7
|
+
// - `opacity` → CSS opacity, multiplicative with whatever animation
|
|
8
|
+
// a primitive may apply via framer-motion (browsers compose them)
|
|
9
|
+
// - `rotation` → CSS transform: rotate(<deg>)
|
|
10
|
+
// - `sizing.x`/`sizing.y` → flex shorthand on the wrapping div, lets
|
|
11
|
+
// a primitive participate in its parent flex layout's auto-sizing
|
|
12
|
+
//
|
|
13
|
+
// `bindUniversal` is resolved by the Tree renderer before the wrapper
|
|
14
|
+
// sees its values, so this component only deals with concrete numbers
|
|
15
|
+
// and booleans.
|
|
16
|
+
|
|
17
|
+
import type { ReactNode, CSSProperties } from "react";
|
|
18
|
+
|
|
19
|
+
export type SizingMode = "fixed" | "hug" | "fill";
|
|
20
|
+
|
|
21
|
+
export interface UniversalProps {
|
|
22
|
+
visible?: boolean;
|
|
23
|
+
opacity?: number;
|
|
24
|
+
rotation?: number;
|
|
25
|
+
sizing?: { x?: SizingMode; y?: SizingMode };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UniversalWrapperProps extends UniversalProps {
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Maps a SizingMode onto a flex shorthand. Per LSML 1.1 §5.4.1 :
|
|
34
|
+
* - fixed : the primitive honours its declared size verbatim
|
|
35
|
+
* - hug : the primitive shrinks to its intrinsic content size
|
|
36
|
+
* - fill : the primitive grows to fill available space
|
|
37
|
+
*/
|
|
38
|
+
function flexFor(mode: SizingMode | undefined): string | undefined {
|
|
39
|
+
switch (mode) {
|
|
40
|
+
case "fixed":
|
|
41
|
+
return "0 0 auto";
|
|
42
|
+
case "hug":
|
|
43
|
+
return "0 1 auto";
|
|
44
|
+
case "fill":
|
|
45
|
+
return "1 1 auto";
|
|
46
|
+
default:
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function UniversalWrapper({
|
|
52
|
+
visible,
|
|
53
|
+
opacity,
|
|
54
|
+
rotation,
|
|
55
|
+
sizing,
|
|
56
|
+
children,
|
|
57
|
+
}: UniversalWrapperProps) {
|
|
58
|
+
if (visible === false) {
|
|
59
|
+
return null; // slot collapses in flex/grid layouts (§5.4)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// No-op fast path — when no universal props are set, render children
|
|
63
|
+
// directly. Lets simple bundles avoid an extra DOM node per primitive.
|
|
64
|
+
const hasOpacity = typeof opacity === "number" && opacity !== 1;
|
|
65
|
+
const hasRotation = typeof rotation === "number" && rotation !== 0;
|
|
66
|
+
const hasSizing = sizing?.x !== undefined || sizing?.y !== undefined;
|
|
67
|
+
if (!hasOpacity && !hasRotation && !hasSizing) {
|
|
68
|
+
return <>{children}</>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const style: CSSProperties = {};
|
|
72
|
+
if (hasOpacity) style.opacity = opacity;
|
|
73
|
+
if (hasRotation) style.transform = `rotate(${rotation}deg)`;
|
|
74
|
+
|
|
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;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return <div style={style}>{children}</div>;
|
|
95
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { batch } from "@preact/signals-react";
|
|
2
|
+
import type { DeltaFrame } from "@lumencast/protocol";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
import { parseWireTransition } from "../animate/transitions";
|
|
5
|
+
|
|
6
|
+
/** Apply an LSDP/1 delta. All patches in the frame land in a single signals
|
|
7
|
+
* batch — components reading multiple paths see them flip in one render pass.
|
|
8
|
+
*
|
|
9
|
+
* LSDP/1.1 §3.2.2 — a patch may carry a `transition` directive overriding
|
|
10
|
+
* the bundle-level default for the next animation cycle on that leaf. We
|
|
11
|
+
* thread it through the store so the renderer reads the correct directive
|
|
12
|
+
* alongside the new value. */
|
|
13
|
+
export function applyDelta(store: Store, frame: DeltaFrame): void {
|
|
14
|
+
batch(() => {
|
|
15
|
+
for (const patch of frame.patches) {
|
|
16
|
+
const transition = parseWireTransition(patch.transition);
|
|
17
|
+
if (transition !== undefined) {
|
|
18
|
+
store.setWithTransition(patch.path, patch.value, transition);
|
|
19
|
+
} else {
|
|
20
|
+
store.set(patch.path, patch.value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|