@lumencast/runtime 0.3.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 +33 -1
- package/dist/animate/transitions.d.ts.map +1 -1
- package/dist/animate/transitions.js +78 -3
- package/dist/animate/transitions.js.map +1 -1
- package/dist/{broadcast-B82fQPph.js → broadcast-3vYij4k-.js} +3 -3
- package/dist/{broadcast-B82fQPph.js.map → broadcast-3vYij4k-.js.map} +1 -1
- package/dist/{control-DIfwMYRb.js → control-BFNkY7-6.js} +4 -4
- package/dist/{control-DIfwMYRb.js.map → control-BFNkY7-6.js.map} +1 -1
- package/dist/{index-BFZXQAD7.js → index-CyOlpZAL.js} +318 -145
- 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 +44 -13
- 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 +8 -5
- 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 +58 -14
- 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 +181 -9
- 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-DNHbHdag.js → status-pill-DIpXc5du.js} +2 -2
- package/dist/{status-pill-DNHbHdag.js.map → status-pill-DIpXc5du.js.map} +1 -1
- package/dist/{test-Dp0QrKYM.js → test-ByRec1kd.js} +4 -4
- package/dist/{test-Dp0QrKYM.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 +85 -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 +53 -14
- package/src/render/primitives/image.tsx +8 -4
- package/src/render/primitives/index.ts +3 -0
- package/src/render/primitives/instance.tsx +14 -15
- package/src/render/primitives/shape.tsx +78 -14
- package/src/render/primitives/text.tsx +226 -9
- 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-BFZXQAD7.js.map +0 -1
- package/dist/tree-x5Qd9Kq0.js +0 -508
- package/dist/tree-x5Qd9Kq0.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 {
|
|
@@ -98,6 +110,58 @@ export interface MountPlay {
|
|
|
98
110
|
animate: Record<string, number | string>;
|
|
99
111
|
}
|
|
100
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Default mount-play timing — applies when a node carries an
|
|
115
|
+
* `animate_initial` (LSML 1.1 `animate.from`) but no per-prop
|
|
116
|
+
* `transitions` entry resolves for any animated key. The compiler
|
|
117
|
+
* documents that `from` without an explicit `transition` mount-plays
|
|
118
|
+
* "with the runtime's default timing" ; before this constant existed the
|
|
119
|
+
* fallback was `toFramer(undefined)` → `{ duration: 0 }`, which snapped
|
|
120
|
+
* the element straight to its settled state (the mount-play never
|
|
121
|
+
* visibly played). 400 ms ease-out matches the runtime's other implicit
|
|
122
|
+
* timings (crossfade fallback, scene-track fade).
|
|
123
|
+
*/
|
|
124
|
+
export const DEFAULT_MOUNT_PLAY_TRANSITION: Transition = {
|
|
125
|
+
kind: "tween",
|
|
126
|
+
duration_ms: 400,
|
|
127
|
+
ease: "cubic-out",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the transition a primitive should hand framer-motion.
|
|
132
|
+
*
|
|
133
|
+
* `keys` are the primitive's native animated prop keys, scanned in
|
|
134
|
+
* order (e.g. `["opacity", "src"]` for Image). When the node also
|
|
135
|
+
* carries an `animate_initial`, the lookup widens to the keys the
|
|
136
|
+
* mount-play actually moves (`from.scale` may have lowered a `scale`
|
|
137
|
+
* transition that an opacity-only primitive would otherwise never look
|
|
138
|
+
* up), and — critically — falls back to
|
|
139
|
+
* `DEFAULT_MOUNT_PLAY_TRANSITION` instead of "no animation" : a
|
|
140
|
+
* mount-play must tween, never complete in zero frames.
|
|
141
|
+
*
|
|
142
|
+
* Without `animate_initial` the prior behaviour is preserved exactly :
|
|
143
|
+
* first declared transition among `keys`, else `undefined` (deltas
|
|
144
|
+
* snap unless a transition is declared).
|
|
145
|
+
*/
|
|
146
|
+
export function resolveTransition(
|
|
147
|
+
transitionFor: (key: string) => Transition | undefined,
|
|
148
|
+
keys: string[],
|
|
149
|
+
animateInitial?: Record<string, number | string>,
|
|
150
|
+
): Transition | undefined {
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
const t = transitionFor(key);
|
|
153
|
+
if (t !== undefined) return t;
|
|
154
|
+
}
|
|
155
|
+
if (animateInitial && Object.keys(animateInitial).length > 0) {
|
|
156
|
+
for (const key of Object.keys(animateInitial)) {
|
|
157
|
+
const t = transitionFor(key);
|
|
158
|
+
if (t !== undefined) return t;
|
|
159
|
+
}
|
|
160
|
+
return DEFAULT_MOUNT_PLAY_TRANSITION;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
/**
|
|
102
166
|
* Build framer-motion `initial` / `animate` props for a primitive that
|
|
103
167
|
* may carry an LSML 1.1 `animate.from` initial state.
|
|
@@ -117,6 +181,7 @@ export interface MountPlay {
|
|
|
117
181
|
export function mountPlay(
|
|
118
182
|
base: Record<string, number | string>,
|
|
119
183
|
initial: Record<string, number | string> | undefined,
|
|
184
|
+
nodeId?: string,
|
|
120
185
|
): MountPlay {
|
|
121
186
|
if (!initial || Object.keys(initial).length === 0) {
|
|
122
187
|
// No `from` → mount directly at target. Pinning `initial` to the
|
|
@@ -124,13 +189,28 @@ export function mountPlay(
|
|
|
124
189
|
// preserves the existing "no jump, no mount-play" behaviour.
|
|
125
190
|
return { initial: base, animate: base };
|
|
126
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
|
+
}
|
|
127
207
|
const animate: Record<string, number | string> = { ...base };
|
|
128
|
-
for (const key of Object.keys(
|
|
208
|
+
for (const key of Object.keys(from)) {
|
|
129
209
|
if (!(key in animate)) {
|
|
130
210
|
animate[key] = INITIAL_IDENTITY[key] ?? 0;
|
|
131
211
|
}
|
|
132
212
|
}
|
|
133
|
-
return { initial, animate };
|
|
213
|
+
return { initial: from, animate };
|
|
134
214
|
}
|
|
135
215
|
|
|
136
216
|
/**
|
|
@@ -157,6 +237,8 @@ export function parseWireTransition(value: unknown): Transition | undefined {
|
|
|
157
237
|
const out: SpringTransition = { kind: "spring" };
|
|
158
238
|
if (typeof v.stiffness === "number") out.stiffness = v.stiffness;
|
|
159
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;
|
|
160
242
|
return out;
|
|
161
243
|
}
|
|
162
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
|
+
}
|