@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.
Files changed (130) hide show
  1. package/README.md +57 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/animate/frame-coalescer.d.ts +13 -0
  4. package/dist/animate/frame-coalescer.d.ts.map +1 -0
  5. package/dist/animate/frame-coalescer.js +46 -0
  6. package/dist/animate/frame-coalescer.js.map +1 -0
  7. package/dist/animate/keyframes.d.ts +1 -1
  8. package/dist/animate/keyframes.d.ts.map +1 -1
  9. package/dist/animate/keyframes.js +20 -6
  10. package/dist/animate/keyframes.js.map +1 -1
  11. package/dist/animate/transitions.d.ts +33 -1
  12. package/dist/animate/transitions.d.ts.map +1 -1
  13. package/dist/animate/transitions.js +78 -3
  14. package/dist/animate/transitions.js.map +1 -1
  15. package/dist/{broadcast-B82fQPph.js → broadcast-3vYij4k-.js} +3 -3
  16. package/dist/{broadcast-B82fQPph.js.map → broadcast-3vYij4k-.js.map} +1 -1
  17. package/dist/{control-DIfwMYRb.js → control-BFNkY7-6.js} +4 -4
  18. package/dist/{control-DIfwMYRb.js.map → control-BFNkY7-6.js.map} +1 -1
  19. package/dist/{index-BFZXQAD7.js → index-CyOlpZAL.js} +318 -145
  20. package/dist/index-CyOlpZAL.js.map +1 -0
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.html +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/lumencast.js +9 -2
  27. package/dist/mount.d.ts.map +1 -1
  28. package/dist/mount.js +11 -1
  29. package/dist/mount.js.map +1 -1
  30. package/dist/render/bind-animate.d.ts +40 -0
  31. package/dist/render/bind-animate.d.ts.map +1 -0
  32. package/dist/render/bind-animate.js +329 -0
  33. package/dist/render/bind-animate.js.map +1 -0
  34. package/dist/render/bundle.d.ts +48 -6
  35. package/dist/render/bundle.d.ts.map +1 -1
  36. package/dist/render/bundle.js +71 -4
  37. package/dist/render/bundle.js.map +1 -1
  38. package/dist/render/color-interp.d.ts +18 -0
  39. package/dist/render/color-interp.d.ts.map +1 -0
  40. package/dist/render/color-interp.js +303 -0
  41. package/dist/render/color-interp.js.map +1 -0
  42. package/dist/render/css-color.d.ts +16 -0
  43. package/dist/render/css-color.d.ts.map +1 -0
  44. package/dist/render/css-color.js +130 -0
  45. package/dist/render/css-color.js.map +1 -0
  46. package/dist/render/diagnostics.d.ts +26 -0
  47. package/dist/render/diagnostics.d.ts.map +1 -0
  48. package/dist/render/diagnostics.js +58 -0
  49. package/dist/render/diagnostics.js.map +1 -0
  50. package/dist/render/fill.d.ts +15 -3
  51. package/dist/render/fill.d.ts.map +1 -1
  52. package/dist/render/fill.js +81 -14
  53. package/dist/render/fill.js.map +1 -1
  54. package/dist/render/filter-clamp.d.ts +35 -0
  55. package/dist/render/filter-clamp.d.ts.map +1 -0
  56. package/dist/render/filter-clamp.js +90 -0
  57. package/dist/render/filter-clamp.js.map +1 -0
  58. package/dist/render/keyframe-player.d.ts +4 -1
  59. package/dist/render/keyframe-player.d.ts.map +1 -1
  60. package/dist/render/keyframe-player.js +2 -2
  61. package/dist/render/keyframe-player.js.map +1 -1
  62. package/dist/render/primitives/frame.d.ts +16 -1
  63. package/dist/render/primitives/frame.d.ts.map +1 -1
  64. package/dist/render/primitives/frame.js +44 -13
  65. package/dist/render/primitives/frame.js.map +1 -1
  66. package/dist/render/primitives/image.d.ts +1 -1
  67. package/dist/render/primitives/image.d.ts.map +1 -1
  68. package/dist/render/primitives/image.js +8 -5
  69. package/dist/render/primitives/image.js.map +1 -1
  70. package/dist/render/primitives/index.d.ts +3 -0
  71. package/dist/render/primitives/index.d.ts.map +1 -1
  72. package/dist/render/primitives/index.js.map +1 -1
  73. package/dist/render/primitives/instance.d.ts +1 -1
  74. package/dist/render/primitives/instance.d.ts.map +1 -1
  75. package/dist/render/primitives/instance.js +10 -13
  76. package/dist/render/primitives/instance.js.map +1 -1
  77. package/dist/render/primitives/shape.d.ts +9 -3
  78. package/dist/render/primitives/shape.d.ts.map +1 -1
  79. package/dist/render/primitives/shape.js +58 -14
  80. package/dist/render/primitives/shape.js.map +1 -1
  81. package/dist/render/primitives/text.d.ts +35 -4
  82. package/dist/render/primitives/text.d.ts.map +1 -1
  83. package/dist/render/primitives/text.js +181 -9
  84. package/dist/render/primitives/text.js.map +1 -1
  85. package/dist/render/prop-allowlist.d.ts +10 -0
  86. package/dist/render/prop-allowlist.d.ts.map +1 -0
  87. package/dist/render/prop-allowlist.js +112 -0
  88. package/dist/render/prop-allowlist.js.map +1 -0
  89. package/dist/render/svg-path.d.ts +35 -0
  90. package/dist/render/svg-path.d.ts.map +1 -0
  91. package/dist/render/svg-path.js +211 -0
  92. package/dist/render/svg-path.js.map +1 -0
  93. package/dist/render/tree.d.ts.map +1 -1
  94. package/dist/render/tree.js +30 -5
  95. package/dist/render/tree.js.map +1 -1
  96. package/dist/{status-pill-DNHbHdag.js → status-pill-DIpXc5du.js} +2 -2
  97. package/dist/{status-pill-DNHbHdag.js.map → status-pill-DIpXc5du.js.map} +1 -1
  98. package/dist/{test-Dp0QrKYM.js → test-ByRec1kd.js} +4 -4
  99. package/dist/{test-Dp0QrKYM.js.map → test-ByRec1kd.js.map} +1 -1
  100. package/dist/tree-D5wYHpPu.js +1230 -0
  101. package/dist/tree-D5wYHpPu.js.map +1 -0
  102. package/dist/types.d.ts +26 -0
  103. package/dist/types.d.ts.map +1 -1
  104. package/package.json +5 -4
  105. package/src/animate/frame-coalescer.ts +63 -0
  106. package/src/animate/keyframes.ts +24 -5
  107. package/src/animate/transitions.ts +85 -3
  108. package/src/index.ts +24 -0
  109. package/src/mount.ts +12 -1
  110. package/src/render/bind-animate.tsx +370 -0
  111. package/src/render/bundle.ts +102 -10
  112. package/src/render/color-interp.ts +303 -0
  113. package/src/render/css-color.ts +145 -0
  114. package/src/render/diagnostics.ts +75 -0
  115. package/src/render/fill.tsx +85 -14
  116. package/src/render/filter-clamp.ts +99 -0
  117. package/src/render/keyframe-player.tsx +10 -2
  118. package/src/render/primitives/frame.tsx +53 -14
  119. package/src/render/primitives/image.tsx +8 -4
  120. package/src/render/primitives/index.ts +3 -0
  121. package/src/render/primitives/instance.tsx +14 -15
  122. package/src/render/primitives/shape.tsx +78 -14
  123. package/src/render/primitives/text.tsx +226 -9
  124. package/src/render/prop-allowlist.ts +119 -0
  125. package/src/render/svg-path.ts +215 -0
  126. package/src/render/tree.tsx +41 -6
  127. package/src/types.ts +27 -0
  128. package/dist/index-BFZXQAD7.js.map +0 -1
  129. package/dist/tree-x5Qd9Kq0.js +0 -508
  130. 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(initial)) {
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({ baseUrl });
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
+ }