@lumencast/runtime 0.4.0 → 0.5.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/README.md +57 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/animate/frame-coalescer.d.ts +13 -0
- package/dist/animate/frame-coalescer.d.ts.map +1 -0
- package/dist/animate/frame-coalescer.js +46 -0
- package/dist/animate/frame-coalescer.js.map +1 -0
- package/dist/animate/keyframes.d.ts +1 -1
- package/dist/animate/keyframes.d.ts.map +1 -1
- package/dist/animate/keyframes.js +20 -6
- package/dist/animate/keyframes.js.map +1 -1
- package/dist/animate/transitions.d.ts +4 -1
- package/dist/animate/transitions.d.ts.map +1 -1
- package/dist/animate/transitions.js +30 -3
- package/dist/animate/transitions.js.map +1 -1
- package/dist/{broadcast-DzZ8TVGZ.js → broadcast-3vYij4k-.js} +3 -3
- package/dist/{broadcast-DzZ8TVGZ.js.map → broadcast-3vYij4k-.js.map} +1 -1
- package/dist/{control-gbDGvdR0.js → control-BFNkY7-6.js} +4 -4
- package/dist/{control-gbDGvdR0.js.map → control-BFNkY7-6.js.map} +1 -1
- package/dist/{index-oteiocFe.js → index-CyOlpZAL.js} +305 -150
- package/dist/index-CyOlpZAL.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/lumencast.js +9 -2
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +11 -1
- package/dist/mount.js.map +1 -1
- package/dist/render/bind-animate.d.ts +40 -0
- package/dist/render/bind-animate.d.ts.map +1 -0
- package/dist/render/bind-animate.js +329 -0
- package/dist/render/bind-animate.js.map +1 -0
- package/dist/render/bundle.d.ts +48 -6
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js +71 -4
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/color-interp.d.ts +18 -0
- package/dist/render/color-interp.d.ts.map +1 -0
- package/dist/render/color-interp.js +303 -0
- package/dist/render/color-interp.js.map +1 -0
- package/dist/render/css-color.d.ts +16 -0
- package/dist/render/css-color.d.ts.map +1 -0
- package/dist/render/css-color.js +130 -0
- package/dist/render/css-color.js.map +1 -0
- package/dist/render/diagnostics.d.ts +26 -0
- package/dist/render/diagnostics.d.ts.map +1 -0
- package/dist/render/diagnostics.js +58 -0
- package/dist/render/diagnostics.js.map +1 -0
- package/dist/render/fill.d.ts +15 -3
- package/dist/render/fill.d.ts.map +1 -1
- package/dist/render/fill.js +81 -14
- package/dist/render/fill.js.map +1 -1
- package/dist/render/filter-clamp.d.ts +35 -0
- package/dist/render/filter-clamp.d.ts.map +1 -0
- package/dist/render/filter-clamp.js +90 -0
- package/dist/render/filter-clamp.js.map +1 -0
- package/dist/render/keyframe-player.d.ts +4 -1
- package/dist/render/keyframe-player.d.ts.map +1 -1
- package/dist/render/keyframe-player.js +2 -2
- package/dist/render/keyframe-player.js.map +1 -1
- package/dist/render/primitives/frame.d.ts +16 -1
- package/dist/render/primitives/frame.d.ts.map +1 -1
- package/dist/render/primitives/frame.js +42 -7
- package/dist/render/primitives/frame.js.map +1 -1
- package/dist/render/primitives/image.d.ts +1 -1
- package/dist/render/primitives/image.d.ts.map +1 -1
- package/dist/render/primitives/image.js +6 -3
- package/dist/render/primitives/image.js.map +1 -1
- package/dist/render/primitives/index.d.ts +3 -0
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/primitives/instance.d.ts +1 -1
- package/dist/render/primitives/instance.d.ts.map +1 -1
- package/dist/render/primitives/instance.js +10 -13
- package/dist/render/primitives/instance.js.map +1 -1
- package/dist/render/primitives/shape.d.ts +9 -3
- package/dist/render/primitives/shape.d.ts.map +1 -1
- package/dist/render/primitives/shape.js +56 -12
- package/dist/render/primitives/shape.js.map +1 -1
- package/dist/render/primitives/text.d.ts +35 -4
- package/dist/render/primitives/text.d.ts.map +1 -1
- package/dist/render/primitives/text.js +179 -7
- package/dist/render/primitives/text.js.map +1 -1
- package/dist/render/prop-allowlist.d.ts +10 -0
- package/dist/render/prop-allowlist.d.ts.map +1 -0
- package/dist/render/prop-allowlist.js +112 -0
- package/dist/render/prop-allowlist.js.map +1 -0
- package/dist/render/svg-path.d.ts +35 -0
- package/dist/render/svg-path.d.ts.map +1 -0
- package/dist/render/svg-path.js +211 -0
- package/dist/render/svg-path.js.map +1 -0
- package/dist/render/tree.d.ts.map +1 -1
- package/dist/render/tree.js +30 -5
- package/dist/render/tree.js.map +1 -1
- package/dist/{status-pill-Cgdl9FtP.js → status-pill-DIpXc5du.js} +2 -2
- package/dist/{status-pill-Cgdl9FtP.js.map → status-pill-DIpXc5du.js.map} +1 -1
- package/dist/{test-CAnkHA0n.js → test-ByRec1kd.js} +4 -4
- package/dist/{test-CAnkHA0n.js.map → test-ByRec1kd.js.map} +1 -1
- package/dist/tree-D5wYHpPu.js +1230 -0
- package/dist/tree-D5wYHpPu.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/animate/frame-coalescer.ts +63 -0
- package/src/animate/keyframes.ts +24 -5
- package/src/animate/transitions.ts +33 -3
- package/src/index.ts +24 -0
- package/src/mount.ts +12 -1
- package/src/render/bind-animate.tsx +370 -0
- package/src/render/bundle.ts +102 -10
- package/src/render/color-interp.ts +303 -0
- package/src/render/css-color.ts +145 -0
- package/src/render/diagnostics.ts +75 -0
- package/src/render/fill.tsx +85 -14
- package/src/render/filter-clamp.ts +99 -0
- package/src/render/keyframe-player.tsx +10 -2
- package/src/render/primitives/frame.tsx +47 -7
- package/src/render/primitives/image.tsx +6 -2
- package/src/render/primitives/index.ts +3 -0
- package/src/render/primitives/instance.tsx +14 -15
- package/src/render/primitives/shape.tsx +76 -12
- package/src/render/primitives/text.tsx +224 -7
- package/src/render/prop-allowlist.ts +119 -0
- package/src/render/svg-path.ts +215 -0
- package/src/render/tree.tsx +41 -6
- package/src/types.ts +27 -0
- package/dist/index-oteiocFe.js.map +0 -1
- package/dist/tree-DVYXwItH.js +0 -512
- package/dist/tree-DVYXwItH.js.map +0 -1
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// filter). Primitives enforce this at the DOM level by exposing those props as
|
|
12
12
|
// motion-bindable values rather than raw CSS.
|
|
13
13
|
|
|
14
|
+
import { sanitizeCssFilterString, warnRejectedFilter } from "../render/filter-clamp";
|
|
15
|
+
|
|
14
16
|
export type TransitionKind = "none" | "tween" | "spring" | "crossfade";
|
|
15
17
|
|
|
16
18
|
export interface TweenTransition {
|
|
@@ -23,6 +25,8 @@ export interface SpringTransition {
|
|
|
23
25
|
kind: "spring";
|
|
24
26
|
stiffness?: number;
|
|
25
27
|
damping?: number;
|
|
28
|
+
/** LSML 1.1 §6.2 — spring mass (kg). Default 1 (framer default). */
|
|
29
|
+
mass?: number;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface CrossfadeTransition {
|
|
@@ -44,6 +48,7 @@ export interface FramerTransition {
|
|
|
44
48
|
type?: "tween" | "spring";
|
|
45
49
|
stiffness?: number;
|
|
46
50
|
damping?: number;
|
|
51
|
+
mass?: number;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const NO_ANIMATION: FramerTransition = { duration: 0 };
|
|
@@ -69,6 +74,7 @@ export function toFramer(t: Transition | undefined): FramerTransition {
|
|
|
69
74
|
type: "spring",
|
|
70
75
|
...(t.stiffness !== undefined ? { stiffness: t.stiffness } : {}),
|
|
71
76
|
...(t.damping !== undefined ? { damping: t.damping } : {}),
|
|
77
|
+
...(t.mass !== undefined ? { mass: t.mass } : {}),
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
// crossfade at the per-prop level degenerates into a tween on opacity.
|
|
@@ -85,12 +91,18 @@ export function toFramer(t: Transition | undefined): FramerTransition {
|
|
|
85
91
|
* may declare. A primitive that doesn't natively animate a given key
|
|
86
92
|
* still converges it to this neutral value on mount so the element ends
|
|
87
93
|
* up visually correct (e.g. a `from.scale: 0.85` settles at `scale: 1`). */
|
|
88
|
-
const INITIAL_IDENTITY: Record<string, number> = {
|
|
94
|
+
const INITIAL_IDENTITY: Record<string, number | string> = {
|
|
89
95
|
opacity: 1,
|
|
90
96
|
scale: 1,
|
|
97
|
+
scaleX: 1,
|
|
98
|
+
scaleY: 1,
|
|
91
99
|
rotate: 0,
|
|
92
100
|
x: 0,
|
|
93
101
|
y: 0,
|
|
102
|
+
// LSML §6.1 filter identity — both functions are always present so
|
|
103
|
+
// framer interpolates between structurally-identical filter lists
|
|
104
|
+
// (the compiler emits the same two-function form, clamped per R8).
|
|
105
|
+
filter: "blur(0px) brightness(1)",
|
|
94
106
|
};
|
|
95
107
|
|
|
96
108
|
export interface MountPlay {
|
|
@@ -169,6 +181,7 @@ export function resolveTransition(
|
|
|
169
181
|
export function mountPlay(
|
|
170
182
|
base: Record<string, number | string>,
|
|
171
183
|
initial: Record<string, number | string> | undefined,
|
|
184
|
+
nodeId?: string,
|
|
172
185
|
): MountPlay {
|
|
173
186
|
if (!initial || Object.keys(initial).length === 0) {
|
|
174
187
|
// No `from` → mount directly at target. Pinning `initial` to the
|
|
@@ -176,13 +189,28 @@ export function mountPlay(
|
|
|
176
189
|
// preserves the existing "no jump, no mount-play" behaviour.
|
|
177
190
|
return { initial: base, animate: base };
|
|
178
191
|
}
|
|
192
|
+
// R8 runtime half (ADR 001 §5.1, issue #42) — `animate_initial` may
|
|
193
|
+
// come from a hand-crafted bundle that never went through the
|
|
194
|
+
// compiler clamps. Re-gate the filter string ; rejected → drop the
|
|
195
|
+
// key (the element mounts at the identity filter instead).
|
|
196
|
+
let from = initial;
|
|
197
|
+
if (initial["filter"] !== undefined) {
|
|
198
|
+
const safe = sanitizeCssFilterString(initial["filter"]);
|
|
199
|
+
from = { ...initial };
|
|
200
|
+
if (safe === null) {
|
|
201
|
+
warnRejectedFilter("animate_initial.filter", nodeId);
|
|
202
|
+
delete from["filter"];
|
|
203
|
+
} else {
|
|
204
|
+
from["filter"] = safe;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
179
207
|
const animate: Record<string, number | string> = { ...base };
|
|
180
|
-
for (const key of Object.keys(
|
|
208
|
+
for (const key of Object.keys(from)) {
|
|
181
209
|
if (!(key in animate)) {
|
|
182
210
|
animate[key] = INITIAL_IDENTITY[key] ?? 0;
|
|
183
211
|
}
|
|
184
212
|
}
|
|
185
|
-
return { initial, animate };
|
|
213
|
+
return { initial: from, animate };
|
|
186
214
|
}
|
|
187
215
|
|
|
188
216
|
/**
|
|
@@ -209,6 +237,8 @@ export function parseWireTransition(value: unknown): Transition | undefined {
|
|
|
209
237
|
const out: SpringTransition = { kind: "spring" };
|
|
210
238
|
if (typeof v.stiffness === "number") out.stiffness = v.stiffness;
|
|
211
239
|
if (typeof v.damping === "number") out.damping = v.damping;
|
|
240
|
+
// LSML §6.2 — mass rides the same wire spring shape.
|
|
241
|
+
if (typeof v.mass === "number") out.mass = v.mass;
|
|
212
242
|
return out;
|
|
213
243
|
}
|
|
214
244
|
return undefined;
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,22 @@ export type {
|
|
|
10
10
|
LumencastTokenProvider,
|
|
11
11
|
LumencastError,
|
|
12
12
|
LumencastMetric,
|
|
13
|
+
LumencastDiagnostic,
|
|
13
14
|
ErrorCode,
|
|
14
15
|
} from "./types.js";
|
|
15
16
|
|
|
17
|
+
// Anti-silent-drop diagnostics channel (ADR 001 §3.4, issue #34) —
|
|
18
|
+
// hosts that render outside `mount()` (embedding the tree directly,
|
|
19
|
+
// tooling, tests) can subscribe here ; `mount()` wires
|
|
20
|
+
// `MountOptions.onDiagnostic` to the same channel.
|
|
21
|
+
export {
|
|
22
|
+
addDiagnosticsHandler,
|
|
23
|
+
ANON_NODE_ID,
|
|
24
|
+
type RenderDiagnostic,
|
|
25
|
+
type DiagnosticHandler,
|
|
26
|
+
} from "./render/diagnostics.js";
|
|
27
|
+
export { PRIMITIVE_PROP_ALLOWLIST } from "./render/prop-allowlist.js";
|
|
28
|
+
|
|
16
29
|
// Bundle types are useful for hosts that want to typecheck pre-compiled scenes.
|
|
17
30
|
export type {
|
|
18
31
|
RenderBundle,
|
|
@@ -21,4 +34,15 @@ export type {
|
|
|
21
34
|
OperatorInput,
|
|
22
35
|
ExternalAdapter,
|
|
23
36
|
Asset,
|
|
37
|
+
BundleUrlResolver,
|
|
38
|
+
} from "./render/bundle.js";
|
|
39
|
+
|
|
40
|
+
// Profile gating (LSML 1.1 §17.3.1 / §17.5.1) — exported so hosts and the
|
|
41
|
+
// compiler-side tooling can apply the same rule outside the fetch path, and
|
|
42
|
+
// so the runtime "publishes the list of profiles it supports" per §17.3.1.
|
|
43
|
+
export {
|
|
44
|
+
SUPPORTED_PROFILES,
|
|
45
|
+
BundleIncompatibleError,
|
|
46
|
+
isAuthoringProfile,
|
|
47
|
+
validateBundleProfiles,
|
|
24
48
|
} from "./render/bundle.js";
|
package/src/mount.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createStore } from "./state/store.js";
|
|
|
11
11
|
import { createBundleFetcher, type BundleFetcher, type RenderBundle } from "./render/bundle.js";
|
|
12
12
|
import { WsClient, type ConnectionStatus, type TransportError } from "./transport/ws.js";
|
|
13
13
|
import { validateOptions } from "./internal/validate-options.js";
|
|
14
|
+
import { addDiagnosticsHandler } from "./render/diagnostics.js";
|
|
14
15
|
import type { LumencastError, LumencastHandle, LumencastToken, MountOptions } from "./types.js";
|
|
15
16
|
|
|
16
17
|
export function mount(options: MountOptions): LumencastHandle {
|
|
@@ -19,7 +20,10 @@ export function mount(options: MountOptions): LumencastHandle {
|
|
|
19
20
|
|
|
20
21
|
const store = createStore();
|
|
21
22
|
const baseUrl = deriveBaseUrl(options.serverUrl);
|
|
22
|
-
const bundleFetcher = createBundleFetcher({
|
|
23
|
+
const bundleFetcher = createBundleFetcher({
|
|
24
|
+
baseUrl,
|
|
25
|
+
...(options.resolveBundleUrl !== undefined ? { resolveUrl: options.resolveBundleUrl } : {}),
|
|
26
|
+
});
|
|
23
27
|
|
|
24
28
|
const bundleSignal = signal<RenderBundle | null>(null);
|
|
25
29
|
const statusSignal = signal<ConnectionStatus>("disconnected");
|
|
@@ -36,6 +40,12 @@ export function mount(options: MountOptions): LumencastHandle {
|
|
|
36
40
|
|
|
37
41
|
let active = true;
|
|
38
42
|
|
|
43
|
+
// ADR 001 §3.4 (issue #34) — anti-silent-drop diagnostics are events
|
|
44
|
+
// surfaced to the host, never console logs in `broadcast` mode.
|
|
45
|
+
const removeDiagnosticsHandler = options.onDiagnostic
|
|
46
|
+
? addDiagnosticsHandler(options.onDiagnostic)
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
39
49
|
const ws = new WsClient({
|
|
40
50
|
url: options.serverUrl,
|
|
41
51
|
token: options.token,
|
|
@@ -110,6 +120,7 @@ export function mount(options: MountOptions): LumencastHandle {
|
|
|
110
120
|
disconnect() {
|
|
111
121
|
if (!active) return;
|
|
112
122
|
active = false;
|
|
123
|
+
removeDiagnosticsHandler?.();
|
|
113
124
|
ws.close();
|
|
114
125
|
root.unmount();
|
|
115
126
|
},
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// LSML 1.1 §6.3 `bindAnimate` — continuous interpolation toward a live
|
|
2
|
+
// leaf value (ADR 001 §3.3, issue #33).
|
|
3
|
+
//
|
|
4
|
+
// Per binding, the hook subscribes the existing leaf-grain signal and
|
|
5
|
+
// retargets a Framer motion value on change — NO remount, the DOM node
|
|
6
|
+
// is identical before/after (RC#6). Scalar channels (§6.1) ride motion
|
|
7
|
+
// values attached to a wrapping `motion.div` ; colour channels (§6.5)
|
|
8
|
+
// are interpolated component-wise in sRGB through the strict shared
|
|
9
|
+
// parser and flow back into the primitive's resolved prop (which
|
|
10
|
+
// re-validates them — RC#11 belt and braces).
|
|
11
|
+
//
|
|
12
|
+
// Anti-DoS (Bastion RC#13) : deltas are coalesced per frame — one
|
|
13
|
+
// retarget max per rAF per binding, whatever the producer's rate
|
|
14
|
+
// (1 kHz tested in E2E). Retargets interrupt in-flight springs with
|
|
15
|
+
// velocity carry (§6.2/§6.4 — framer preserves a motion value's
|
|
16
|
+
// velocity when a spring animation is replaced ; no snap).
|
|
17
|
+
//
|
|
18
|
+
// R8 runtime half (issue #42) : `filter.blur` / `filter.brightness`
|
|
19
|
+
// values arriving live re-pass the same caps as the compiler before
|
|
20
|
+
// they may touch the composed CSS filter (see filter-clamp.ts).
|
|
21
|
+
//
|
|
22
|
+
// Stagger (§6.7) : inside a `repeat` iteration the FIRST animated
|
|
23
|
+
// retarget per binding is delayed by the ambient StaggerContext delay ;
|
|
24
|
+
// steady-state retargets are never delayed (a permanently-lagging gauge
|
|
25
|
+
// would defeat the purpose of a live binding). Documented hypothesis —
|
|
26
|
+
// the spec only constrains animation *starts*.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
animate,
|
|
30
|
+
useMotionValue,
|
|
31
|
+
useTransform,
|
|
32
|
+
type AnimationPlaybackControls,
|
|
33
|
+
type MotionValue,
|
|
34
|
+
type MotionStyle,
|
|
35
|
+
} from "framer-motion";
|
|
36
|
+
import { effect } from "@preact/signals-react";
|
|
37
|
+
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
38
|
+
import type { Store } from "../state/store";
|
|
39
|
+
import type { RenderNode } from "./bundle";
|
|
40
|
+
import { scopedPath } from "./scope";
|
|
41
|
+
import { StaggerContext } from "./stagger-context";
|
|
42
|
+
import { toFramer, type FramerTransition, type Transition } from "../animate/transitions";
|
|
43
|
+
import { createFrameCoalescer } from "../animate/frame-coalescer";
|
|
44
|
+
import { clampFilterChannel, warnRejectedFilter } from "./filter-clamp";
|
|
45
|
+
import { warnRejectedColor } from "./css-color";
|
|
46
|
+
import { emitDiagnostic } from "./diagnostics";
|
|
47
|
+
import { cssColorToRgba, mixRgba, serializeRgba, type Rgba } from "./color-interp";
|
|
48
|
+
|
|
49
|
+
/** §6.5 colour-typed bindAnimate keys → the runtime prop name the
|
|
50
|
+
* primitive reads (and re-validates through `parseCssColor`). */
|
|
51
|
+
export const BIND_ANIMATE_COLOR_PROPS: Readonly<Record<string, string>> = {
|
|
52
|
+
"style.color": "colour",
|
|
53
|
+
fill: "fill",
|
|
54
|
+
background: "background",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Scalar motion channels a bindAnimate key drives. */
|
|
58
|
+
type ScalarChannel = "opacity" | "x" | "y" | "scaleX" | "scaleY" | "rotate" | "blur" | "brightness";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate + normalise one live bindAnimate value into per-channel
|
|
62
|
+
* numeric targets. Returns `null` on rejection (wrong JSON shape per
|
|
63
|
+
* §6.3, non-finite numbers, filter values outside the R8 caps when the
|
|
64
|
+
* channel rejects rather than clamps). A `null` keeps the last
|
|
65
|
+
* known-good target — the raw input never reaches a style.
|
|
66
|
+
*
|
|
67
|
+
* Exported pure for the hostile-delta fixture suite (issue #42).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveScalarTargets(
|
|
70
|
+
key: string,
|
|
71
|
+
raw: unknown,
|
|
72
|
+
): Partial<Record<ScalarChannel, number>> | null {
|
|
73
|
+
switch (key) {
|
|
74
|
+
case "opacity": {
|
|
75
|
+
const v = toFiniteScalar(raw);
|
|
76
|
+
if (v === null) return null;
|
|
77
|
+
return { opacity: v < 0 ? 0 : v > 1 ? 1 : v };
|
|
78
|
+
}
|
|
79
|
+
case "transform.translate": {
|
|
80
|
+
if (!Array.isArray(raw) || raw.length !== 2) return null;
|
|
81
|
+
const tx = toFiniteScalar(raw[0]);
|
|
82
|
+
const ty = toFiniteScalar(raw[1]);
|
|
83
|
+
if (tx === null || ty === null) return null;
|
|
84
|
+
return { x: tx, y: ty };
|
|
85
|
+
}
|
|
86
|
+
case "transform.scale": {
|
|
87
|
+
const s = toFiniteScalar(raw);
|
|
88
|
+
if (s !== null) return { scaleX: s, scaleY: s };
|
|
89
|
+
if (Array.isArray(raw) && raw.length === 2) {
|
|
90
|
+
const sx = toFiniteScalar(raw[0]);
|
|
91
|
+
const sy = toFiniteScalar(raw[1]);
|
|
92
|
+
if (sx === null || sy === null) return null;
|
|
93
|
+
return { scaleX: sx, scaleY: sy };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
case "transform.rotate": {
|
|
98
|
+
const v = toFiniteScalar(raw);
|
|
99
|
+
if (v === null) return null;
|
|
100
|
+
return { rotate: v };
|
|
101
|
+
}
|
|
102
|
+
case "filter.blur": {
|
|
103
|
+
const v = clampFilterChannel("blur", raw);
|
|
104
|
+
return v === null ? null : { blur: v };
|
|
105
|
+
}
|
|
106
|
+
case "filter.brightness": {
|
|
107
|
+
const v = clampFilterChannel("brightness", raw);
|
|
108
|
+
return v === null ? null : { brightness: v };
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── IEEE-754 `-0` policy (R8 / PR #39 coherence, issue #33) ───────────
|
|
116
|
+
// `-0 < 0` is FALSE, so a plain sign clamp lets -0 through. Uniform
|
|
117
|
+
// rule : no -0 ever reaches a motion value or a style. Per channel :
|
|
118
|
+
// · opacity — the negative case CLAMPS to 0 (`-5 → 0`), so -0 follows
|
|
119
|
+
// the same line and normalises to +0 (rejecting -0 while clamping
|
|
120
|
+
// -5 would be incoherent within the channel) ;
|
|
121
|
+
// · translate / scale / rotate — negatives are valid values and
|
|
122
|
+
// `-0 == 0` is mathematically neutral, so -0 normalises to +0 (a
|
|
123
|
+
// producer computing `-x` at x = 0 must not be rejected into a
|
|
124
|
+
// stale last-good) ;
|
|
125
|
+
// · filter.blur / filter.brightness — REJECTED (null → last-good) by
|
|
126
|
+
// `clampFilterChannel` : the R8 gate treats any negative sign,
|
|
127
|
+
// including -0, as hostile input (compiler mirror, issues #39/#41
|
|
128
|
+
// — do not relax here).
|
|
129
|
+
/** Finite-number gate + `-0 → +0` normalisation for the generic scalar
|
|
130
|
+
* channels (filter channels go through `clampFilterChannel` instead). */
|
|
131
|
+
function toFiniteScalar(v: unknown): number | null {
|
|
132
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
133
|
+
return Object.is(v, -0) ? 0 : v;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Default retarget transition when neither a per-leaf wire directive
|
|
137
|
+
* nor a compiled `transitions` entry resolves : the §6.2 default
|
|
138
|
+
* spring (stiffness 170, damping 26, mass 1). A spring is the only
|
|
139
|
+
* curve with well-defined retarget semantics (velocity carry) — a
|
|
140
|
+
* documented hypothesis, see the PR for issue #33. */
|
|
141
|
+
export const DEFAULT_BIND_ANIMATE_TRANSITION: Transition = {
|
|
142
|
+
kind: "spring",
|
|
143
|
+
stiffness: 170,
|
|
144
|
+
damping: 26,
|
|
145
|
+
mass: 1,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** node.transitions lookup key for each bindAnimate key (mirrors the
|
|
149
|
+
* compiler's `transitionKeysForBindAnimate`). */
|
|
150
|
+
function transitionLookupKey(key: string): string {
|
|
151
|
+
switch (key) {
|
|
152
|
+
case "opacity":
|
|
153
|
+
return "opacity";
|
|
154
|
+
case "transform.translate":
|
|
155
|
+
return "x";
|
|
156
|
+
case "transform.scale":
|
|
157
|
+
return "scale";
|
|
158
|
+
case "transform.rotate":
|
|
159
|
+
return "rotate";
|
|
160
|
+
case "filter.blur":
|
|
161
|
+
case "filter.brightness":
|
|
162
|
+
return "filter";
|
|
163
|
+
default:
|
|
164
|
+
return BIND_ANIMATE_COLOR_PROPS[key] ?? key;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface BindAnimateHandle {
|
|
169
|
+
/** Motion-value style for the wrapping `motion.div` — `null` when no
|
|
170
|
+
* scalar channel is bound (no wrapper needed). */
|
|
171
|
+
motionStyle: MotionStyle | null;
|
|
172
|
+
/** Live-interpolated colour values, keyed by the primitive prop name
|
|
173
|
+
* (`colour` / `fill` / `background`). Merged over `resolved`. */
|
|
174
|
+
colorProps: Record<string, string>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const NO_COLORS: Record<string, string> = {};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Drive a node's `animateBindings`. Must be called unconditionally
|
|
181
|
+
* (hook) ; cheap no-op when the node has no bindings.
|
|
182
|
+
*/
|
|
183
|
+
export function useBindAnimate(node: RenderNode, store: Store, scope: string): BindAnimateHandle {
|
|
184
|
+
const bindings = node.animateBindings;
|
|
185
|
+
const staggerDelayMs = useContext(StaggerContext);
|
|
186
|
+
|
|
187
|
+
// Fixed channel set — created unconditionally so hook order is
|
|
188
|
+
// stable ; unbound channels stay at their identity value.
|
|
189
|
+
const opacity = useMotionValue(1);
|
|
190
|
+
const x = useMotionValue(0);
|
|
191
|
+
const y = useMotionValue(0);
|
|
192
|
+
const scaleX = useMotionValue(1);
|
|
193
|
+
const scaleY = useMotionValue(1);
|
|
194
|
+
const rotate = useMotionValue(0);
|
|
195
|
+
const blur = useMotionValue(0);
|
|
196
|
+
const brightness = useMotionValue(1);
|
|
197
|
+
// Composed CSS filter — both functions always present so framer
|
|
198
|
+
// interpolates structurally-identical lists (same form as the
|
|
199
|
+
// compiler emits, clamped per R8).
|
|
200
|
+
const filter = useTransform(
|
|
201
|
+
[blur, brightness] as [MotionValue<number>, MotionValue<number>],
|
|
202
|
+
([b, br]: number[]) => `blur(${b}px) brightness(${br})`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const [colorProps, setColorProps] = useState<Record<string, string>>(NO_COLORS);
|
|
206
|
+
|
|
207
|
+
const channels = useRef<Record<ScalarChannel, MotionValue<number>>>({
|
|
208
|
+
opacity,
|
|
209
|
+
x,
|
|
210
|
+
y,
|
|
211
|
+
scaleX,
|
|
212
|
+
scaleY,
|
|
213
|
+
rotate,
|
|
214
|
+
blur,
|
|
215
|
+
brightness,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (!bindings || Object.keys(bindings).length === 0) return;
|
|
220
|
+
|
|
221
|
+
const mvs = channels.current;
|
|
222
|
+
const controls = new Map<string, AnimationPlaybackControls>();
|
|
223
|
+
const colorState = new Map<string, { current: Rgba }>();
|
|
224
|
+
const animatedOnce = new Set<string>();
|
|
225
|
+
let mounted = false;
|
|
226
|
+
|
|
227
|
+
const transitionFor = (key: string, fullPath: string): FramerTransition => {
|
|
228
|
+
const live = store.transitionSignal(fullPath).peek();
|
|
229
|
+
const declared = live ?? node.transitions?.[transitionLookupKey(key)];
|
|
230
|
+
const base = toFramer(declared ?? DEFAULT_BIND_ANIMATE_TRANSITION);
|
|
231
|
+
// §6.7 — stagger delays only the first animated retarget.
|
|
232
|
+
if (staggerDelayMs > 0 && !animatedOnce.has(key)) {
|
|
233
|
+
return { ...base, delay: staggerDelayMs / 1000 } as FramerTransition;
|
|
234
|
+
}
|
|
235
|
+
return base;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const dispatch = (key: string, value: unknown, instant: boolean): void => {
|
|
239
|
+
const colorProp = BIND_ANIMATE_COLOR_PROPS[key];
|
|
240
|
+
const fullPath = scopedPath(scope, bindings[key] as string);
|
|
241
|
+
|
|
242
|
+
if (colorProp !== undefined) {
|
|
243
|
+
// §6.5 — canonicalise BOTH endpoints through the strict parser
|
|
244
|
+
// before interpolating ; never a raw string.
|
|
245
|
+
const end = cssColorToRgba(value);
|
|
246
|
+
if (end === null) {
|
|
247
|
+
warnRejectedColor(`bindAnimate.${key}`, node.id);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const prev = colorState.get(key);
|
|
251
|
+
if (instant || prev === undefined) {
|
|
252
|
+
colorState.set(key, { current: end });
|
|
253
|
+
setColorProps((p) => ({ ...p, [colorProp]: serializeRgba(end) }));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const start = prev.current;
|
|
257
|
+
const tx = transitionFor(key, fullPath);
|
|
258
|
+
animatedOnce.add(key);
|
|
259
|
+
controls.get(`color:${key}`)?.stop();
|
|
260
|
+
controls.set(
|
|
261
|
+
`color:${key}`,
|
|
262
|
+
animate(0, 1, {
|
|
263
|
+
...tx,
|
|
264
|
+
onUpdate: (t) => {
|
|
265
|
+
const mixed = mixRgba(start, end, t);
|
|
266
|
+
prev.current = mixed;
|
|
267
|
+
setColorProps((p) => ({ ...p, [colorProp]: serializeRgba(mixed) }));
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const targets = resolveScalarTargets(key, value);
|
|
275
|
+
if (targets === null) {
|
|
276
|
+
// R9 — the offending value is never logged.
|
|
277
|
+
if (key.startsWith("filter.")) warnRejectedFilter(`bindAnimate.${key}`, node.id);
|
|
278
|
+
else warnRejectedBindValue(key, node.id);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (instant) {
|
|
282
|
+
// §6.3.1 — on mount the rendered state initialises from the
|
|
283
|
+
// bound value instantly (there is no previous state).
|
|
284
|
+
for (const [ch, v] of Object.entries(targets)) {
|
|
285
|
+
mvs[ch as ScalarChannel].jump(v as number);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const tx = transitionFor(key, fullPath);
|
|
290
|
+
animatedOnce.add(key);
|
|
291
|
+
for (const [ch, v] of Object.entries(targets)) {
|
|
292
|
+
// framer's animate() replaces any in-flight animation on the
|
|
293
|
+
// motion value and seeds the new spring with the value's
|
|
294
|
+
// current velocity — §6.2 velocity carry, no snap.
|
|
295
|
+
controls.set(ch, animate(mvs[ch as ScalarChannel], v as number, tx));
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// RC#13 — one retarget max per rAF per binding.
|
|
300
|
+
const coalescer = createFrameCoalescer((key, value) => dispatch(key, value, false));
|
|
301
|
+
|
|
302
|
+
const disposers = Object.entries(bindings).map(([key, path]) =>
|
|
303
|
+
effect(() => {
|
|
304
|
+
const v = store.signal(scopedPath(scope, path)).value;
|
|
305
|
+
if (v === undefined) return;
|
|
306
|
+
if (!mounted) dispatch(key, v, true);
|
|
307
|
+
else coalescer.push(key, v);
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
mounted = true;
|
|
311
|
+
|
|
312
|
+
return () => {
|
|
313
|
+
for (const d of disposers) d();
|
|
314
|
+
coalescer.dispose();
|
|
315
|
+
for (const c of controls.values()) c.stop();
|
|
316
|
+
};
|
|
317
|
+
// node/store/scope identity changes re-wire every subscription ;
|
|
318
|
+
// staggerDelayMs is stable per repeat iteration.
|
|
319
|
+
}, [node, bindings, store, scope, staggerDelayMs]);
|
|
320
|
+
|
|
321
|
+
const motionStyle = useMemo<MotionStyle | null>(() => {
|
|
322
|
+
if (!bindings) return null;
|
|
323
|
+
const style: MotionStyle = {};
|
|
324
|
+
let any = false;
|
|
325
|
+
for (const key of Object.keys(bindings)) {
|
|
326
|
+
switch (key) {
|
|
327
|
+
case "opacity":
|
|
328
|
+
style.opacity = opacity;
|
|
329
|
+
any = true;
|
|
330
|
+
break;
|
|
331
|
+
case "transform.translate":
|
|
332
|
+
style.x = x;
|
|
333
|
+
style.y = y;
|
|
334
|
+
any = true;
|
|
335
|
+
break;
|
|
336
|
+
case "transform.scale":
|
|
337
|
+
style.scaleX = scaleX;
|
|
338
|
+
style.scaleY = scaleY;
|
|
339
|
+
any = true;
|
|
340
|
+
break;
|
|
341
|
+
case "transform.rotate":
|
|
342
|
+
style.rotate = rotate;
|
|
343
|
+
any = true;
|
|
344
|
+
break;
|
|
345
|
+
case "filter.blur":
|
|
346
|
+
case "filter.brightness":
|
|
347
|
+
style.filter = filter;
|
|
348
|
+
any = true;
|
|
349
|
+
break;
|
|
350
|
+
default:
|
|
351
|
+
break; // colour keys flow through colorProps, not the wrapper
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!any) return null;
|
|
355
|
+
style.willChange = "transform, opacity, filter";
|
|
356
|
+
return style;
|
|
357
|
+
}, [bindings, opacity, x, y, scaleX, scaleY, rotate, filter]);
|
|
358
|
+
|
|
359
|
+
return { motionStyle, colorProps };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** R9 diagnostic — shape-invalid bindAnimate value (non-filter
|
|
363
|
+
* channels). Structured event, value withheld. */
|
|
364
|
+
function warnRejectedBindValue(key: string, nodeId?: string): void {
|
|
365
|
+
emitDiagnostic(
|
|
366
|
+
nodeId,
|
|
367
|
+
`bindAnimate.${key}`,
|
|
368
|
+
"rejected bound value : JSON shape does not match the property type (LSML §6.3)",
|
|
369
|
+
);
|
|
370
|
+
}
|