@unrulysystems/native-motion 0.1.0-alpha.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 (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,3180 @@
1
+ // Motion.View's brain (specs/M2.2-BUILD-PACKET.md §pinned semantics): validate → resolve → diff →
2
+ // driver commands, factored OUT of the React component so the behavioral checks drive it
3
+ // deterministically against the manual-stepping driver (no React timing in the verification path —
4
+ // the same pure-interior/thin-binding split the core uses). The component wrapper owns only React
5
+ // concerns (hook driver, animated style, prop plumbing); everything semantic lives here and calls
6
+ // core's exported pipeline — no forked resolution logic (REQ-API-017 is the seam this reuses).
7
+
8
+ import {
9
+ InvalidTransitionError,
10
+ validateTransition,
11
+ assertTargetShape,
12
+ captureTransition,
13
+ describeValue,
14
+ getDefaultTransition,
15
+ hostCapabilities,
16
+ DiscreteHostBaseError,
17
+ InvalidTargetError,
18
+ isAuto,
19
+ isDiscreteKeyword,
20
+ parseColor,
21
+ parseValue,
22
+ isLengthTarget,
23
+ resolveLengthToPx,
24
+ resolveStartValue,
25
+ springKeyframeCountRefusal,
26
+ targetShapeRefusal,
27
+ transformColor,
28
+ validateTarget,
29
+ validateTargetRefusal,
30
+ type DriverCommand,
31
+ type ElementHandle,
32
+ type HostCapabilities,
33
+ type LengthLayoutContext,
34
+ type MeasureResolveContextError,
35
+ type PositionalLengthAxis,
36
+ type PropTarget,
37
+ type PropTargetTo,
38
+ type PropTransition,
39
+ type ResolvedValue,
40
+ type RGBA,
41
+ type Target,
42
+ type Transition,
43
+ TARGET_PROPERTY_KEYS,
44
+ } from '@unrulysystems/native-motion-core'
45
+ import { rewritePathCommands, seedPathRotationKey } from './pathTransition'
46
+ import {
47
+ isNativeMeasureLengthKey,
48
+ isNativeViewMotionProp,
49
+ NATIVE_PROPERTY_TRANSITION_OPTIONS,
50
+ nativeMountTargetValueKind,
51
+ } from './shippedSurface'
52
+ import { TRANSFORM_KEY_NAMES } from './mappedKeys'
53
+ import { keyframeTimingRefusal } from './keyframeTiming'
54
+
55
+ const TRANSFORM_KEY_SET = new Set<string>(TRANSFORM_KEY_NAMES)
56
+ const TRANSITION_MAP_KEYS = new Set<string>(['default', 'layout', ...TARGET_PROPERTY_KEYS])
57
+ /** Instant property transition for reduced-motion / static / skip paths (pin type:false analog). */
58
+ const INSTANT_TRANSITION: Transition = Object.freeze({ type: 'tween', duration: 0 })
59
+ import {
60
+ captureBoundedArray,
61
+ capturedArrayDescription,
62
+ isRepeatFoldRefusal,
63
+ resolveTransitionForKey,
64
+ } from '@unrulysystems/native-motion-core/internal-driver'
65
+ import type { WorkletDriverBinding } from '../driver/workletDriver'
66
+ import { IDENTITY_DECLARATIVE_LANE_COMMAND } from './identityValueChannelLaneMarker'
67
+ import { projectColorSequence } from './colorProjection'
68
+ import { projectDiscreteStep } from './discreteProjection'
69
+
70
+ export interface MotionViewProps {
71
+ // First-paint state (REQ-API-010): a Target overlays the start values; `false` mounts already AT
72
+ // the resolved `animate` with no entrance animation; omitted → style/host-base start values.
73
+ readonly initial?: Target | false | undefined
74
+ readonly animate: Target
75
+ readonly transition?: Transition | undefined
76
+ // Static style passthrough consulted for start values (REQ-API-011). L1: numeric values plus
77
+ // color strings for rgba-typed keys; the style-merge policy stays a flagged §7 call.
78
+ readonly style?: Readonly<Record<string, number | string>>
79
+ // FLAG 5a (REQ-API-020): keys registered STATIC — at their style/host-base start value with no
80
+ // target and no entrance command — because an external lane drives them (the drag axis through
81
+ // the driver's write lane). The initial-only-key rule generalized; a key already covered by
82
+ // animate/initial registers through its normal path and this list is a no-op for it.
83
+ readonly staticKeys?: readonly string[]
84
+ /** Internal host identity used solely for element-owned validation diagnostics. */
85
+ readonly componentId?: MotionComponentId
86
+ }
87
+
88
+ export type MotionComponentId = '<Motion.View>' | '<Motion.Text>' | '<Motion.Image>'
89
+
90
+ // L1 (specs/L1-BUILD-PACKET.md §semantics 4): the endpoint-sync port the wrapper provides. Invoked
91
+ // with the CURRENT endpoints at every command entry (mount and each color retarget), BEFORE the
92
+ // driver command is issued, so the animated style's endpoint shared values are never stale when a
93
+ // fresh progress lands — one sync path, no lazily-reconciled state.
94
+ export interface MotionViewControllerHooks {
95
+ readonly onColorEndpoints?: (key: string, sequence: readonly RGBA[]) => void
96
+ // T23 B2b: the discrete lane's endpoint publication — the [from, to] keyword pair the animated
97
+ // style projects through (the onColorEndpoints mirror; primitive-only payload for the crossing).
98
+ readonly onDiscreteEndpoints?: (key: string, pair: readonly [string, string]) => void
99
+ // The component's severity channel (review major 42): the R8-F1 spring/keyframe-array compat refusal
100
+ // for INDIRECT consumers (a gesture/label array riding the element-transition fallback reaches the
101
+ // controller here, past its supplying gate) is reported + the property dropped in production, thrown
102
+ // in development. A direct controller consumer defaults to development (the loud law).
103
+ readonly severity?: NativeSeverity
104
+ readonly report?: (error: Error) => void
105
+ // REQ-API-035: live MotionConfig policy box (the wrapper mutates `.current` each render so a
106
+ // late AccessibilityInfo "user" resolution and isStatic/skipAnimations updates reach the
107
+ // next command without recreating the controller).
108
+ readonly motionPolicyBox?: {
109
+ current: { readonly shouldReduceMotion: boolean; readonly skipAnimations: boolean }
110
+ }
111
+ // REQ-VALUETYPE-013: live layout context for measure-resolving %/auto/simple calc length targets.
112
+ // Wrapper updates `.current` from parent/viewport/content onLayout; missing required fields fail loud.
113
+ readonly lengthLayoutContextBox?: { current: LengthLayoutContext }
114
+ }
115
+
116
+ export interface MotionViewController {
117
+ // Validate + resolve + seed the element and issue the mount command (one `start`, O(1)); throws
118
+ // InvalidTargetError on a bad target BEFORE any driver mutation (fail-closed, REQ-API-013).
119
+ mount(): ElementHandle
120
+ // Diff the RESOLVED next target against the current one; issue ONE `retarget` carrying only the
121
+ // changed keys, or NO command when nothing changed (unrelated re-render discipline). The return
122
+ // reports whether any driver command was emitted so lifecycle gates can settle a zero-delta exit
123
+ // synchronously instead of waiting for an onAllSettled edge that cannot occur.
124
+ update(next: {
125
+ readonly animate: Target
126
+ readonly transition?: Transition | undefined
127
+ /**
128
+ * Internal variant-lane execution edge. Motion restarts an element-wise equal keyframe
129
+ * target when its authored variant label changes, while ordinary equal-target updates
130
+ * remain no-ops. Scalars are deliberately unaffected.
131
+ */
132
+ readonly restartEqualKeyframes?: boolean | undefined
133
+ // AnimatePresence's one removal-boundary snapshot. Only null-first targets consume it; ordinary
134
+ // updates keep reading the driver's current value at their own command boundary.
135
+ readonly removalValues?: Readonly<Record<string, ResolvedValue>> | undefined
136
+ /**
137
+ * Deferred-length pending queue scope (review-1785219035198-7rbpy5):
138
+ * - `'partial'` (default): gesture/label slices — never treat key absence as abandonment
139
+ * - `'full'`: declarative animate snapshot (host prop) — keys absent from animate are abandoned
140
+ */
141
+ readonly animateScope?: 'partial' | 'full'
142
+ /**
143
+ * Internal — the deferred flush's own re-entry (review 356417a77aec): the flush resolves
144
+ * auto targets against the measure it just seeded, so the retarget invalidation must not
145
+ * drop that fresh measure. Only flushDeferredLengthMeasures sets this.
146
+ */
147
+ readonly skipAutoMeasureInvalidation?: boolean
148
+ /**
149
+ * T21 (REQ-API-053) — the orchestration-computed per-child start delay (seconds; negative
150
+ * = elapsed-time offset). Applied as the per-key DEFAULT under any authored delay (the
151
+ * pin's `{ delay, ...getValueTransition }` spread, visual-element-target.ts:87-90). Set
152
+ * only by the variant tree's label-application lane; finite or the update fails loud.
153
+ */
154
+ readonly orchestrationDelaySeconds?: number | undefined
155
+ }): boolean
156
+ /**
157
+ * First-mount element-box measures (auto, x/y %): keys registered at host base while
158
+ * onLayout was pending. Call after autoWidth/autoHeight land so deferred targets command
159
+ * without violating the fixed-at-mount key set (REQ-VALUETYPE-013).
160
+ */
161
+ /**
162
+ * Flush pending measure-resolved keys. Optional `held` excludes gesture-held keys so mid-hold
163
+ * onLayout does not retarget protected keys (review-1785230277710-lhz6ub).
164
+ */
165
+ flushDeferredLengthMeasures(held?: ReadonlySet<string>): boolean
166
+ /**
167
+ * Re-seed settled auto-resting AND relative-resting keys to their freshly measured values
168
+ * (reviews ae0a42832d85 + 7cec8ad963d8): an auto-rest key releases to the intrinsic layout at
169
+ * settle so content drift shows up in onLayout directly; an initial-only relative rest
170
+ * (%/calc/vw/vh) re-resolves its kept form against the changed element box / parent /
171
+ * viewport. Both re-seed INSTANT (never an animation — the element already tracks the
172
+ * measure). Keys mid-animation are skipped. Returns whether any re-seed commanded.
173
+ */
174
+ remeasureAutoRests(): boolean
175
+ /**
176
+ * Rebuild deferred-length pending from a declarative animate snapshot without issuing
177
+ * driver commands or re-running keyframe/element-transition compatibility (label full
178
+ * projection after per-label updates — review-1785226634574-2gxseb).
179
+ */
180
+ syncDeferredLengthPending(next: {
181
+ readonly animate: Target
182
+ readonly animateScope?: 'partial' | 'full'
183
+ }): void
184
+ // The projected rgba() string a color key currently renders (L1, specs/L1-BUILD-PACKET.md
185
+ // §semantics 3): the SAME projectColor seam the animated style binds, evaluated at the driver's
186
+ // live progress — the deterministic observable the checks drive.
187
+ currentColor(key: string): string
188
+ // The discrete deterministic observable (T23 B2b): the keyword displayed at the live progress,
189
+ // through the SAME projectDiscreteStep seam the animated style binds.
190
+ currentDiscrete(key: string): string
191
+ /**
192
+ * T23 B3 (pin visual-element-target.ts:159-168): JUMP-apply the latest target's transitionEnd
193
+ * values — instantly, never animated. Every key must be mounted (fixed-at-mount law); each
194
+ * family jumps on its own lane (numeric INSTANT retarget; discrete/color settled re-seed +
195
+ * INSTANT progress start on an EQUAL pair, which projects the jumped value at any progress) and
196
+ * moves its diff base so a later identical animate target is a no-op.
197
+ */
198
+ applyTransitionEnd(target: Readonly<Record<string, unknown>>): void
199
+ // T23 C: project the driver marshal's raw committed record (progress lanes) to the public
200
+ // latest-values record the onUpdate callback receives. Pure JS; shares the currentColor /
201
+ // currentDiscrete projection seams.
202
+ projectLatestValues(
203
+ raw: Readonly<Record<string, number>>,
204
+ ): Readonly<Record<string, string | number>>
205
+ // Immutable removal-boundary snapshot for Presence (REQ-PRESENCE-020). Reads every registered
206
+ // property's actual committed host value; colors are projected to the string currently displayed.
207
+ committedValues(keys: readonly string[]): Readonly<Record<string, ResolvedValue>>
208
+ /** Fresh pin-shaped maps for functional animation definitions. */
209
+ variantValueState(): {
210
+ readonly current: Readonly<Record<string, ResolvedValue>>
211
+ readonly velocity: Readonly<Record<string, number>>
212
+ }
213
+ // Deactivate before release (REQ-DRIVER-020 window-shrink).
214
+ unmount(): void
215
+ }
216
+
217
+ // The native host declaration, built from the registry so it can never drift from membership — the
218
+ // same construction the conformance scenarios use.
219
+ const NATIVE_HOST: HostCapabilities = hostCapabilities('native', ['universal', 'native-extension'])
220
+
221
+ // The single validation entry the wrapper AND the controller share, so fail-loud ordering can never
222
+ // fork: an invalid/unknown/off-host key throws core's InvalidTargetError (REQ-API-013/014) BEFORE any
223
+ // other wrapper concern (review cycle 2: a scope guard running first swallowed the pipeline error).
224
+ // The property lane's full transition law (r9 major 762e9d0a4c77): core executability PLUS
225
+ // this lane's executable option set — an option the lane cannot execute (velocity) fails loud on a
226
+ // direct consumer instead of riding along inert (G-INV-8). Transition maps resolve to the actual
227
+ // mounted property keys before this lane-specific option check; core still validates every map branch.
228
+ function validatePropertyLaneTransition(
229
+ transition: Transition | undefined,
230
+ componentId: MotionComponentId,
231
+ propertyKeys: readonly string[] = ['__root__'],
232
+ ): void {
233
+ validateTransition(transition, { componentId })
234
+ if (transition === undefined) return
235
+ if (
236
+ Reflect.ownKeys(transition).some(
237
+ (key) => typeof key === 'string' && TRANSITION_MAP_KEYS.has(key),
238
+ )
239
+ ) {
240
+ const selectedKeys = propertyKeys.length === 0 ? ['__root__'] : propertyKeys
241
+ for (const key of new Set(selectedKeys)) {
242
+ validatePropertyLaneTransition(resolveTransitionForKey(transition, key), componentId)
243
+ }
244
+ return
245
+ }
246
+ for (const option of Reflect.ownKeys(transition)) {
247
+ if (typeof option !== 'string' || TRANSITION_MAP_KEYS.has(option)) continue // core's closed-schema validator already rejects symbols
248
+ const value = (transition as Record<string, unknown>)[option]
249
+ if (value === undefined) continue // explicitly-undefined reads as absent (core semantics)
250
+ if (!NATIVE_PROPERTY_TRANSITION_OPTIONS.has(option)) {
251
+ throw new InvalidTransitionError(
252
+ componentId,
253
+ option,
254
+ value,
255
+ `not executable by the property lane (it drives: ` +
256
+ `${NATIVE_PROPERTY_TRANSITION_OPTIONS.options.join(', ')}) — a silently inert ` +
257
+ 'option is refused (G-INV-8, shippedSurface.ts)',
258
+ )
259
+ }
260
+ }
261
+ for (const mapKey of Reflect.ownKeys(transition)) {
262
+ if (typeof mapKey !== 'string' || !TRANSITION_MAP_KEYS.has(mapKey)) continue
263
+ const bag = (transition as Record<string, unknown>)[mapKey]
264
+ if (typeof bag !== 'object' || bag === null || Array.isArray(bag)) continue
265
+ for (const option of Reflect.ownKeys(bag)) {
266
+ if (typeof option !== 'string' || option === 'inherit' || TRANSITION_MAP_KEYS.has(option))
267
+ continue
268
+ const value = (bag as Record<string, unknown>)[option]
269
+ if (value === undefined || NATIVE_PROPERTY_TRANSITION_OPTIONS.has(option)) continue
270
+ throw new InvalidTransitionError(
271
+ componentId,
272
+ option,
273
+ value,
274
+ 'not executable by the property lane (it drives: ' +
275
+ NATIVE_PROPERTY_TRANSITION_OPTIONS.options.join(', ') +
276
+ ') — a silently inert option is refused (G-INV-8, shippedSurface.ts)',
277
+ )
278
+ }
279
+ }
280
+ }
281
+
282
+ export function validateAgainstNativeHost(
283
+ target: Readonly<Record<string, unknown>>,
284
+ componentId: MotionComponentId = '<Motion.View>',
285
+ ): void {
286
+ validateTarget(target, NATIVE_HOST, { componentId })
287
+ }
288
+
289
+ // Capture the per-call transition ONCE and validate the SNAPSHOT (review majors 51/61): a malformed
290
+ // non-object shape (null is never absence) is refused on the RAW, an object is CAPTURED then validated —
291
+ // so the values the driver commands run are EXACTLY the values validation saw. A malicious accessor
292
+ // (`duration` 0.2 to validation, -1 after) cannot show validation one value and the command another; the
293
+ // getter is read exactly once. `undefined` is absence (rides the default). Returns the frozen snapshot.
294
+ function captureAndValidateTransition(
295
+ raw: Transition | undefined,
296
+ componentId: MotionComponentId,
297
+ propertyKeys: readonly string[] = ['__root__'],
298
+ ): Transition | undefined {
299
+ if (raw === undefined) return undefined
300
+ // A CHEAP shape gate that reads NO option value (review majors 61/63): a primitive, null, array, or
301
+ // prototype-carrying object like `Date` retains the TYPED refusal — capturing it would launder an array
302
+ // / `Date` into an empty `{}`. Then capture the option values EXACTLY ONCE and validate the SNAPSHOT as
303
+ // the single truth: the getter is read once (the capture), the snapshot is what validation AND every
304
+ // command see, and the null-proto capture keeps an own `__proto__` as an own key so it reaches the
305
+ // snapshot validator. A second-read-throwing/stateful-but-legal object is materialized once, not reread.
306
+ const proto = typeof raw === 'object' && raw !== null ? Object.getPrototypeOf(raw) : undefined
307
+ if (
308
+ typeof raw !== 'object' ||
309
+ raw === null ||
310
+ Array.isArray(raw) ||
311
+ (proto !== Object.prototype && proto !== null)
312
+ ) {
313
+ throw new InvalidTransitionError(
314
+ componentId,
315
+ '(transition)',
316
+ raw,
317
+ 'a transition must be a plain object of transition options — a primitive, array, or ' +
318
+ 'prototype-carrying object is refused (REQ-API-013; a malformed config never coerces to default)',
319
+ )
320
+ }
321
+ const snapshot = captureTransition(raw)
322
+ validatePropertyLaneTransition(snapshot, componentId, propertyKeys)
323
+ return snapshot
324
+ }
325
+
326
+ // L1 (specs/L1-BUILD-PACKET.md): rgba-typed keys animate by scalar progress through the numeric
327
+ // driver seam (REQ-DRIVER-014 unchanged); everything else stays numeric fast-tier. The registry is
328
+ // the single classification source — the same table the web engine's disposition rides.
329
+ function isColorKey(key: string): boolean {
330
+ return nativeMountTargetValueKind(key) === 'color'
331
+ }
332
+
333
+ // T23 B2b: discrete-typed keys (display/visibility) ride the color lane's [0,100] progress
334
+ // convention with a projected KEYWORD (a step or a constant, per REQ-VALUETYPE-015's two paths)
335
+ // — same single classification source.
336
+ function isDiscreteKey(key: string): boolean {
337
+ return nativeMountTargetValueKind(key) === 'discrete'
338
+ }
339
+
340
+ // Positional length keys that Motion measure-resolves (DOMKeyframesResolver positionalValues).
341
+ // The ONE authority is shippedSurface's NATIVE_MEASURE_LENGTH_KEYS — the variants gate reads the
342
+ // same set, so it never admits a length string this lane cannot execute (review b3c3dd6b431a).
343
+ function isPositionalLengthKey(key: string): key is PositionalLengthAxis {
344
+ return isNativeMeasureLengthKey(key)
345
+ }
346
+
347
+ function isMeasureResolvedLengthString(value: unknown): value is string {
348
+ if (typeof value !== 'string') return false
349
+ // Total verdict predicate — never bare catch over parseLengthTarget (unexpected faults propagate
350
+ // from resolveLengthToPx / parseLengthTarget throw path elsewhere).
351
+ return isLengthTarget(value)
352
+ }
353
+
354
+ // Trim-insensitive auto detection over a scalar or ANY keyframe element (review 356417a77aec):
355
+ // " AUTO " parses as auto, so the retarget re-measure and the unconstrained-paint detector must
356
+ // recognize it too — and a keyframe array re-measures when any element is auto.
357
+ function containsAutoLengthTarget(value: unknown): boolean {
358
+ if (typeof value === 'string') return isAuto(value)
359
+ return (
360
+ Array.isArray(value) && value.some((element) => typeof element === 'string' && isAuto(element))
361
+ )
362
+ }
363
+
364
+ // Mount-refused keys drop out of a later update/sync target (review 39c6a3e7ee90): the mount's
365
+ // severity-channel report is the one notice; subsequent applications of the same refused
366
+ // property are refused through the same law rather than crashing the fixed-at-mount invariant.
367
+ function dropMountRefusedKeys(target: Target, mountRefusedKeys: ReadonlySet<string>): Target {
368
+ if (mountRefusedKeys.size === 0) return target
369
+ const kept: Record<string, unknown> = {}
370
+ for (const [key, value] of Object.entries(target as Record<string, unknown>)) {
371
+ if (!mountRefusedKeys.has(key)) kept[key] = value
372
+ }
373
+ return kept as Target
374
+ }
375
+
376
+ // The FINAL resting form of a target (review 1578a42974bc): only a final AUTO keyframe rests at
377
+ // auto — an auto MIDDLE keyframe ([0,'auto',100]) is a measurement episode whose resting form is
378
+ // the numeric final value, and a % origin-only settle is a % measure episode. Auto-rest tracking
379
+ // (release + drift re-seed) reads this, never the broader "contains an auto" measure test.
380
+ function restsAtAutoForm(value: unknown): boolean {
381
+ if (typeof value === 'string') return isAuto(value)
382
+ if (Array.isArray(value) && value.length > 0) {
383
+ const last = value[value.length - 1]
384
+ return typeof last === 'string' && isAuto(last)
385
+ }
386
+ return false
387
+ }
388
+
389
+ // A scalar RELATIVE measure form (review 7cec8ad963d8): %/calc/vw/vh — everything the measure
390
+ // lane resolves except auto (auto owns its own rest lane). Initial-only relative settles keep
391
+ // these forms so a later element/parent/viewport change re-measures them.
392
+ function isRelativeLengthForm(value: unknown): value is string {
393
+ return typeof value === 'string' && !isAuto(value) && isLengthTarget(value)
394
+ }
395
+
396
+ // The FINAL element of a target rests at a RELATIVE measure form (review 26cc511b0927): a
397
+ // scalar relative string, or a keyframe array whose last keyframe is one.
398
+ function restsAtRelativeForm(value: unknown): boolean {
399
+ if (typeof value === 'string') return isRelativeLengthForm(value)
400
+ if (Array.isArray(value) && value.length > 0) {
401
+ const last = value[value.length - 1]
402
+ return typeof last === 'string' && isRelativeLengthForm(last)
403
+ }
404
+ return false
405
+ }
406
+
407
+ // Element-wise identity of two keyframe arrays (the AUTHORED shape, not the resolved values) —
408
+ // a context-only re-resolution presents the same raw array (review 26cc511b0927).
409
+ function sameRawKeyframes(a: unknown, b: unknown): boolean {
410
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
411
+ for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false
412
+ return true
413
+ }
414
+
415
+ // L1 scope + R13 value-breadth (REQ-VALUETYPE-013): numeric OR measure-resolved length string.
416
+ // Measure-required forms resolve through layout context; bare unitless/px strings parse to numbers.
417
+ function numericValue(
418
+ key: string,
419
+ value: unknown,
420
+ layoutContext: LengthLayoutContext = {},
421
+ ): number {
422
+ if (typeof value === 'number') {
423
+ if (!Number.isFinite(value)) {
424
+ throw new Error(`L1 scope: target '${key}' is non-finite ${value}`)
425
+ }
426
+ return value
427
+ }
428
+ if (
429
+ typeof value === 'string' &&
430
+ isPositionalLengthKey(key) &&
431
+ isMeasureResolvedLengthString(value)
432
+ ) {
433
+ return resolveLengthToPx(value, key, layoutContext)
434
+ }
435
+ throw new Error(
436
+ `L1 scope: target '${key}' is ${JSON.stringify(value)}; only numeric values or measure-resolved ` +
437
+ 'length strings (%/auto/simple calc/px) are driven for non-color keys (REQ-VALUETYPE-013).',
438
+ )
439
+ }
440
+
441
+ /**
442
+ * Cross-bundle safe: class identity can diverge between core source/dist and the consumer.
443
+ * Match on name + reason field rather than `instanceof`.
444
+ */
445
+ function measureResolveContextReason(error: unknown): string | null {
446
+ if (!(error instanceof Error)) return null
447
+ if (error.name !== 'MeasureResolveContextError') return null
448
+ const reason = (error as { reason?: unknown }).reason
449
+ return typeof reason === 'string' ? reason : null
450
+ }
451
+
452
+ /**
453
+ * Typed / shape refusals that own the severity boundary. Unexpected dependency faults
454
+ * (non-Error throws, non-boundary Errors) always propagate — never message-regex classification.
455
+ */
456
+ function isMeasureResolveBoundaryError(error: unknown): error is Error {
457
+ if (measureResolveContextReason(error) !== null) return true
458
+ if (!(error instanceof Error)) return false
459
+ // L1 shape refusals are constructed only in this module with a fixed prefix.
460
+ return (
461
+ error.message.startsWith('L1 scope: target ') || error.message.startsWith('L1 scope: keyframe ')
462
+ )
463
+ }
464
+
465
+ /** Element-box measures (auto, x/y %) arrive from onLayout after first mount — defer, never drop key. */
466
+ function isDeferrableElementMeasureError(error: unknown): error is MeasureResolveContextError {
467
+ return measureResolveContextReason(error) === 'missing-element-measure'
468
+ }
469
+
470
+ /**
471
+ * The tri-state origin resolution (review 763d6bcb0194): a production-refused NONDEFERRABLE
472
+ * origin and an element-measure deferral must never share one null — the refused property is
473
+ * dropped (one report, fired exactly once HERE), a deferred origin waits for the element box.
474
+ */
475
+ type OriginResolution =
476
+ | { readonly kind: 'resolved'; readonly value: number }
477
+ | { readonly kind: 'deferred' }
478
+ | { readonly kind: 'refused' }
479
+
480
+ function resolveNumericKeyVerdict(
481
+ key: string,
482
+ value: unknown,
483
+ layoutContext: LengthLayoutContext,
484
+ severity: 'development' | 'production',
485
+ report: (error: Error) => void,
486
+ ): OriginResolution {
487
+ try {
488
+ return { kind: 'resolved', value: numericValue(key, value, layoutContext) }
489
+ } catch (error) {
490
+ if (isDeferrableElementMeasureError(error)) return { kind: 'deferred' }
491
+ if (!isMeasureResolveBoundaryError(error)) throw error
492
+ if (severity === 'development') throw error
493
+ report(error)
494
+ return { kind: 'refused' }
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Resolve one numeric target under severity (major a41d97e260cb): missing layout context for
500
+ * parent/%/viewport reports+drops in production; development throws. Element-measure misses
501
+ * return null with `defer` so the host can register the key and flush after onLayout.
502
+ */
503
+ function resolveNumericKeyWithSeverity(
504
+ key: string,
505
+ value: unknown,
506
+ layoutContext: LengthLayoutContext,
507
+ severity: 'development' | 'production',
508
+ report: (error: Error) => void,
509
+ ): number | null {
510
+ try {
511
+ return numericValue(key, value, layoutContext)
512
+ } catch (error) {
513
+ if (isDeferrableElementMeasureError(error)) {
514
+ // First-mount path: host registers the key and flushes after onLayout supplies auto sizes.
515
+ return null
516
+ }
517
+ if (!isMeasureResolveBoundaryError(error)) throw error
518
+ if (severity === 'development') throw error
519
+ report(error)
520
+ return null
521
+ }
522
+ }
523
+
524
+ // L1 typed entry gate (packet §semantics 6): a color endpoint must be a string core's parseColor
525
+ // accepts. Core validation already rejects wrong-primitive values (InvalidTargetError names the
526
+ // key — observed in the RED run); this gate wraps the PARSE failure with the key context.
527
+ function colorValue(key: string, value: unknown): RGBA {
528
+ if (typeof value !== 'string') {
529
+ throw new Error(
530
+ `L1: color target '${key}' is ${JSON.stringify(value)}; an rgba-typed key takes a color ` +
531
+ 'string (specs/L1-BUILD-PACKET.md §semantics 6).',
532
+ )
533
+ }
534
+ // No parse catch (M3 r8 major b9eb82c0d429): every colorValue call site runs POST-gate,
535
+ // where core validation already proved the string parses — a throw here is a dependency
536
+ // fault or a core/L1 skew bug and must propagate loud, never be re-wrapped.
537
+ return parseColor(value)
538
+ }
539
+
540
+ // The driver's internal progress convention for non-numeric values (REQ-SPRING-013): a color prop
541
+ // registers at PROGRESS_START and animates to PROGRESS_END; projection divides by PROGRESS_END.
542
+ const PROGRESS_START = 0
543
+ const PROGRESS_END = 100
544
+
545
+ // A RAW color TARGET value (R8 M2, review major 35): a scalar color string → its RGBA, OR a color
546
+ // keyframe ARRAY → its parsed RGBA sequence with a null-FIRST element kept as `null` (from-current,
547
+ // R8-F2 — substituted by the caller's live color in buildColorSequence). Core validation already
548
+ // proved a same-value-type array whose only legal null is index 0.
549
+ type ColorTarget = RGBA | readonly (RGBA | null)[]
550
+ function colorTargetValue(key: string, value: unknown): ColorTarget {
551
+ if (Array.isArray(value)) {
552
+ return value.map((element, index) => {
553
+ if (element === null) return null // legal ONLY at index 0 (validated upstream); seeded from live
554
+ if (typeof element !== 'string') {
555
+ throw new Error(
556
+ `L1: color keyframe '${key}[${index}]' is ${JSON.stringify(element)}; a color keyframe ` +
557
+ 'is a color string (REQ-API-033 / specs/L1-BUILD-PACKET.md §semantics 6).',
558
+ )
559
+ }
560
+ return parseColor(element)
561
+ })
562
+ }
563
+ return colorValue(key, value)
564
+ }
565
+
566
+ // The color TARGETS of a target (raw scalar RGBA or a keyframe RGBA sequence), each through the gate.
567
+ function resolveColors(target: Target): Record<string, ColorTarget> {
568
+ const resolved: Record<string, ColorTarget> = {}
569
+ for (const key of Object.keys(target)) {
570
+ if (!isColorKey(key)) continue
571
+ resolved[key] = colorTargetValue(key, (target as Record<string, unknown>)[key])
572
+ }
573
+ return resolved
574
+ }
575
+
576
+ function isColorArrayTarget(target: ColorTarget): target is readonly (RGBA | null)[] {
577
+ return Array.isArray(target)
578
+ }
579
+
580
+ // The typed entry gate for a discrete target (T23 B2b): a SINGLE keyword string of the discrete
581
+ // FAMILY. isDiscreteKeyword pre-guards the parse (a letters-only string can never throw in
582
+ // parseValue), and the family verdict catches keywords other families own ('auto', named colors).
583
+ // Keyframe ARRAYS are not chartered for discrete keys — refuse loud, never a silent scalar pick.
584
+ function discreteValue(key: string, value: unknown): string {
585
+ if (Array.isArray(value)) {
586
+ throw new Error(
587
+ `T23: discrete keyframe arrays are not implemented — '${key}' takes a single keyword ` +
588
+ 'target (scalar display/visibility only).',
589
+ )
590
+ }
591
+ if (
592
+ typeof value !== 'string' ||
593
+ !isDiscreteKeyword(value) ||
594
+ parseValue(value).kind !== 'discrete'
595
+ ) {
596
+ throw new Error(
597
+ `T23: discrete target '${key}' is ${JSON.stringify(value)}; a display/visibility target ` +
598
+ "is a single CSS keyword string (e.g. 'flex'/'none'/'visible'/'hidden').",
599
+ )
600
+ }
601
+ return value
602
+ }
603
+
604
+ // The discrete TARGETS of a target, each through the gate (the resolveColors mirror).
605
+ function resolveDiscretes(target: Target): Record<string, string> {
606
+ const resolved: Record<string, string> = {}
607
+ for (const key of Object.keys(target)) {
608
+ if (!isDiscreteKey(key)) continue
609
+ resolved[key] = discreteValue(key, (target as Record<string, unknown>)[key])
610
+ }
611
+ return resolved
612
+ }
613
+
614
+ // Build the live RGBA SEQUENCE the driver's progress maps through (major 35): a SCALAR color target is
615
+ // the from-current 2-color `[fromColor, target]` (the sealed scalar-color lane); a color ARRAY is its
616
+ // parsed sequence with a null-FIRST element substituted by `fromColor` (R8-F2 from-current).
617
+ function buildColorSequence(target: ColorTarget, fromColor: RGBA): readonly RGBA[] {
618
+ if (!isColorArrayTarget(target)) return [fromColor, target]
619
+ return target.map((element) => element ?? fromColor)
620
+ }
621
+
622
+ // The evenly-spaced numeric PROGRESS keyframe array for an N-color sequence (major 35): [0, …, 100]
623
+ // with N points, so the shipped keyframe generator owns offsets/per-segment easing/duration while the
624
+ // projector maps progress → the color segment. N ≥ 2 (a color target is ≥2 keyframes).
625
+ function colorProgressArray(n: number): readonly number[] {
626
+ return Array.from({ length: n }, (_, i) => (i / (n - 1)) * PROGRESS_END)
627
+ }
628
+
629
+ // The SETTLED color of a color target — where the key rests once the animation completes (the LAST
630
+ // keyframe; a scalar rests at itself). Used to register a constant color (initial={false} / initial-
631
+ // only). The last element is non-null (validation guarantees a null only at index 0).
632
+ function settledColor(key: string, target: ColorTarget): RGBA {
633
+ if (!isColorArrayTarget(target)) return target
634
+ const last = target[target.length - 1]
635
+ if (last === null || last === undefined) {
636
+ throw new Error(`color keyframe array '${key}' has no final color (REQ-API-033).`)
637
+ }
638
+ return last
639
+ }
640
+
641
+ // The canonical string of a WHOLE color target — the diff base for the M2.2 no-op invariant (review
642
+ // major 37). A scalar is its rgba() projection; an ARRAY is EVERY keyframe joined (a null-first element,
643
+ // from-current R8-F2, is its own marker), so two sequences that share endpoints but differ in a MIDDLE
644
+ // keyframe (red→green→blue vs red→yellow→blue) canonicalize DIFFERENTLY and correctly re-command — the
645
+ // old settled-color-only diff wrongly treated them as equal and continued the stale trajectory.
646
+ const FROM_CURRENT_MARKER = '~from-current~'
647
+ function colorTargetCanonical(target: ColorTarget): string {
648
+ if (!isColorArrayTarget(target)) return transformColor(target)
649
+ return target
650
+ .map((element) => (element === null ? FROM_CURRENT_MARKER : transformColor(element)))
651
+ .join('|')
652
+ }
653
+
654
+ // A validated ANIMATE target value: a scalar number OR a numeric keyframe ARRAY (REQ-API-033, R8 M2).
655
+ // Core validation already proved a well-formed same-value-type keyframe array (length ≥2, null only at
656
+ // index 0); this gate rides it into the numeric lane and fails loud on any other shape (a unit string
657
+ // stays the flagged L1 stretch). Distinct from `numericValue` (start/from values, scalar-only).
658
+ function numericTargetValue(
659
+ key: string,
660
+ value: unknown,
661
+ layoutContext: LengthLayoutContext = {},
662
+ ): number | PropTargetTo {
663
+ if (typeof value === 'number') return value
664
+ // Materialize the array ONCE into a frozen snapshot (review major 30): the driver + `current` diff
665
+ // base then hold the single truth, never aliasing caller memory a later mutation/accessor would skew.
666
+ if (Array.isArray(value)) {
667
+ const elements = value.map((element, index) => {
668
+ if (element === null && index === 0) return null
669
+ if (typeof element === 'number') return element
670
+ if (
671
+ typeof element === 'string' &&
672
+ isPositionalLengthKey(key) &&
673
+ isMeasureResolvedLengthString(element)
674
+ ) {
675
+ return resolveLengthToPx(element, key, layoutContext)
676
+ }
677
+ throw new Error(
678
+ `L1 scope: keyframe '${key}[${index}]' is ${JSON.stringify(element)}; only numeric or ` +
679
+ 'measure-resolved length values are driven (REQ-VALUETYPE-013).',
680
+ )
681
+ })
682
+ return Object.freeze(elements) as PropTargetTo
683
+ }
684
+ if (
685
+ typeof value === 'string' &&
686
+ isPositionalLengthKey(key) &&
687
+ isMeasureResolvedLengthString(value)
688
+ ) {
689
+ return resolveLengthToPx(value, key, layoutContext)
690
+ }
691
+ throw new Error(
692
+ `L1 scope: target '${key}' is ${JSON.stringify(value)}; only a numeric value, measure-resolved ` +
693
+ 'length string, or numeric keyframe array is driven for non-color keys (REQ-VALUETYPE-013).',
694
+ )
695
+ }
696
+
697
+ // Resolve a validated ANIMATE target for the numeric lane: scalars pass through, a numeric keyframe
698
+ // array rides as its PropTargetTo; color keys are EXCLUDED (they ride the progress convention).
699
+ // Element-measure misses (auto / x|y %) go to `deferred` so mount can register the key and flush
700
+ // after onLayout — never drop from the fixed-at-mount key set.
701
+ function resolveAnimateTargets(
702
+ target: Target,
703
+ layoutContext: LengthLayoutContext = {},
704
+ severity: 'development' | 'production' = 'development',
705
+ report: (error: Error) => void = (): void => {},
706
+ ): {
707
+ readonly resolved: Record<string, number | PropTargetTo>
708
+ readonly deferred: Record<string, unknown>
709
+ } {
710
+ const resolved: Record<string, number | PropTargetTo> = {}
711
+ const deferred: Record<string, unknown> = {}
712
+ for (const key of Object.keys(target)) {
713
+ if (isColorKey(key) || isDiscreteKey(key)) continue
714
+ const raw = (target as Record<string, unknown>)[key]
715
+ try {
716
+ resolved[key] = numericTargetValue(key, raw, layoutContext)
717
+ } catch (error) {
718
+ if (isDeferrableElementMeasureError(error)) {
719
+ deferred[key] = raw
720
+ continue
721
+ }
722
+ if (!isMeasureResolveBoundaryError(error)) throw error
723
+ if (severity === 'development') throw error
724
+ report(error)
725
+ }
726
+ }
727
+ return { resolved, deferred }
728
+ }
729
+
730
+ /** Host-base numeric seed when a measure-required animate target is deferred to first onLayout. */
731
+ function deferredRegistrationSeed(
732
+ key: string,
733
+ style: MotionViewProps['style'],
734
+ layoutContext: LengthLayoutContext,
735
+ severity: 'development' | 'production',
736
+ report: (error: Error) => void,
737
+ ): number {
738
+ const start = resolveStartValue(key, style !== undefined ? { style } : {})
739
+ if (typeof start === 'number' && Number.isFinite(start)) return start
740
+ if (
741
+ typeof start === 'string' &&
742
+ isPositionalLengthKey(key) &&
743
+ isMeasureResolvedLengthString(start)
744
+ ) {
745
+ try {
746
+ return resolveLengthToPx(start, key, layoutContext)
747
+ } catch (error) {
748
+ if (isDeferrableElementMeasureError(error)) return 0
749
+ if (!isMeasureResolveBoundaryError(error)) throw error
750
+ if (severity === 'development') throw error
751
+ report(error)
752
+ return 0
753
+ }
754
+ }
755
+ return 0
756
+ }
757
+
758
+ type PendingDeferredLengthTarget = Readonly<{
759
+ /** Raw animate endpoint; resolved only after the element box exists. Absent on an
760
+ * initial-only pending origin (review 73044cc0e1bd) until an update pairs one. */
761
+ to?: unknown
762
+ /** Raw first-mount origin when it too requires the element box. */
763
+ from?: unknown
764
+ /** The deferring application's OWN transition (review 93aeec05a9a8): R7 law (b) submits
765
+ * label applications separately — ownership stays per entry, so a later application can
766
+ * never overwrite an earlier one's transition. An explicit null means the deferring
767
+ * application authored NO transition and owns the DEFAULT (review 7d395c90c954) —
768
+ * absence of a transition is an ownership state, never "retain the prior application's".
769
+ * Absent → the mount fallback. */
770
+ transition?: Transition | null
771
+ }>
772
+
773
+ type PendingDeferredLengthTargets = Record<string, PendingDeferredLengthTarget>
774
+
775
+ function deferredTargetsFrom(target: Record<string, unknown>): PendingDeferredLengthTargets {
776
+ const pending: PendingDeferredLengthTargets = {}
777
+ for (const [key, to] of Object.entries(target)) pending[key] = { to }
778
+ return pending
779
+ }
780
+
781
+ // A transition object with at least one DEFINED option (review major 38): Motion treats an empty `{}`
782
+ // (and one whose every value is explicitly `undefined`) as NO transition — defaults apply — so it must
783
+ // not suppress the count-aware per-prop default. An explicitly-undefined option reads as absent (the R6
784
+ // option law), so it does not count as "defined".
785
+ function hasDefinedTransition(transition: Transition | undefined): boolean {
786
+ if (transition === undefined) return false
787
+ return Reflect.ownKeys(transition).some(
788
+ (key) => typeof key === 'string' && (transition as Record<string, unknown>)[key] !== undefined,
789
+ )
790
+ }
791
+
792
+ // transition-default-selection F6: the pin's `isTransitionDefined` exempts EXACTLY these
793
+ // orchestration keys (motion-dom@12.42.2 utils/is-transition-defined.ts:4-29) — a flat bag whose
794
+ // every defined field is one of them is NOT a defined transition, so `animateMotionValue` merges
795
+ // the per-key default UNDER it (motion-value.ts:68-70 → utils/default-transitions.ts:35-48). The
796
+ // flat arm of perPropTransition is that pin seam and takes the pin's full list;
797
+ // TRANSITION_ORCHESTRATION_KEYS below stays the map branch's own (narrower) executable check.
798
+ const TRANSITION_DEFINED_EXEMPT_KEYS = new Set([
799
+ 'when',
800
+ 'delay',
801
+ 'delayChildren',
802
+ 'staggerChildren',
803
+ 'staggerDirection',
804
+ 'repeat',
805
+ 'repeatType',
806
+ 'repeatDelay',
807
+ 'from',
808
+ 'elapsed',
809
+ ])
810
+
811
+ /**
812
+ * Seed the T21 orchestration-computed delay as the per-key DEFAULT (REQ-API-053). Pin law
813
+ * (visual-element-target.ts:87-90): every value transition is `{ delay, ...getValueTransition }`
814
+ * — the computed delay applies wherever no delay is authored, and an authored delay at any
815
+ * altitude replaces it. Flat bags seed at the root; map bags seed each REPLACING property/
816
+ * `default` entry plus the root, so core resolveTransitionForKey's key → default → root
817
+ * precedence carries the default to unmapped keys; `inherit: true` entries are left alone
818
+ * (the merged root supplies the default at the correct precedence). The `layout` entry is
819
+ * NEVER seeded — orchestration is a property-lane concern and the layout builder owns its
820
+ * own delay consumption.
821
+ */
822
+ // alloc-ok: lifecycle-edge — runs once per orchestrated update, never per frame.
823
+ function seedOrchestrationDelay(
824
+ transition: Transition | undefined,
825
+ delaySeconds: number,
826
+ ): Transition {
827
+ if (!Number.isFinite(delaySeconds)) {
828
+ throw new Error(
829
+ `motionViewController: orchestrationDelaySeconds must be finite, got ${String(delaySeconds)} ` +
830
+ '— the upstream per-child resolver is broken (internal invariant, REQ-API-053)',
831
+ )
832
+ }
833
+ // The seed is written LAST so an own explicit `delay: undefined` (ABSENT under the R6 option
834
+ // law) cannot re-erase it via spread order (review r1 major 3).
835
+ const seedFlat = (bag: Transition): Transition =>
836
+ (bag as { delay?: number }).delay !== undefined
837
+ ? bag
838
+ : (Object.freeze({ ...bag, delay: delaySeconds }) as Transition)
839
+ if (transition === undefined) return Object.freeze({ delay: delaySeconds }) as Transition
840
+ if (!hasTransitionMap(transition)) return seedFlat(transition)
841
+ const seeded: Record<string, unknown> = {}
842
+ for (const key of Reflect.ownKeys(transition)) {
843
+ if (typeof key !== 'string') continue
844
+ const value = (transition as Record<string, unknown>)[key]
845
+ // An `inherit: true` entry is NEVER pre-seeded (review r1 major 3): resolveTransitionForKey
846
+ // merges root-under-child for it, so the seeded ROOT already supplies the computed default
847
+ // at the lowest precedence — pre-seeding the child would sit the computed delay ABOVE the
848
+ // root's AUTHORED delay in that merge. Replacing entries (no inherit) drop the root, so
849
+ // they take the seed directly, reproducing the pin's `{ delay, ...getValueTransition }`.
850
+ seeded[key] =
851
+ key !== 'layout' &&
852
+ TRANSITION_MAP_KEYS.has(key) &&
853
+ typeof value === 'object' &&
854
+ value !== null &&
855
+ (value as { inherit?: boolean }).inherit !== true
856
+ ? seedFlat(value as Transition)
857
+ : value
858
+ }
859
+ return seedFlat(seeded as Transition)
860
+ }
861
+
862
+ const TRANSITION_ORCHESTRATION_KEYS = new Set(['delay', 'repeat', 'repeatType', 'repeatDelay'])
863
+
864
+ function hasExecutableTransition(transition: Transition | undefined): boolean {
865
+ if (transition === undefined) return false
866
+ for (const key of Reflect.ownKeys(transition)) {
867
+ if (
868
+ typeof key === 'string' &&
869
+ (transition as Record<string, unknown>)[key] !== undefined &&
870
+ !TRANSITION_ORCHESTRATION_KEYS.has(key)
871
+ )
872
+ return true
873
+ }
874
+ return false
875
+ }
876
+
877
+ function definedTransitionFields(transition: Transition): Transition {
878
+ const defined: Record<string, unknown> = {}
879
+ for (const key of Reflect.ownKeys(transition)) {
880
+ if (typeof key !== 'string') continue
881
+ const value = (transition as Record<string, unknown>)[key]
882
+ if (value !== undefined) defined[key] = value
883
+ }
884
+ return defined as Transition
885
+ }
886
+
887
+ function transitionForProperty(
888
+ transition: Transition | undefined,
889
+ key: string,
890
+ ): Transition | undefined {
891
+ if (transition === undefined) return undefined
892
+ if (
893
+ !Reflect.ownKeys(transition).some(
894
+ (mapKey) => typeof mapKey === 'string' && TRANSITION_MAP_KEYS.has(mapKey),
895
+ )
896
+ )
897
+ return transition
898
+ return resolveTransitionForKey(transition, key)
899
+ }
900
+
901
+ function effectiveTransitionForProperty(
902
+ key: string,
903
+ to: number | PropTargetTo,
904
+ transition: Transition | undefined,
905
+ ): PropTransition | undefined {
906
+ const selected = transitionForProperty(transition, key)
907
+ if (selected === undefined || hasExecutableTransition(selected)) return selected
908
+ // Motion's `isTransitionDefined` ignores orchestration-only fields, then merges the property's
909
+ // count-aware default into the remaining delay/repeat options. This is the native-side equivalent
910
+ // for a map branch such as `x: { delay: 0.2 }` or `x: {}`; forwarding that sparse bag as a complete
911
+ // transition would make the driver choose its generic spring for every property.
912
+ const defaults = getDefaultTransition(key, Array.isArray(to) ? to : [null, to])
913
+ return { ...defaults, ...definedTransitionFields(selected) } as PropTransition
914
+ }
915
+
916
+ function hasTransitionMap(transition: Transition): boolean {
917
+ return Reflect.ownKeys(transition).some(
918
+ (mapKey) => typeof mapKey === 'string' && TRANSITION_MAP_KEYS.has(mapKey),
919
+ )
920
+ }
921
+
922
+ // The per-prop transition for an animate value (R8 M2, consult agent-2026-07-19-e715fd): ONLY a
923
+ // keyframe ARRAY with NO defined command-level transition carries a per-prop default — the count-aware
924
+ // getDefaultTransition (a 2-kf transform → spring, >2 → 800ms keyframes). A scalar, or an array under a
925
+ // DEFINED command-level transition, uses the command-level transition (returns undefined). An empty
926
+ // `{}` is NOT a defined transition (major 38). The drivers read `target.transition ?? command.transition`.
927
+ // REQ-API-035: reducedMotion skips transform (positional) keys; skipAnimations/isStatic skip all keys.
928
+ function perPropTransition(
929
+ key: string,
930
+ to: number | PropTargetTo,
931
+ commandTransition: Transition | undefined,
932
+ motionPolicy?: { readonly shouldReduceMotion?: boolean; readonly skipAnimations?: boolean },
933
+ ): PropTransition | undefined {
934
+ if (motionPolicy?.skipAnimations === true) return INSTANT_TRANSITION
935
+ if (motionPolicy?.shouldReduceMotion === true && TRANSFORM_KEY_SET.has(key)) {
936
+ return INSTANT_TRANSITION
937
+ }
938
+ if (commandTransition !== undefined && hasTransitionMap(commandTransition)) {
939
+ return effectiveTransitionForProperty(key, to, commandTransition)
940
+ }
941
+ if (typeof to === 'number') {
942
+ // transition-default-selection F6 (REQ-SPRING-012, flat arm): the pin's `isTransitionDefined`
943
+ // exempts the orchestration keys (TRANSITION_DEFINED_EXEMPT_KEYS), so a flat bag whose every
944
+ // defined field is orchestration-only is NOT a defined transition — the per-key count-aware
945
+ // default merges UNDER the bag (the pin's `{ ...getDefaultTransition(...), ...valueTransition }`,
946
+ // motion-dom@12.42.2 motion-value.ts:68-70), the same shape effectiveTransitionForProperty
947
+ // already produces for a sparse MAP branch. An absent or fieldless (`{}`) bag keeps the
948
+ // command-level ride unchanged (the R8 M2 scalar law); a bag with ANY defined non-exempt field
949
+ // is defined and rides the command transition to the driver (an ease-only bag resolves there —
950
+ // the drivers' timing lane routes a defined typeless bag carrying an ease spelling).
951
+ if (commandTransition === undefined) return undefined
952
+ const defined = definedTransitionFields(commandTransition)
953
+ const definedKeys = Reflect.ownKeys(defined)
954
+ if (definedKeys.length === 0) return undefined
955
+ if (
956
+ definedKeys.some(
957
+ (definedKey) =>
958
+ typeof definedKey === 'string' && !TRANSITION_DEFINED_EXEMPT_KEYS.has(definedKey),
959
+ )
960
+ )
961
+ return undefined
962
+ return { ...getDefaultTransition(key, [null, to]), ...defined } as PropTransition
963
+ }
964
+ if (hasDefinedTransition(commandTransition)) return undefined
965
+ return getDefaultTransition(key, to)
966
+ }
967
+
968
+ // Filter the R8-F1-incompatible keyframe arrays from an animate target BEFORE any resolve/register/seed/
969
+ // command (review majors 36 + 42): a keyframe array (numeric OR color — a color array's progress
970
+ // keyframes carry the same count) whose command-level transition is an EXPLICIT spring interpolates
971
+ // exactly two keyframes; a longer array is refused HERE. Running up-front makes the whole mount/update
972
+ // ATOMIC (major 36: a rejection mutates nothing) AND routes INDIRECT consumers through the severity
973
+ // boundary (major 42: a gesture/label array riding the element-transition fallback reaches the
974
+ // controller past its supplying gate) — production reports + drops the property (siblings survive),
975
+ // development throws (the direct-consumer loud law). The per-prop default is never a spring for a
976
+ // refusable count, so the command transition is the sole trigger.
977
+ function filterStartCompat(
978
+ animate: Target,
979
+ commandTransition: Transition | undefined,
980
+ severity: NativeSeverity,
981
+ report: (error: Error) => void,
982
+ componentId: MotionComponentId,
983
+ ): Target {
984
+ if (!hasDefinedTransition(commandTransition)) return animate
985
+ const kept: Record<string, unknown> = {}
986
+ for (const [key, value] of Object.entries(animate)) {
987
+ const effectiveTransition = transitionForProperty(commandTransition, key)
988
+ let refusal: Error | null = null
989
+ if (effectiveTransition?.type === 'spring' && Array.isArray(value)) {
990
+ const springRefusal = springKeyframeCountRefusal(value.length)
991
+ if (springRefusal !== null) refusal = springRefusal
992
+ }
993
+ if (refusal === null)
994
+ refusal = keyframeTimingRefusal(componentId, key, value, effectiveTransition)
995
+ if (refusal !== null) {
996
+ if (severity === 'development') throw refusal
997
+ report(refusal)
998
+ continue // production: refuse the property, valid siblings survive
999
+ }
1000
+ kept[key] = value
1001
+ }
1002
+ return kept as Target
1003
+ }
1004
+
1005
+ // Value equality for the update diff (M2.2 no-op invariant): scalars by ===, keyframe arrays by
1006
+ // element-wise equality so a new-but-EQUAL array on an unrelated re-render issues NO command.
1007
+ function sameTargetValue(a: number | PropTargetTo, b: number | PropTargetTo): boolean {
1008
+ if (typeof a === 'number' || typeof b === 'number') return a === b
1009
+ if (a.length !== b.length) return false
1010
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
1011
+ return true
1012
+ }
1013
+
1014
+ // The SETTLED value of an animate target — where the element rests once the animation completes. A
1015
+ // scalar rests at itself; a keyframe array rests at its LAST keyframe (validation guarantees a non-null
1016
+ // numeric final element). Used for the `initial={false}` sentinel (mount AT the settled state).
1017
+ function settledTargetValue(key: string, value: number | PropTargetTo): number {
1018
+ if (typeof value === 'number') return value
1019
+ const last = value[value.length - 1]
1020
+ if (typeof last !== 'number') {
1021
+ throw new Error(`keyframe array target '${key}' has no numeric final value (REQ-API-033).`)
1022
+ }
1023
+ return last
1024
+ }
1025
+
1026
+ export type NativeSeverity = 'development' | 'production'
1027
+
1028
+ // The omitted-animate mount target: one frozen instance so every static MotionView shares it and
1029
+ // the controller's resolved-value diffing sees a stable identity.
1030
+ export const EMPTY_TARGET: Target = Object.freeze({})
1031
+
1032
+ function animatePropRefusal(error: unknown, componentId: MotionComponentId): Error {
1033
+ // Caught values are untrusted, including Error instances with hostile prototype or message
1034
+ // accessors. Re-wrapping through the total formatter keeps the severity boundary owned.
1035
+ return new InvalidTargetError(
1036
+ componentId,
1037
+ '(target)',
1038
+ describeValue(error),
1039
+ 'malformed animate target (REQ-API-013)',
1040
+ )
1041
+ }
1042
+
1043
+ // The component's animate-prop boundary (round 9 major 31 + U7h): omitted (undefined) animate
1044
+ // AND a boolean animate are the empty static target. Boolean is the pin's parse-skip
1045
+ // (`typeof prop === "boolean"`) — accepted and inert, not a disable switch. `??` silently
1046
+ // converted an explicit `animate={null}` into a static mount that the web entry rejects as a
1047
+ // typed failure; null stays loud. Runs in the RENDER body on every render, so a malformed
1048
+ // shape arriving on an UPDATE fails here too, before it can reach Object.keys inside the
1049
+ // update planner (raw TypeError) or any driver mutation. Development throws; production
1050
+ // reports the typed error and refuses the whole target (static view).
1051
+ export function resolveAnimateProp(
1052
+ animate: Target | boolean | undefined,
1053
+ severity: NativeSeverity,
1054
+ report: (error: Error) => void,
1055
+ componentId: MotionComponentId,
1056
+ ): Target {
1057
+ // U7h (REQ-API-057): pin animation-state.ts skips `typeof prop === "boolean"` — accepted
1058
+ // and inert. Not the initial={false} sentinel and not a disable switch. null stays loud.
1059
+ if (animate === undefined || typeof animate === 'boolean') return EMPTY_TARGET
1060
+ try {
1061
+ assertTargetShape(animate, { componentId })
1062
+ } catch (error) {
1063
+ const refusal = animatePropRefusal(error, componentId)
1064
+ if (severity === 'development') throw refusal
1065
+ report(refusal)
1066
+ return EMPTY_TARGET
1067
+ }
1068
+ return animate
1069
+ }
1070
+
1071
+ // Per-key severity validation — the web shim's validateWithSeverity mirrored natively (review
1072
+ // round 6; the ratified severity law is ONE policy across engines): development throws through
1073
+ // core's validator untouched; production validates PER KEY, reports each offender through the
1074
+ // error channel, and refuses it — the remaining keys proceed.
1075
+ // One key's FULL boundary check: core validation plus the L1 value-shape gate (review round 7
1076
+ // major a6139e22 — `x: '10px'` passes core validation but is a rung-level value failure; the
1077
+ // boundary must catch it BEFORE any driver/controller mutation, on both severity lanes).
1078
+ function keyBoundaryRefusal(
1079
+ key: string,
1080
+ value: unknown,
1081
+ allowArrays: boolean,
1082
+ commandTransition: Transition | undefined,
1083
+ componentId: MotionComponentId,
1084
+ ): Error | null {
1085
+ const effectiveTransition = transitionForProperty(commandTransition, key)
1086
+ // RETURN-shaped (M3 r8 major b9eb82c0d429): the returned refusal is boundary/validator-
1087
+ // made by construction; anything THROWN through core validation (a polluted parse
1088
+ // dependency) propagates untouched — no catch exists on this path.
1089
+ // A keyframe ARRAY is only an ANIMATE target (REQ-API-033); `initial`/`from` are a SINGLE start
1090
+ // value, so an array there is refused AT the boundary (review major 34) — never left to escape as
1091
+ // an uncaught resolveNumeric throw downstream.
1092
+ if (!allowArrays && Array.isArray(value)) {
1093
+ return new Error(
1094
+ `'${key}' takes a single start value, not a keyframe array — a keyframe array is an ANIMATE ` +
1095
+ 'target only (REQ-API-010/011/033).',
1096
+ )
1097
+ }
1098
+ const coreRefusal = validateTargetRefusal({ [key]: value } as Target, NATIVE_HOST, {
1099
+ componentId,
1100
+ })
1101
+ if (coreRefusal !== null) return coreRefusal
1102
+ // R8-F1 through the SEVERITY boundary (review major 41): a keyframe array whose command-level
1103
+ // transition is an EXPLICIT spring interpolates exactly two keyframes — a longer array is refused
1104
+ // HERE (per-key: production reports + drops this key, valid siblings survive; development throws)
1105
+ // instead of escaping as an uncaught throw from the controller's atomicity preflight. An
1106
+ // omitted/empty or non-spring transition routes the array through the keyframes generator (no
1107
+ // refusal); the per-prop default is never a spring for a >2 count. Applies to numeric AND color
1108
+ // arrays (a color array's progress keyframes carry the same count).
1109
+ if (
1110
+ Array.isArray(value) &&
1111
+ hasDefinedTransition(effectiveTransition) &&
1112
+ effectiveTransition?.type === 'spring'
1113
+ ) {
1114
+ const springRefusal = springKeyframeCountRefusal(value.length)
1115
+ if (springRefusal !== null) return springRefusal
1116
+ }
1117
+ // R8 M2-B (REQ-API-033 law d): validate keyframe timing before any family-specific early return.
1118
+ // Exact-count `times` override even offsets; other shape-valid counts fall back. Missing segment
1119
+ // easings are linear and extras are ignored. Scalar targets still refuse keyframe-only timing as
1120
+ // inert. Applies to numeric AND color arrays (color progress keyframes carry the same count).
1121
+ const timingRefusal = keyframeTimingRefusal(componentId, key, value, effectiveTransition)
1122
+ if (timingRefusal !== null) return timingRefusal
1123
+ if (key === 'transitionEnd') {
1124
+ // T23 B3: a settle-JUMP sub-target, not a driven value key. Core validateTarget deep-validated
1125
+ // its shape and every sub-value; the native boundary destructures it into the settle lane
1126
+ // (MotionView), and applyTransitionEnd re-gates each sub-value on its own family at jump time.
1127
+ return null
1128
+ }
1129
+ if (isColorKey(key)) {
1130
+ // A color keyframe ARRAY is core-validated above (same-value-type, length ≥2, null only at index 0
1131
+ // for from-current) and driven by the R8 M2 color lane (major 35, projectColorSequence); the
1132
+ // `initial`/`from` scalar lane already refused arrays at the top (allowArrays gate).
1133
+ if (Array.isArray(value)) return null
1134
+ if (typeof value !== 'string') {
1135
+ return new Error(
1136
+ `L1: color target '${key}' is ${JSON.stringify(value)}; an rgba-typed key takes a color ` +
1137
+ 'string (specs/L1-BUILD-PACKET.md §semantics 6).',
1138
+ )
1139
+ }
1140
+ return null
1141
+ }
1142
+ if (isDiscreteKey(key)) {
1143
+ // T23 B2b: a single keyword string of the discrete family; arrays are not chartered. The
1144
+ // same verdict discreteValue throws — RETURN-shaped here so production reports + drops.
1145
+ if (Array.isArray(value)) {
1146
+ return new Error(
1147
+ `T23: discrete keyframe arrays are not implemented — '${key}' takes a single keyword ` +
1148
+ 'target (scalar display/visibility only).',
1149
+ )
1150
+ }
1151
+ if (
1152
+ typeof value !== 'string' ||
1153
+ !isDiscreteKeyword(value) ||
1154
+ parseValue(value).kind !== 'discrete'
1155
+ ) {
1156
+ return new Error(
1157
+ `T23: discrete target '${key}' is ${JSON.stringify(value)}; a display/visibility target ` +
1158
+ "is a single CSS keyword string (e.g. 'flex'/'none'/'visible'/'hidden').",
1159
+ )
1160
+ }
1161
+ return null
1162
+ }
1163
+ // A numeric keyframe ARRAY is core-validated above (REQ-API-033) and driven by the R8 M2 numeric
1164
+ // keyframe lane; a scalar number passes; measure-resolved length strings pass for positional keys
1165
+ // (REQ-VALUETYPE-013); anything else stays refused.
1166
+ if (typeof value === 'number' || Array.isArray(value)) return null
1167
+ if (
1168
+ typeof value === 'string' &&
1169
+ isPositionalLengthKey(key) &&
1170
+ isMeasureResolvedLengthString(value)
1171
+ ) {
1172
+ return null
1173
+ }
1174
+ return new Error(
1175
+ `L1 scope: target '${key}' is ${JSON.stringify(value)}; only a numeric value, measure-resolved ` +
1176
+ 'length string (%/auto/simple calc/px), or numeric keyframe array is driven for non-color keys ' +
1177
+ '(REQ-VALUETYPE-013).',
1178
+ )
1179
+ }
1180
+
1181
+ function checkKeyAtBoundary(
1182
+ key: string,
1183
+ value: unknown,
1184
+ allowArrays: boolean,
1185
+ commandTransition: Transition | undefined,
1186
+ componentId: MotionComponentId,
1187
+ ): void {
1188
+ const refusal = keyBoundaryRefusal(key, value, allowArrays, commandTransition, componentId)
1189
+ if (refusal !== null) throw refusal
1190
+ }
1191
+
1192
+ export function gateTargetWithSeverity(
1193
+ target: Target,
1194
+ severity: NativeSeverity,
1195
+ report: (error: Error) => void,
1196
+ // Whether a keyframe ARRAY is a legal value here: the ANIMATE lane accepts arrays (REQ-API-033); the
1197
+ // `initial` lane is scalar-only (a single start value) and refuses them AT the boundary (major 34).
1198
+ allowArrays = true,
1199
+ // The command-level transition (major 41): its type drives the R8-F1 spring/keyframe-array
1200
+ // compatibility refusal per key, so an incompatible array is reported + dropped under severity here
1201
+ // instead of throwing uncaught from the controller. Absent for the `initial` lane (arrays refused).
1202
+ commandTransition: Transition | undefined = undefined,
1203
+ componentId: MotionComponentId = '<Motion.View>',
1204
+ ): Target {
1205
+ return gateTargetWithSeverityResult(
1206
+ target,
1207
+ severity,
1208
+ report,
1209
+ allowArrays,
1210
+ commandTransition,
1211
+ componentId,
1212
+ ).target
1213
+ }
1214
+
1215
+ export interface GatedTargetWithSeverity {
1216
+ /** The immutable target snapshot that passed the per-property severity gate. */
1217
+ readonly target: Target
1218
+ /** Keys supplied to the gate before per-property refusals; derived from the same snapshot. */
1219
+ readonly attemptedKeys: readonly string[]
1220
+ }
1221
+
1222
+ // The richer form lets a supplying boundary distinguish an absent fallback consumer from one whose
1223
+ // every property was already refused. It is deliberately derived from the same captured target as the
1224
+ // returned target: examining the caller's object again would recreate the validated/dispatched split.
1225
+ export function gateTargetWithSeverityResult(
1226
+ target: Target,
1227
+ severity: NativeSeverity,
1228
+ report: (error: Error) => void,
1229
+ allowArrays = true,
1230
+ commandTransition: Transition | undefined = undefined,
1231
+ componentId: MotionComponentId = '<Motion.View>',
1232
+ ): GatedTargetWithSeverity {
1233
+ // Whole-target shape gate (round 9 major 31): a malformed non-object target (null, primitive,
1234
+ // array) is ONE typed failure and the WHOLE target is refused — there are no per-key survivors
1235
+ // of a shapeless target. Without this, Object.keys/entries silently coerced numbers and
1236
+ // booleans to the empty target and raw-TypeErrored on null, so native mounted a static view
1237
+ // for inputs the web entry rejects (REQ-API-013/014, one severity policy across engines).
1238
+ const shapeRefusal = targetShapeRefusal(target, { componentId })
1239
+ if (shapeRefusal !== null) {
1240
+ if (severity === 'development') throw shapeRefusal
1241
+ report(shapeRefusal)
1242
+ return { target: {} as Target, attemptedKeys: [] }
1243
+ }
1244
+ // Materialize each keyframe array member ONCE, before validation (review major 30 / web major 23):
1245
+ // an accessor-backed array read during validation and re-read on forward could substitute the
1246
+ // accepted value; the single frozen snapshot is the ONE truth validation AND forwarding (and the
1247
+ // controller's `current` diff base) see. A null-prototype accumulator keeps an own '__proto__' key
1248
+ // from vanishing to the legacy setter (major 24 parity); non-array members pass through.
1249
+ const captured = captureTargetArrays(target)
1250
+ if (captured.refusal !== null) {
1251
+ if (severity === 'development') throw captured.refusal
1252
+ report(captured.refusal)
1253
+ return { target: {} as Target, attemptedKeys: [] }
1254
+ }
1255
+ const snapshot = captured.target
1256
+ const attemptedKeys = Object.freeze(Object.keys(snapshot))
1257
+ if (severity === 'development') {
1258
+ validateAgainstNativeHost(snapshot, componentId)
1259
+ for (const [key, value] of Object.entries(snapshot)) {
1260
+ checkKeyAtBoundary(key, value, allowArrays, commandTransition, componentId)
1261
+ }
1262
+ return { target: snapshot, attemptedKeys }
1263
+ }
1264
+ // RETURN-shaped per key (M3 r8 major b9eb82c0d429): only the boundary's OWN refusals are
1265
+ // reported and dropped; a dependency fault thrown through core validation keeps its
1266
+ // exact identity — no catch exists here.
1267
+ const kept: Record<string, unknown> = Object.create(null)
1268
+ for (const [key, value] of Object.entries(snapshot)) {
1269
+ const refusal = keyBoundaryRefusal(key, value, allowArrays, commandTransition, componentId)
1270
+ if (refusal !== null) {
1271
+ report(refusal)
1272
+ continue
1273
+ }
1274
+ kept[key] = value
1275
+ }
1276
+ return { target: kept as Target, attemptedKeys }
1277
+ }
1278
+
1279
+ // Materialize a validated-shape target's keyframe array members ONCE into frozen snapshots (review
1280
+ // major 30). A null-prototype accumulator is own-property-safe: an own enumerable '__proto__' key
1281
+ // lands as data instead of vanishing to the legacy prototype setter (major 24 parity), so it reaches
1282
+ // the capability gate. The shared bounded capture reads each caller array length and own index once;
1283
+ // scalars pass through.
1284
+ export function materializeTargetArrays(
1285
+ target: Target,
1286
+ componentId: MotionComponentId = '<Motion.View>',
1287
+ ): Target {
1288
+ const captured = captureTargetArrays(target, componentId)
1289
+ if (captured.refusal !== null) throw captured.refusal
1290
+ return captured.target
1291
+ }
1292
+
1293
+ function captureTargetArrays(
1294
+ target: Target,
1295
+ componentId: MotionComponentId = '<Motion.View>',
1296
+ ): {
1297
+ readonly target: Target
1298
+ readonly refusal: InvalidTargetError | null
1299
+ } {
1300
+ const snapshot: Record<string, unknown> = Object.create(null)
1301
+ for (const key of Object.keys(target)) {
1302
+ const value = (target as Record<string, unknown>)[key]
1303
+ if (!Array.isArray(value)) {
1304
+ snapshot[key] = value
1305
+ continue
1306
+ }
1307
+ const capture = captureBoundedArray(value)
1308
+ if (capture.kind !== 'captured') {
1309
+ return {
1310
+ target: {} as Target,
1311
+ refusal: new InvalidTargetError(
1312
+ componentId,
1313
+ key,
1314
+ capturedArrayDescription(capture),
1315
+ 'keyframe arrays must have a safe bounded length (REQ-API-033)',
1316
+ ),
1317
+ }
1318
+ }
1319
+ snapshot[key] = capture.values
1320
+ }
1321
+ return { target: snapshot as Target, refusal: null }
1322
+ }
1323
+
1324
+ // VENDORED motion-prop predicate — framer-motion's `isValidMotionProp` (the pinned oracle's
1325
+ // own denial surface, review round 7 major f10c6c8a: a hand-maintained subset forwarded
1326
+ // Motion-valid capabilities like dragConstraints). Vendored rather than imported so the native
1327
+ // bundle never pulls motion/react; `motionPropOracle.canary.test.ts` compares this predicate
1328
+ // against the REAL isValidMotionProp and fails on drift. The declared public props
1329
+ // (initial/animate/exit/transition/style/onAnimationComplete/children) are destructured before
1330
+ // the gate and never reach it.
1331
+ export function isPinnedMotionProp(key: string): boolean {
1332
+ return isNativeViewMotionProp(key)
1333
+ }
1334
+
1335
+ // The extra denial the native surface adds beyond motion's predicate: capabilities this repo
1336
+ // declares on the WEB ViewProps but has not pulled natively. EMPTY since FLAG 5a pulled
1337
+ // dragSnapPoints into the native surface; the mechanism stays for the next web-ahead capability.
1338
+ const NATIVE_DEFERRED_EXTRAS = new Set<string>()
1339
+
1340
+ /** Deferred drag/pan family names cited by residual Campaign 1 (REQ-API-024). */
1341
+ export function isDeferredDragFamilyProp(key: string): boolean {
1342
+ // R15 D5 (REQ-API-046): _dragX/_dragY SHIPPED — never deferred.
1343
+ // R15 D6 (REQ-API-047): onPan* SHIPPED — never deferred.
1344
+ if (key === 'dragOriginEvent') return true
1345
+ // R15 D4 (REQ-API-044): onDirectionLock + dragDirectionLock SHIPPED — never deferred.
1346
+ // R15 D5 (REQ-API-045): dragMomentum / dragTransition / dragSnapToOrigin SHIPPED — never deferred.
1347
+ // Shipped drag surface is destructured before this gate; remaining drag* rest props are deferred.
1348
+ // R15 D1 (REQ-API-041): dragPropagation SHIPPED (destructured + root lock) — never deferred.
1349
+ // R15 D3 (REQ-API-043): dragControls + dragListener SHIPPED — never deferred.
1350
+ if (
1351
+ key.startsWith('drag') &&
1352
+ key !== 'draggable' &&
1353
+ key !== 'dragPropagation' &&
1354
+ key !== 'dragControls' &&
1355
+ key !== 'dragListener' &&
1356
+ key !== 'dragDirectionLock' &&
1357
+ key !== 'dragMomentum' &&
1358
+ key !== 'dragTransition' &&
1359
+ key !== 'dragSnapToOrigin'
1360
+ )
1361
+ return true
1362
+ return false
1363
+ }
1364
+
1365
+ export function gateDeferredViewProps(
1366
+ rest: Record<string, unknown>,
1367
+ severity: NativeSeverity,
1368
+ report: (error: Error) => void,
1369
+ componentId: MotionComponentId = '<Motion.View>',
1370
+ ): Record<string, unknown> {
1371
+ const forwarded: Record<string, unknown> = {}
1372
+ for (const [key, value] of Object.entries(rest)) {
1373
+ if (value === undefined) continue
1374
+ if (isPinnedMotionProp(key) || NATIVE_DEFERRED_EXTRAS.has(key)) {
1375
+ const dragFamily = isDeferredDragFamilyProp(key)
1376
+ const error = new Error(
1377
+ dragFamily
1378
+ ? `${componentId}: "${key}" is a deferred drag-family capability (REQ-API-024) — ` +
1379
+ 'refused until its shipping campaign lands; never a silent free-drag substitute.'
1380
+ : `${componentId}: "${key}" is a motion capability outside the ratified native surface — it fails ` +
1381
+ 'loudly on every engine until its own rung lands (REQ-API-006; BRIEF Decisions).',
1382
+ )
1383
+ if (severity === 'development') throw error
1384
+ report(error)
1385
+ continue
1386
+ }
1387
+ forwarded[key] = value
1388
+ }
1389
+ return forwarded
1390
+ }
1391
+
1392
+ // The optional-animate growth rule (review round 5): a STATIC mount (no keys at all) accepting
1393
+ // its first keyed `animate` is valid Motion — the wrapper REMOUNTS its internals with the new
1394
+ // key set (new element registration; starts resolve from the current static visuals). Growing
1395
+ // a NON-empty mounted set stays the ratified L1 fixed-at-mount deferral: the update guard
1396
+ // throws loud, recorded as a Motion divergence until the dynamic-key rung.
1397
+ export type MotionViewUpdatePlan = 'update' | 'grow-from-empty'
1398
+
1399
+ export function planMotionViewUpdate(
1400
+ mountedKeys: readonly string[],
1401
+ animate: Target,
1402
+ ): MotionViewUpdatePlan {
1403
+ if (mountedKeys.length === 0 && Object.keys(animate).length > 0) return 'grow-from-empty'
1404
+ return 'update'
1405
+ }
1406
+
1407
+ export function createMotionViewController(
1408
+ binding: WorkletDriverBinding,
1409
+ props: MotionViewProps,
1410
+ hooks: MotionViewControllerHooks = {},
1411
+ ): MotionViewController {
1412
+ let handle: ElementHandle | null = null
1413
+ let unmounted = false
1414
+ // The severity channel for the R8-F1 compat filter (major 42): a direct consumer defaults to the
1415
+ // development loud law; the component threads its ambient severity + reporter.
1416
+ const severity: NativeSeverity = hooks.severity ?? 'development'
1417
+ const report = hooks.report ?? ((): void => {})
1418
+ const componentId = props.componentId ?? '<Motion.View>'
1419
+ // A driver command can REFUSE from BELOW the boundary, and the boundary cannot pre-empt it: the
1420
+ // fold re-measures its iteration from the LIVE value, so a `repeat` that validation certified and
1421
+ // that `start` accepted can be refused at a retarget (T18-a round-7 review MAJOR). Left unhandled
1422
+ // that surfaced as an uncaught `InvalidTransitionError` from inside a React lifecycle — on BOTH
1423
+ // severity lanes, including production, whose entire purpose is that it reports instead.
1424
+ //
1425
+ // The ratified severity law owns this outcome and already says what it is (see `keyBoundaryRefusal`
1426
+ // above): development throws typed and untouched; production reports each offender through the
1427
+ // error channel and refuses it, while the REMAINING KEYS PROCEED. The last clause is why a refused
1428
+ // command is re-issued per key rather than dropped whole — the driver commits a retarget
1429
+ // atomically, so a multi-key refusal applied nothing at all and the survivors would otherwise be
1430
+ // punished for sharing a command with an offender.
1431
+ //
1432
+ // Only the fold's own refusal is routed here. Anything else is a different fault and propagates.
1433
+ // The recognition is STRUCTURAL and single-sourced in core: class identity cannot cross to the UI
1434
+ // runtime, so `instanceof` was decidable on the reference backend only and this router silently
1435
+ // stopped firing on the backend that ships (T18-a round-9 review BLOCKING 1).
1436
+ const isFoldRefusal = isRepeatFoldRefusal
1437
+ // A fold refusal is only reachable when the author asked for a fold — no resolved default injects
1438
+ // `repeat` — so this is the exact predicate for "this command could be refused from below".
1439
+ const transitionCarriesFold = (transition: unknown): boolean =>
1440
+ typeof transition === 'object' &&
1441
+ transition !== null &&
1442
+ (transition as { repeat?: unknown }).repeat !== undefined
1443
+ const commandCarriesFold = (command: Extract<DriverCommand, { targets: unknown }>): boolean => {
1444
+ if (transitionCarriesFold(command.transition)) return true
1445
+ if (command.kind === 'retarget' && command.targetTransitions !== undefined) {
1446
+ for (const key of Object.keys(command.targetTransitions)) {
1447
+ if (transitionCarriesFold(command.targetTransitions[key])) return true
1448
+ }
1449
+ }
1450
+ if (command.kind !== 'start') return false
1451
+ // A start resolves its transition PER PROP (keyframe-count-aware defaults), so the fold can
1452
+ // live on a target rather than on the command.
1453
+ for (const key of Object.keys(command.targets)) {
1454
+ if (transitionCarriesFold(command.targets[key]!.transition)) return true
1455
+ }
1456
+ return false
1457
+ }
1458
+ // The identity-channel lane marker for the adapter binding (REQ-API-061): the declarative
1459
+ // update lane (MotionView's animateScope-carrying update) keeps a timed trajectory on the exact
1460
+ // identity, while an unmarked gesture-state/presence retarget of the same shape takes the
1461
+ // host-resolved carrier commit. Set per update()/mount() call; read once per issued command.
1462
+ let commandLaneIsDeclarative = false
1463
+ const issueCommand = (h: ElementHandle, command: DriverCommand): void => {
1464
+ const commands = rewritePathCommands(command, () => binding.driver.committed(h))
1465
+ try {
1466
+ for (const next of commands) {
1467
+ binding.driver.command(
1468
+ h,
1469
+ commandLaneIsDeclarative
1470
+ ? (Object.assign(next, {
1471
+ [IDENTITY_DECLARATIVE_LANE_COMMAND]: true,
1472
+ }) as DriverCommand)
1473
+ : next,
1474
+ )
1475
+ }
1476
+ } catch (error) {
1477
+ if (!isFoldRefusal(error) || severity === 'development') throw error
1478
+ report(error)
1479
+ }
1480
+ }
1481
+ // Only the target-bearing commands can carry a fold; `stop` has nothing to refuse.
1482
+ const commandThroughSeverity = (
1483
+ h: ElementHandle,
1484
+ command: Extract<DriverCommand, { targets: unknown }>,
1485
+ ): void => {
1486
+ const keys = Object.keys(command.targets)
1487
+ // A raw DriverCommand has one command-level transition slot, so a map cannot represent distinct
1488
+ // scalar retarget timings there. Resolve it at this controller boundary: starts keep one atomic
1489
+ // command by placing the selected flat bag in each target slot; scalar retargets become one flat
1490
+ // command per selected bag. The concrete drivers and the shared prepared boundary therefore never
1491
+ // receive a map, while every key still follows the same resolver used by mount/layout validation.
1492
+ if (hasTransitionMap(command.transition)) {
1493
+ if (command.kind === 'retarget') {
1494
+ const selectedTransitions = keys.map((key) => ({
1495
+ key,
1496
+ selectedTransition: effectiveTransitionForProperty(
1497
+ key,
1498
+ command.targets[key]!,
1499
+ command.transition,
1500
+ ),
1501
+ selectedAuthoredTransition: transitionForProperty(command.transition, key) ?? {},
1502
+ }))
1503
+ if (
1504
+ severity === 'development' &&
1505
+ selectedTransitions.some(({ selectedTransition }) =>
1506
+ transitionCarriesFold(selectedTransition),
1507
+ )
1508
+ ) {
1509
+ // Keep every mapped property in one prepared command on the loud lane. The backend
1510
+ // preflight then sees the whole retarget before mutating any shared value; sequentially
1511
+ // issuing mapped keys would commit an early key before a later fold refusal (review major
1512
+ // n4wse8).
1513
+ const targetTransitions: Record<string, PropTransition> = {}
1514
+ for (const {
1515
+ key,
1516
+ selectedTransition,
1517
+ selectedAuthoredTransition,
1518
+ } of selectedTransitions) {
1519
+ targetTransitions[key] = selectedTransition ?? selectedAuthoredTransition
1520
+ }
1521
+ commandThroughSeverity(h, {
1522
+ ...command,
1523
+ transition: {},
1524
+ targetTransitions,
1525
+ } as Extract<DriverCommand, { targets: unknown }>)
1526
+ return
1527
+ }
1528
+ for (const { key, selectedTransition, selectedAuthoredTransition } of selectedTransitions) {
1529
+ commandThroughSeverity(h, {
1530
+ ...command,
1531
+ targets: { [key]: command.targets[key]! },
1532
+ transition: selectedAuthoredTransition,
1533
+ ...(selectedTransition === undefined
1534
+ ? {}
1535
+ : { targetTransitions: { [key]: selectedTransition } }),
1536
+ } as Extract<DriverCommand, { targets: unknown }>)
1537
+ }
1538
+ return
1539
+ }
1540
+ const targets: Record<string, PropTarget> = {}
1541
+ for (const key of keys) {
1542
+ const target = command.targets[key]!
1543
+ const targetTransition = target.transition
1544
+ const selectedTransition =
1545
+ targetTransition === undefined
1546
+ ? effectiveTransitionForProperty(key, target.to, command.transition)
1547
+ : hasTransitionMap(targetTransition as Transition)
1548
+ ? effectiveTransitionForProperty(key, target.to, targetTransition as Transition)
1549
+ : undefined
1550
+ targets[key] = {
1551
+ ...target,
1552
+ ...(targetTransition === undefined
1553
+ ? selectedTransition === undefined
1554
+ ? {}
1555
+ : { transition: selectedTransition }
1556
+ : hasTransitionMap(targetTransition as Transition)
1557
+ ? selectedTransition === undefined
1558
+ ? {}
1559
+ : { transition: selectedTransition }
1560
+ : {}),
1561
+ }
1562
+ }
1563
+ commandThroughSeverity(h, {
1564
+ ...command,
1565
+ targets,
1566
+ transition: {},
1567
+ } as Extract<DriverCommand, { targets: unknown }>)
1568
+ return
1569
+ }
1570
+ // Development keeps the command whole: the typed throw IS the law there, and nothing degrades.
1571
+ // Production splits a FOLD-bearing multi-key command into per-key commands BEFORE issuing it.
1572
+ // Splitting REACTIVELY — issue, catch, re-issue per key — cannot work on the backend that
1573
+ // ships: it crosses commands to the UI runtime through a fire-and-forget schedule, so a refusal
1574
+ // never returns to this caller as a throw and the healthy keys went down with the offender
1575
+ // (T18-a round-9 review BLOCKING 2). Splitting up front works on BOTH lanes because each key
1576
+ // becomes its own atomic command, and it costs nothing on the common path: a command with no
1577
+ // authored fold cannot be refused, so it keeps the single crossing (REQ-DRIVER-013).
1578
+ if (severity === 'development' || keys.length <= 1 || !commandCarriesFold(command)) {
1579
+ issueCommand(h, command)
1580
+ return
1581
+ }
1582
+ let fault: unknown = null
1583
+ for (const key of keys) {
1584
+ try {
1585
+ issueCommand(h, { ...command, targets: { [key]: command.targets[key]! } } as DriverCommand)
1586
+ } catch (error) {
1587
+ // A non-fold fault is a real defect and stays loud — but it is raised AFTER every other key
1588
+ // has had its command. Aborting the split mid-loop would leave it half-applied, which is
1589
+ // the shape the split exists to avoid. First fault wins (round-9 review MINOR).
1590
+ if (fault === null) fault = error
1591
+ }
1592
+ }
1593
+ if (fault !== null) throw fault
1594
+ }
1595
+ const motionPolicyOf = (): {
1596
+ readonly shouldReduceMotion: boolean
1597
+ readonly skipAnimations: boolean
1598
+ } =>
1599
+ hooks.motionPolicyBox?.current ?? {
1600
+ shouldReduceMotion: false,
1601
+ skipAnimations: false,
1602
+ }
1603
+ const lengthLayoutOf = (): LengthLayoutContext => hooks.lengthLayoutContextBox?.current ?? {}
1604
+ // Diff bases for update() — tracking RESOLVED values (not prop identity) is what makes an
1605
+ // unrelated re-render a guaranteed no-op (M2.2 §semantics 3). Numeric keys diff on the resolved
1606
+ // number; color keys diff on the CANONICAL rgba() projection of the target (L1 §semantics),
1607
+ // so '#f00' vs 'rgb(255,0,0)' never issues a spurious command.
1608
+ let current: Record<string, number | PropTargetTo> = {}
1609
+ let activeRemovalValues: Readonly<Record<string, ResolvedValue>> | undefined
1610
+ const handedOffRemovalKeys = new Set<string>()
1611
+ // The color diff base (review major 37): the canonical of the WHOLE declared color target per key —
1612
+ // scalar rgba() or the full keyframe sequence — so a changed MIDDLE keyframe re-commands.
1613
+ const currentColorCanonical: Record<string, string> = {}
1614
+ // Live color SEQUENCE per rgba-typed key (L1 §semantics 3/4; R8 M2 color keyframe arrays, major 35):
1615
+ // the RGBA sequence the driver's progress maps through (a scalar color is a 2-element [from, to]).
1616
+ // Every mutation flows through seed() — state first, wrapper port second, driver command last — so
1617
+ // the animated style's endpoint shareds can never lag a fresh progress (no getter/mutator skew).
1618
+ const colors: Record<string, readonly RGBA[]> = {}
1619
+ // Live discrete PAIR + diff base per discrete-typed key (T23 B2b, the color-lane mirror): the
1620
+ // [from, to] keyword pair the driver's progress RESOLVES through — by whichever of
1621
+ // REQ-VALUETYPE-015's two paths the pair takes, the mixVisibility step or the zero-slot target
1622
+ // constant, which reads no progress at all. The diff base is the declared
1623
+ // target keyword (scalar-only lane). Every mutation flows through seedDiscrete — state first,
1624
+ // wrapper port second, driver command last (no getter/mutator skew).
1625
+ const discretes: Record<string, readonly [string, string]> = {}
1626
+ const currentDiscreteCanonical: Record<string, string> = {}
1627
+ // The fixed-at-mount key set (review major 52): the controller's OWN record of every registered key,
1628
+ // independent of the driver's `committed` map. An update proves EVERY changed key is a member BEFORE
1629
+ // any command — a scalar retarget to an unmounted key must not ride behind an already-committed array
1630
+ // start (atomicity), and membership can't rely on `committed` alone (a not-yet-stepped key is absent).
1631
+ const registeredKeys = new Set<string>()
1632
+ // Mount-refused keys (reviews 763d6bcb0194 + 39c6a3e7ee90): a key whose origin/seed refused
1633
+ // through the severity channel at mount is TERMINAL for this mount's life — it never
1634
+ // registers (fixed-at-mount key set), so any later update naming it must be dropped through
1635
+ // the same severity law instead of crashing on the fixed-key invariant. The mount report is
1636
+ // the one notice; subsequent applications of the same refused property drop silently.
1637
+ const mountRefusedKeys = new Set<string>()
1638
+ // Keys whose CURRENT resting target is an auto form (review ae0a42832d85): once the auto
1639
+ // animation settles, MotionView releases these to the intrinsic layout, and
1640
+ // remeasureAutoRests re-seeds their committed measure when content drifts — the next animate
1641
+ // edge then originates from the drifted content, never a stale number.
1642
+ const autoRestKeys = new Set<string>()
1643
+ // Keys whose CURRENT resting value came from an authored RELATIVE measure form and have NO
1644
+ // animate target owning their re-measure (initial-only %/calc/vw/vh rests, review
1645
+ // 7cec8ad963d8): the form is kept verbatim so remeasureAutoRests re-resolves it against a
1646
+ // changed element box / parent / viewport and INSTANT re-seeds.
1647
+ const relativeRestForms = new Map<string, string>()
1648
+ // The last AUTHORED array form each key commanded (review 26cc511b0927): an identical raw
1649
+ // array whose RESOLUTION changed means a context edge, never an authored replacement — the
1650
+ // array's resting final value retargets instead of replaying the sequence.
1651
+ const lastCommandedRawKeyframes = new Map<string, readonly unknown[]>()
1652
+ // Element-box endpoints (auto / x|y %) remain paired until onLayout. A first-mount `initial`
1653
+ // or authored style origin may require the same measurement as its animate endpoint; retaining
1654
+ // only `to` would manufacture a 0-origin instead of issuing the measured endpoint pair.
1655
+ let pendingDeferredAnimate: PendingDeferredLengthTargets | null = null
1656
+ let pendingDeferredTransition: Transition | undefined
1657
+ // Mount-deferred flush under initial={false} settles instantly (REQ-API-010). Post-mount
1658
+ // deferred flushes keep the command transition (review residual over-fix major).
1659
+ let mountDeferredNeedsInstantFlush = false
1660
+ function registerElement(registered: Record<string, number>): ElementHandle {
1661
+ for (const key of Object.keys(registered)) registeredKeys.add(key)
1662
+ return binding.driver.register(registered)
1663
+ }
1664
+
1665
+ function requireHandle(): ElementHandle {
1666
+ if (handle === null) {
1667
+ throw new Error('MotionView controller used before mount(): mount the component first.')
1668
+ }
1669
+ return handle
1670
+ }
1671
+
1672
+ // FLAG 5a staticKeys: register each not-yet-registered static key at its resolved start value
1673
+ // (REQ-API-011's rule — style wins, else the documented host base). Mutates the registration
1674
+ // map BEFORE driver.register so the element is born with the key; never adds a target. An
1675
+ // rgba-typed static key (r4 major 18: a gesture-only color) registers SETTLED at its start
1676
+ // color — from = to, progress at PROGRESS_END, the same constant-projection shape as an
1677
+ // initial-only color — so a later retarget re-seeds through the existing update color lane.
1678
+ function addStaticKeys(registered: Record<string, number>): void {
1679
+ for (const key of props.staticKeys ?? []) {
1680
+ if (key in registered) continue
1681
+ if (isDiscreteKey(key)) {
1682
+ // Settled at the start keyword (style-provided). A missing start is the severity-gated
1683
+ // discreteStartKeyword refusal (T23 B2a2): dev throws, production reports + skips the key.
1684
+ const startKeyword = discreteStartKeyword(key)
1685
+ if (startKeyword === null) continue
1686
+ seedDiscrete(key, startKeyword, [startKeyword, startKeyword])
1687
+ registered[key] = PROGRESS_END
1688
+ continue
1689
+ }
1690
+ const start = resolveStartValue(key, props.style !== undefined ? { style: props.style } : {})
1691
+ if (isColorKey(key)) {
1692
+ const startColor = colorValue(key, start)
1693
+ seed(key, startColor, [startColor, startColor]) // settled: from = to (constant projection)
1694
+ registered[key] = PROGRESS_END
1695
+ continue
1696
+ }
1697
+ const n = resolveNumericKeyWithSeverity(key, start, lengthLayoutOf(), severity, report)
1698
+ if (n !== null) registered[key] = n
1699
+ }
1700
+ }
1701
+
1702
+ // Seed a color key's live SEQUENCE (length ≥2; a scalar color is [from, to]) plus its DIFF BASE. The
1703
+ // diff base is the canonical of the DECLARED target (the whole sequence, major 37 — not just the
1704
+ // settled color); the wrapper port receives the live RGBA sequence so the animated-style worklet
1705
+ // projects through it (projectColorSequence). One mutation path — no getter/mutator skew.
1706
+ function seed(key: string, declaredTarget: ColorTarget, sequence: readonly RGBA[]): void {
1707
+ colors[key] = sequence
1708
+ currentColorCanonical[key] = colorTargetCanonical(declaredTarget)
1709
+ if (hooks.onColorEndpoints !== undefined) hooks.onColorEndpoints(key, sequence)
1710
+ }
1711
+
1712
+ // T23 B2a2 (severity law): a discrete animate/static key with NO authored start — core's
1713
+ // TYPED host-base refusal (the default diverges across engines) — is an authoring error
1714
+ // owned by this boundary: development throws core's error; production reports it once and
1715
+ // the key is refused WHOLE (no seed, no registration, no target — the numeric refused-seed
1716
+ // posture). Non-discrete host-base refusals (unknown/complex keys) stay loud on every
1717
+ // severity.
1718
+ function discreteStartKeyword(key: string): string | null {
1719
+ try {
1720
+ return discreteValue(
1721
+ key,
1722
+ resolveStartValue(key, props.style !== undefined ? { style: props.style } : {}),
1723
+ )
1724
+ } catch (error) {
1725
+ if (!(error instanceof DiscreteHostBaseError)) throw error
1726
+ if (severity === 'development') throw error
1727
+ report(error)
1728
+ // TERMINAL for the key at this mount (the numeric refused-seed law): the update lane
1729
+ // skips it silently — one report, never a fixed-key throw on a later retarget.
1730
+ mountRefusedKeys.add(key)
1731
+ return null
1732
+ }
1733
+ }
1734
+
1735
+ // Seed a discrete key's live [from, to] pair plus its DIFF BASE (the declared target keyword) —
1736
+ // the seed() mirror; the wrapper port receives the pair so the animated-style worklet projects
1737
+ // through it (projectDiscreteStep). One mutation path — no getter/mutator skew.
1738
+ function seedDiscrete(
1739
+ key: string,
1740
+ declaredTarget: string,
1741
+ pair: readonly [string, string],
1742
+ ): void {
1743
+ discretes[key] = pair
1744
+ currentDiscreteCanonical[key] = declaredTarget
1745
+ if (hooks.onDiscreteEndpoints !== undefined) hooks.onDiscreteEndpoints(key, pair)
1746
+ }
1747
+
1748
+ return {
1749
+ mount() {
1750
+ commandLaneIsDeclarative = false
1751
+ // Fail-closed ordering (REQ-API-013): every target validates BEFORE the driver sees anything.
1752
+ // The transition validates through the same boundary (r7 major 41b089c8c0af) — the
1753
+ // component pre-gates with severity; a direct controller consumer gets the loud law,
1754
+ // including this lane's OWN vocabulary (r9 major 762e9d0a4c77: the property lane rebases
1755
+ // delay and never reads velocity — the component hands it the selected projection).
1756
+ // Capture + validate the transition ONCE (review major 61): a DIRECT controller consumer's raw
1757
+ // accessor-backed transition would otherwise be read by validation and RE-read by the filter,
1758
+ // per-prop defaults, and the command — the validated value must be the one the command runs.
1759
+ // Materialize the animate arrays ONCE, then validate the SNAPSHOT (review major 60 — the mount
1760
+ // analog of major 30): the shape gate is a top-level check that reads no array element, so a
1761
+ // frozen snapshot is the ONE truth validation, filtering, resolution, and forwarding all see; a
1762
+ // direct consumer's accessor-backed array cannot pass validation with one value and forward another.
1763
+ const animateShapeRefusal = targetShapeRefusal(props.animate, { componentId })
1764
+ if (animateShapeRefusal !== null) throw animateShapeRefusal
1765
+ const transition = captureAndValidateTransition(
1766
+ props.transition,
1767
+ componentId,
1768
+ Object.keys(props.animate),
1769
+ )
1770
+ const animateSnapshot = materializeTargetArrays(props.animate, componentId)
1771
+ validateAgainstNativeHost(animateSnapshot, componentId)
1772
+ if (props.initial !== false && props.initial !== undefined) {
1773
+ validateAgainstNativeHost(props.initial, componentId)
1774
+ }
1775
+ // Filter R8-F1-incompatible arrays under severity FIRST (major 42): an indirect consumer's array
1776
+ // riding an explicit-spring element transition is reported + dropped (production) or throws
1777
+ // (development) before any mutation, so the rest of the mount is atomic and the property is
1778
+ // refused rather than throwing uncaught downstream.
1779
+ const compatAnimate = filterStartCompat(
1780
+ animateSnapshot,
1781
+ transition,
1782
+ severity,
1783
+ report,
1784
+ componentId,
1785
+ )
1786
+ const layoutCtx = lengthLayoutOf()
1787
+ const { resolved: animate, deferred: deferredAnimate } = resolveAnimateTargets(
1788
+ compatAnimate,
1789
+ layoutCtx,
1790
+ severity,
1791
+ report,
1792
+ )
1793
+ // The mount's authored array forms seed the context-vs-authored discriminator (review
1794
+ // 26cc511b0927): a later context-only re-resolution of the SAME array is a responsive
1795
+ // retarget, not a sequence restart.
1796
+ for (const key of Object.keys(animate)) {
1797
+ const raw = (compatAnimate as Record<string, unknown>)[key]
1798
+ if (Array.isArray(raw)) lastCommandedRawKeyframes.set(key, raw)
1799
+ }
1800
+ const animateColors = resolveColors(compatAnimate)
1801
+ const animateDiscretes = resolveDiscretes(compatAnimate)
1802
+ if (Object.keys(deferredAnimate).length > 0) {
1803
+ pendingDeferredAnimate = deferredTargetsFrom(deferredAnimate)
1804
+ pendingDeferredTransition = transition
1805
+ mountDeferredNeedsInstantFlush = props.initial === false
1806
+ }
1807
+
1808
+ if (props.initial === false) {
1809
+ // REQ-API-010 sentinel: first painted frame IS the settled animate state — register at the
1810
+ // animate values, no entrance command, element idle. A keyframe array rests at its LAST
1811
+ // keyframe (settledTargetValue). A color registers at PROGRESS_END with from = to: the
1812
+ // projection is constant at the target color.
1813
+ const registered: Record<string, number> = {}
1814
+ for (const key of Object.keys(animate)) {
1815
+ registered[key] = settledTargetValue(key, animate[key]!)
1816
+ }
1817
+ // Element-measure deferred keys still join the fixed-at-mount set at host base; flush
1818
+ // after onLayout retargets them to the measured settle without re-registering. A REFUSED
1819
+ // seed base (e.g. a missing-parent % style) never fabricates a zero registration —
1820
+ // one report at the verdict, key left unregistered (review 763d6bcb0194). The refusal is
1821
+ // TERMINAL for the key at this mount (review 39c6a3e7ee90): it leaves pending AND the
1822
+ // diff base too — a leftover pending would let the post-onLayout flush re-enter update()
1823
+ // and throw the fixed-key invariant OUTSIDE the severity channel.
1824
+ const refusedSeedKeys: string[] = []
1825
+ for (const key of Object.keys(deferredAnimate)) {
1826
+ if (!(key in registered)) {
1827
+ const seedVerdict = resolveNumericKeyVerdict(
1828
+ key,
1829
+ resolveStartValue(key, props.style !== undefined ? { style: props.style } : {}),
1830
+ layoutCtx,
1831
+ severity,
1832
+ report,
1833
+ )
1834
+ if (seedVerdict.kind === 'refused') {
1835
+ refusedSeedKeys.push(key)
1836
+ mountRefusedKeys.add(key)
1837
+ continue
1838
+ }
1839
+ registered[key] =
1840
+ seedVerdict.kind === 'resolved'
1841
+ ? seedVerdict.value
1842
+ : deferredRegistrationSeed(key, props.style, layoutCtx, severity, report)
1843
+ }
1844
+ }
1845
+ if (refusedSeedKeys.length > 0 && pendingDeferredAnimate !== null) {
1846
+ const retained: PendingDeferredLengthTargets = {}
1847
+ for (const key of Object.keys(pendingDeferredAnimate)) {
1848
+ if (!refusedSeedKeys.includes(key)) retained[key] = pendingDeferredAnimate[key]!
1849
+ }
1850
+ pendingDeferredAnimate = Object.keys(retained).length === 0 ? null : retained
1851
+ if (pendingDeferredAnimate === null) {
1852
+ pendingDeferredTransition = undefined
1853
+ mountDeferredNeedsInstantFlush = false
1854
+ }
1855
+ }
1856
+ for (const key of Object.keys(animateColors)) {
1857
+ const target = animateColors[key]!
1858
+ const settled = settledColor(key, target)
1859
+ // initial=false: rest AT the settled color, constant projection (from = to). The DIFF BASE is
1860
+ // the DECLARED target (the whole array), not the settled scalar (review major 43) — else an
1861
+ // unchanged re-render diffs its full-sequence canonical against a settled-only base and wrongly
1862
+ // restarts, violating both the settled-mount rule and the no-command invariant.
1863
+ seed(key, target, [settled, settled])
1864
+ registered[key] = PROGRESS_END
1865
+ }
1866
+ // initial={false} discrete keys rest AT the settled target keyword (constant projection).
1867
+ for (const key of Object.keys(animateDiscretes)) {
1868
+ const keyword = animateDiscretes[key]!
1869
+ seedDiscrete(key, keyword, [keyword, keyword])
1870
+ registered[key] = PROGRESS_END
1871
+ }
1872
+ addStaticKeys(registered)
1873
+ seedPathRotationKey(registered, transition)
1874
+ handle = registerElement(registered)
1875
+ current = { ...animate }
1876
+ for (const key of Object.keys(deferredAnimate)) {
1877
+ // A refused seed never enters the diff base either (review 39c6a3e7ee90).
1878
+ if (!(key in current) && !refusedSeedKeys.includes(key)) current[key] = registered[key]!
1879
+ }
1880
+ return handle
1881
+ }
1882
+
1883
+ // Start values per key (REQ-API-010/011): an `initial` target value wins, else the style
1884
+ // value, else the documented host base — the latter two via core's resolveStartValue. Color
1885
+ // start values resolve identically, then parse through the typed entry gate.
1886
+ // Start values per key (REQ-API-010/011): an `initial` target value wins, else the style
1887
+ // value, else the documented host base — the latter two via core's resolveStartValue. Color
1888
+ // start values resolve identically, then parse through the typed entry gate. Every origin
1889
+ // resolves through the tri-state verdict ONCE per mount (review 763d6bcb0194): a refusal
1890
+ // reports exactly once, and the same verdict is reused everywhere below — never re-reported.
1891
+ const initialVerdicts = new Map<string, OriginResolution>()
1892
+ const initial: Record<string, number> = {}
1893
+ // (initial === false returned above; here initial is a Target or undefined.)
1894
+ if (props.initial !== undefined) {
1895
+ for (const key of Object.keys(props.initial as Record<string, unknown>)) {
1896
+ if (isColorKey(key) || isDiscreteKey(key)) continue
1897
+ const verdict = resolveNumericKeyVerdict(
1898
+ key,
1899
+ (props.initial as Record<string, unknown>)[key],
1900
+ layoutCtx,
1901
+ severity,
1902
+ report,
1903
+ )
1904
+ initialVerdicts.set(key, verdict)
1905
+ if (verdict.kind === 'resolved') initial[key] = verdict.value
1906
+ }
1907
+ }
1908
+ const initialColors = props.initial === undefined ? {} : resolveColors(props.initial)
1909
+ const initialDiscretes =
1910
+ props.initial === undefined ? {} : resolveDiscretes(props.initial as Target)
1911
+ const from: Record<string, number> = {}
1912
+ const mountDeferredPairs: PendingDeferredLengthTargets = {
1913
+ ...pendingDeferredAnimate,
1914
+ }
1915
+ const rawStartValue = (key: string): unknown => {
1916
+ if (props.initial !== undefined && props.initial !== false && key in props.initial) {
1917
+ return (props.initial as Record<string, unknown>)[key]!
1918
+ }
1919
+ return resolveStartValue(key, props.style !== undefined ? { style: props.style } : {})
1920
+ }
1921
+ for (const key of Object.keys(animate)) {
1922
+ if (key in initial) {
1923
+ from[key] = initial[key]!
1924
+ } else {
1925
+ const rawStart = rawStartValue(key)
1926
+ const verdict =
1927
+ initialVerdicts.get(key) ??
1928
+ resolveNumericKeyVerdict(key, rawStart, layoutCtx, severity, report)
1929
+ if (verdict.kind === 'resolved') {
1930
+ from[key] = verdict.value
1931
+ } else if (verdict.kind === 'deferred') {
1932
+ // Do not start a resolved target from a synthetic 0 when initial/style needs the
1933
+ // element box. The deferred flush resolves and commands both endpoints together.
1934
+ mountDeferredPairs[key] = {
1935
+ to: (compatAnimate as Record<string, unknown>)[key]!,
1936
+ from: rawStart,
1937
+ }
1938
+ delete animate[key]
1939
+ } else {
1940
+ // Refused (review 763d6bcb0194): the report fired once, at the verdict — drop the
1941
+ // property entirely: never a fabricated-zero registration, never a pending measure.
1942
+ // Terminal for this mount (review 39c6a3e7ee90): later updates drop it silently.
1943
+ mountRefusedKeys.add(key)
1944
+ delete animate[key]
1945
+ }
1946
+ }
1947
+ }
1948
+ // Initial-ONLY unresolved origins (review 73044cc0e1bd): a first-mount initial key no
1949
+ // animate target names — the label-form topology initial="open" with
1950
+ // variants.open.height="auto" mounts with an EMPTY animate — must still join the fixed
1951
+ // mount key set (a temporary seed registration) with its raw origin held as a pending
1952
+ // endpoint, or a later label update meets an unmounted key. Origins already refused
1953
+ // through the severity channel by the initial verdict stay refused (never resurrected).
1954
+ // (initial === false returned above; here initial is a Target or undefined.)
1955
+ if (props.initial !== undefined) {
1956
+ for (const key of Object.keys(props.initial as Record<string, unknown>)) {
1957
+ if (isColorKey(key) || isDiscreteKey(key)) continue
1958
+ if (key in mountDeferredPairs) continue // animate loop already paired this origin
1959
+ if (key in initial) continue // resolved origin — the initial-only registration owns it
1960
+ const rawStart = (props.initial as Record<string, unknown>)[key]
1961
+ const verdict = initialVerdicts.get(key)!
1962
+ if (verdict.kind === 'deferred') {
1963
+ mountDeferredPairs[key] = { from: rawStart }
1964
+ }
1965
+ // Refused: the initial verdict already threw (development) or reported once
1966
+ // (production) — leave it refused, never registered.
1967
+ }
1968
+ }
1969
+ // Deferred element-measure keys: register at the authored origin when available, otherwise a
1970
+ // temporary seed. A paired origin waits for onLayout with its target and never becomes 0→0.
1971
+ // A REFUSED origin (e.g. a missing-parent % style base) drops the pair entirely — one
1972
+ // report at the verdict, no fabricated-zero registration, no pending measure (review
1973
+ // 763d6bcb0194).
1974
+ for (const key of Object.keys(mountDeferredPairs)) {
1975
+ if (key in from) continue
1976
+ const pair = mountDeferredPairs[key]!
1977
+ if (pair.from === undefined && key in initial) {
1978
+ from[key] = initial[key]!
1979
+ continue
1980
+ }
1981
+ const rawStart = pair.from ?? rawStartValue(key)
1982
+ const verdict =
1983
+ initialVerdicts.get(key) ??
1984
+ resolveNumericKeyVerdict(key, rawStart, layoutCtx, severity, report)
1985
+ if (verdict.kind === 'resolved') {
1986
+ from[key] = verdict.value
1987
+ } else if (verdict.kind === 'deferred') {
1988
+ // A target deferred before this loop still needs its authored unresolved origin
1989
+ // retained. Pair it now instead of letting the later flush retarget from the 0 seed.
1990
+ mountDeferredPairs[key] = { ...pair, from: rawStart }
1991
+ from[key] = deferredRegistrationSeed(key, props.style, layoutCtx, severity, report)
1992
+ } else {
1993
+ mountRefusedKeys.add(key)
1994
+ delete mountDeferredPairs[key]
1995
+ }
1996
+ }
1997
+ pendingDeferredAnimate =
1998
+ Object.keys(mountDeferredPairs).length === 0 ? null : mountDeferredPairs
1999
+ if (pendingDeferredAnimate !== null && pendingDeferredTransition === undefined) {
2000
+ // review 818eba29da3a: the animate endpoint may have RESOLVED while only the authored
2001
+ // initial/style origin deferred — the measured first animation still rides the
2002
+ // AUTHORED mount transition, never a defaulted {}.
2003
+ pendingDeferredTransition = transition
2004
+ }
2005
+ const registered: Record<string, number> = { ...from }
2006
+ const targets: Record<string, PropTarget> = {}
2007
+ for (const key of Object.keys(animate)) {
2008
+ const to = animate[key]!
2009
+ // An omitted-transition keyframe ARRAY carries its count-aware per-prop default; a scalar (or an
2010
+ // array under an explicit command-level transition) rides the command-level transition (R8 M2).
2011
+ const perProp = perPropTransition(key, to, transition, motionPolicyOf())
2012
+ targets[key] = {
2013
+ to,
2014
+ from: from[key]!,
2015
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2016
+ }
2017
+ }
2018
+ // Initial-ONLY keys (review round 4 major 9 — the optional-animate surface): first paint
2019
+ // must honor `initial` exactly like the web entry, so the key registers at its initial
2020
+ // value with NO target — a static value until a later animate retargets it. An initial-only
2021
+ // RELATIVE form joins the relative-rest lane (review 7cec8ad963d8): no animate target owns
2022
+ // its re-measure, so a later context change re-resolves and re-seeds it here.
2023
+ for (const key of Object.keys(initial)) {
2024
+ if (!(key in targets)) {
2025
+ registered[key] = initial[key]!
2026
+ const rawInitial = (props.initial as Record<string, unknown>)[key]
2027
+ if (isRelativeLengthForm(rawInitial)) relativeRestForms.set(key, rawInitial)
2028
+ }
2029
+ }
2030
+ // BUILD the color targets + collect the endpoint seeds WITHOUT mutating (major 36 atomicity): the
2031
+ // seeds apply only AFTER the whole target set pre-validates, so a rejected mount mutates nothing.
2032
+ const pendingSeeds: {
2033
+ key: string
2034
+ declaredTarget: ColorTarget
2035
+ sequence: readonly RGBA[]
2036
+ }[] = []
2037
+ for (const key of Object.keys(animateColors)) {
2038
+ const target = animateColors[key]!
2039
+ const fromColor: RGBA =
2040
+ key in initialColors
2041
+ ? settledColor(key, initialColors[key]!) // initial is scalar-only ⇒ the start color
2042
+ : colorValue(
2043
+ key,
2044
+ resolveStartValue(key, props.style !== undefined ? { style: props.style } : {}),
2045
+ )
2046
+ pendingSeeds.push({
2047
+ key,
2048
+ declaredTarget: target,
2049
+ sequence: buildColorSequence(target, fromColor),
2050
+ })
2051
+ registered[key] = PROGRESS_START
2052
+ // A color ARRAY drives a numeric PROGRESS keyframe array [0…100] (per-prop count-aware default,
2053
+ // R8 M2 major 35); a scalar color rides the sealed 0→100 progress under the command transition.
2054
+ if (isColorArrayTarget(target)) {
2055
+ const progressTo = colorProgressArray(target.length)
2056
+ const perProp = perPropTransition(key, progressTo, transition, motionPolicyOf())
2057
+ targets[key] = {
2058
+ to: progressTo,
2059
+ from: PROGRESS_START,
2060
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2061
+ }
2062
+ } else {
2063
+ const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
2064
+ targets[key] = {
2065
+ to: PROGRESS_END,
2066
+ from: PROGRESS_START,
2067
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2068
+ }
2069
+ }
2070
+ }
2071
+ // Initial-only COLOR keys register settled at the initial color (from = to: the
2072
+ // projection is constant), the same shape as the initial={false} sentinel.
2073
+ for (const key of Object.keys(initialColors)) {
2074
+ if (!(key in targets)) {
2075
+ const settled = settledColor(key, initialColors[key]!) // initial is scalar-only
2076
+ pendingSeeds.push({ key, declaredTarget: settled, sequence: [settled, settled] })
2077
+ registered[key] = PROGRESS_END
2078
+ }
2079
+ }
2080
+ // BUILD the discrete targets + pair seeds WITHOUT mutating (the color block's major-36
2081
+ // atomicity): the start keyword is initial's, else style's, else the LOUD host-base refusal
2082
+ // (T23 B2a — the discrete default diverges across engines). There is no pair-legality probe:
2083
+ // since H3 every keyword pair is legal, and core's mixDiscrete answers all of them.
2084
+ const pendingDiscreteSeeds: {
2085
+ key: string
2086
+ declaredTarget: string
2087
+ pair: readonly [string, string]
2088
+ }[] = []
2089
+ for (const key of Object.keys(animateDiscretes)) {
2090
+ const target = animateDiscretes[key]!
2091
+ const fromKeyword: string | null =
2092
+ key in initialDiscretes ? initialDiscretes[key]! : discreteStartKeyword(key)
2093
+ if (fromKeyword === null) continue
2094
+ const pair: readonly [string, string] = [fromKeyword, target]
2095
+ pendingDiscreteSeeds.push({
2096
+ key,
2097
+ declaredTarget: target,
2098
+ pair,
2099
+ })
2100
+ registered[key] = PROGRESS_START
2101
+ // A scalar keyword rides the sealed 0→100 progress under the command transition (the
2102
+ // scalar-color shape; discrete keyframe arrays are refused upstream).
2103
+ const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
2104
+ targets[key] = {
2105
+ to: PROGRESS_END,
2106
+ from: PROGRESS_START,
2107
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2108
+ }
2109
+ }
2110
+ // Initial-only DISCRETE keys register settled at the initial keyword (constant projection).
2111
+ for (const key of Object.keys(initialDiscretes)) {
2112
+ if (!(key in targets)) {
2113
+ const keyword = initialDiscretes[key]!
2114
+ pendingDiscreteSeeds.push({ key, declaredTarget: keyword, pair: [keyword, keyword] })
2115
+ registered[key] = PROGRESS_END
2116
+ }
2117
+ }
2118
+ // R8-F1-incompatible arrays were filtered up-front (compatAnimate) so `targets` holds none — the
2119
+ // build → commit order below leaks no registration/endpoint on a refusal (major 36 atomicity).
2120
+ for (const { key, declaredTarget, sequence } of pendingSeeds)
2121
+ seed(key, declaredTarget, sequence)
2122
+ for (const { key, declaredTarget, pair } of pendingDiscreteSeeds)
2123
+ seedDiscrete(key, declaredTarget, pair)
2124
+ addStaticKeys(registered)
2125
+ seedPathRotationKey(registered, transition)
2126
+ handle = registerElement(registered)
2127
+ // An initial-only mount has nothing to animate — no command, element idle at first paint.
2128
+ if (Object.keys(targets).length > 0) {
2129
+ commandThroughSeverity(handle, {
2130
+ kind: 'start',
2131
+ targets,
2132
+ transition: transition ?? {},
2133
+ })
2134
+ }
2135
+ current = { ...animate }
2136
+ for (const key of Object.keys(mountDeferredPairs)) {
2137
+ if (!(key in current)) current[key] = registered[key]!
2138
+ }
2139
+ return handle
2140
+ },
2141
+
2142
+ flushDeferredLengthMeasures(held) {
2143
+ if (pendingDeferredAnimate === null || handle === null) return false
2144
+ const pendingAll = pendingDeferredAnimate
2145
+ const retainHeld: PendingDeferredLengthTargets = {}
2146
+ // Flush commands are PARTITIONED by transition ownership (review 93aeec05a9a8): each
2147
+ // pending entry rides its deferring application's OWN transition (the mount fallback
2148
+ // covers unstamped mount pairs; the instant latch still wins while set). Keys sharing
2149
+ // one transition share one command.
2150
+ const flushTargetGroups = new Map<Transition | undefined, Record<string, unknown>>()
2151
+ const startGroups = new Map<
2152
+ Transition | undefined,
2153
+ { targets: Record<string, PropTarget>; resolved: Record<string, number | PropTargetTo> }
2154
+ >()
2155
+ // Origin-only initial pendings that resolved this flush — settled INSTANT (review
2156
+ // 2bd345e800ff), never through the application transition.
2157
+ const settleTargets: Record<string, number> = {}
2158
+ const effectiveTransitionOf = (
2159
+ pending: PendingDeferredLengthTarget,
2160
+ ): Transition | undefined =>
2161
+ mountDeferredNeedsInstantFlush
2162
+ ? INSTANT_TRANSITION
2163
+ : pending.transition === null
2164
+ ? undefined // explicit default ownership (review 7d395c90c954)
2165
+ : (pending.transition ?? pendingDeferredTransition)
2166
+ const layoutContext = lengthLayoutOf()
2167
+ for (const key of Object.keys(pendingAll)) {
2168
+ const pending = pendingAll[key]!
2169
+ if (held !== undefined && held.has(key)) {
2170
+ retainHeld[key] = pending
2171
+ continue
2172
+ }
2173
+ // An origin-only pending (initial-only key, review 73044cc0e1bd) holds while its origin
2174
+ // still needs the element box — and SETTLES the registration to the measured origin the
2175
+ // moment it resolves (review 2bd345e800ff): INSTANT, never an entrance animation — the
2176
+ // authored application transition is not an entrance for an initial-only key.
2177
+ if (pending.to === undefined) {
2178
+ try {
2179
+ const measured = numericValue(key, pending.from, layoutContext)
2180
+ settleTargets[key] = measured
2181
+ } catch (error) {
2182
+ if (isDeferrableElementMeasureError(error)) {
2183
+ retainHeld[key] = pending
2184
+ continue
2185
+ }
2186
+ if (!isMeasureResolveBoundaryError(error)) throw error
2187
+ if (severity === 'development') throw error
2188
+ report(error)
2189
+ // A refused origin at flush: dropped with the report, never retained as pending.
2190
+ }
2191
+ continue
2192
+ }
2193
+ const transition = effectiveTransitionOf(pending)
2194
+ if (pending.from === undefined) {
2195
+ const group = flushTargetGroups.get(transition) ?? {}
2196
+ group[key] = pending.to
2197
+ flushTargetGroups.set(transition, group)
2198
+ continue
2199
+ }
2200
+ try {
2201
+ const to = numericTargetValue(key, pending.to, layoutContext)
2202
+ const from =
2203
+ pending.from === undefined
2204
+ ? binding.driver.committed(handle)[key]
2205
+ : numericValue(key, pending.from, layoutContext)
2206
+ if (from === undefined) {
2207
+ throw new Error(
2208
+ `L1: cannot flush deferred '${key}': it was not registered on this MotionView controller.`,
2209
+ )
2210
+ }
2211
+ const perProp = perPropTransition(key, to, transition, motionPolicyOf())
2212
+ let group = startGroups.get(transition)
2213
+ if (group === undefined) {
2214
+ group = { targets: {}, resolved: {} }
2215
+ startGroups.set(transition, group)
2216
+ }
2217
+ group.targets[key] = {
2218
+ to,
2219
+ from,
2220
+ ...(perProp === undefined ? {} : { transition: perProp }),
2221
+ }
2222
+ group.resolved[key] = to
2223
+ } catch (error) {
2224
+ if (isDeferrableElementMeasureError(error)) {
2225
+ retainHeld[key] = pending
2226
+ continue
2227
+ }
2228
+ if (!isMeasureResolveBoundaryError(error)) throw error
2229
+ if (severity === 'development') throw error
2230
+ report(error)
2231
+ }
2232
+ }
2233
+ if (
2234
+ startGroups.size === 0 &&
2235
+ flushTargetGroups.size === 0 &&
2236
+ Object.keys(settleTargets).length === 0
2237
+ ) {
2238
+ pendingDeferredAnimate = Object.keys(retainHeld).length === 0 ? null : retainHeld
2239
+ if (pendingDeferredAnimate === null) {
2240
+ pendingDeferredTransition = undefined
2241
+ mountDeferredNeedsInstantFlush = false
2242
+ }
2243
+ // Everything still held — keep pending; do not clear mount instant latch.
2244
+ return false
2245
+ }
2246
+ // Origin-only settles ride ONE instant retarget — the measured registration, never an
2247
+ // entrance (review 2bd345e800ff). The settle rests at auto ONLY when its origin IS a
2248
+ // dimension auto (review 1578a42974bc); a RELATIVE origin-only settle keeps its authored
2249
+ // relative form in the relative-rest lane instead (review 7cec8ad963d8) — never the auto
2250
+ // lane — so a later context change re-measures it.
2251
+ if (Object.keys(settleTargets).length > 0) {
2252
+ for (const key of Object.keys(settleTargets)) {
2253
+ const pendingFrom = pendingAll[key]?.from
2254
+ if ((key === 'width' || key === 'height') && restsAtAutoForm(pendingFrom)) {
2255
+ autoRestKeys.add(key)
2256
+ relativeRestForms.delete(key)
2257
+ } else if (isRelativeLengthForm(pendingFrom)) {
2258
+ autoRestKeys.delete(key)
2259
+ relativeRestForms.set(key, pendingFrom)
2260
+ } else {
2261
+ autoRestKeys.delete(key)
2262
+ relativeRestForms.delete(key)
2263
+ }
2264
+ }
2265
+ commandThroughSeverity(handle, {
2266
+ kind: 'retarget',
2267
+ targets: settleTargets,
2268
+ transition: INSTANT_TRANSITION,
2269
+ })
2270
+ current = { ...current, ...settleTargets }
2271
+ }
2272
+ for (const [transition, group] of startGroups) {
2273
+ for (const key of Object.keys(group.targets)) {
2274
+ const pendingTo = pendingAll[key]?.to
2275
+ if (restsAtAutoForm(pendingTo)) autoRestKeys.add(key)
2276
+ else autoRestKeys.delete(key)
2277
+ // The flush's start lane commands the array too (review 551ba4344486): record the
2278
+ // authored keyframes so a later context-only re-resolution of the SAME array reads as
2279
+ // a responsive final-value retarget, never a first-keyframe replay. A non-array
2280
+ // pending takes the key out of array authorship (same law as the update lane).
2281
+ if (Array.isArray(pendingTo)) lastCommandedRawKeyframes.set(key, pendingTo)
2282
+ else lastCommandedRawKeyframes.delete(key)
2283
+ }
2284
+ commandThroughSeverity(handle, {
2285
+ kind: 'start',
2286
+ targets: group.targets,
2287
+ transition: transition ?? {},
2288
+ })
2289
+ current = { ...current, ...group.resolved }
2290
+ }
2291
+ pendingDeferredAnimate = Object.keys(retainHeld).length === 0 ? null : retainHeld
2292
+ if (pendingDeferredAnimate === null) {
2293
+ pendingDeferredTransition = undefined
2294
+ mountDeferredNeedsInstantFlush = false
2295
+ }
2296
+ let commanded = startGroups.size > 0 || Object.keys(settleTargets).length > 0
2297
+ for (const [transition, flushTarget] of flushTargetGroups) {
2298
+ commanded =
2299
+ this.update({
2300
+ animate: flushTarget as Target,
2301
+ animateScope: 'partial',
2302
+ // This re-entry resolves auto against the measure the flush just seeded — the
2303
+ // retarget invalidation must not drop it back into the deferred lane (review
2304
+ // 356417a77aec).
2305
+ skipAutoMeasureInvalidation: true,
2306
+ ...(transition === undefined ? {} : { transition }),
2307
+ }) || commanded
2308
+ }
2309
+ return commanded
2310
+ },
2311
+
2312
+ remeasureAutoRests() {
2313
+ if (handle === null || (autoRestKeys.size === 0 && relativeRestForms.size === 0)) {
2314
+ return false
2315
+ }
2316
+ const layoutContext = lengthLayoutOf()
2317
+ const reseed: Record<string, number> = {}
2318
+ for (const key of autoRestKeys) {
2319
+ // A key mid-animation keeps its course — the drift re-seed applies only at rest.
2320
+ const active = binding.propertyStateFor?.(handle, key)?.active.value === true
2321
+ if (active) continue
2322
+ try {
2323
+ const measured = resolveLengthToPx('auto', key as PositionalLengthAxis, layoutContext)
2324
+ if (current[key] !== measured) reseed[key] = measured
2325
+ } catch (error) {
2326
+ if (isDeferrableElementMeasureError(error)) continue
2327
+ if (!isMeasureResolveBoundaryError(error)) throw error
2328
+ if (severity === 'development') throw error
2329
+ report(error)
2330
+ }
2331
+ }
2332
+ // The relative-rest lane (review 7cec8ad963d8): initial-only %/calc/vw/vh forms re-resolve
2333
+ // against the changed element box / parent / viewport and INSTANT re-seed.
2334
+ for (const [key, rawForm] of relativeRestForms) {
2335
+ const active = binding.propertyStateFor?.(handle, key)?.active.value === true
2336
+ if (active) continue
2337
+ try {
2338
+ const measured = numericValue(key, rawForm, layoutContext)
2339
+ if (current[key] !== measured) reseed[key] = measured
2340
+ } catch (error) {
2341
+ if (isDeferrableElementMeasureError(error)) continue
2342
+ if (!isMeasureResolveBoundaryError(error)) throw error
2343
+ if (severity === 'development') throw error
2344
+ report(error)
2345
+ }
2346
+ }
2347
+ if (Object.keys(reseed).length === 0) return false
2348
+ commandThroughSeverity(handle, {
2349
+ kind: 'retarget',
2350
+ targets: reseed,
2351
+ transition: INSTANT_TRANSITION,
2352
+ })
2353
+ current = { ...current, ...reseed }
2354
+ return true
2355
+ },
2356
+
2357
+ syncDeferredLengthPending(next) {
2358
+ commandLaneIsDeclarative = next.animateScope !== undefined
2359
+ // Pending-only: no filterStartCompat against element keyframe timing, no driver commands.
2360
+ // Label full projection uses this after per-label updates (major 210b429d9557).
2361
+ if (handle === null) {
2362
+ throw new Error('MotionView controller used before mount(): mount the component first.')
2363
+ }
2364
+ const animateShapeRefusal = targetShapeRefusal(next.animate, { componentId })
2365
+ if (animateShapeRefusal !== null) throw animateShapeRefusal
2366
+ const animateSnapshot = materializeTargetArrays(next.animate, componentId)
2367
+ validateAgainstNativeHost(animateSnapshot, componentId)
2368
+ // Mount-refused keys are terminal for this mount (review 39c6a3e7ee90): the sync must not
2369
+ // rebuild pending for them either.
2370
+ const animateEffective = dropMountRefusedKeys(animateSnapshot, mountRefusedKeys)
2371
+ const { deferred: stillDeferred } = resolveAnimateTargets(
2372
+ animateEffective,
2373
+ lengthLayoutOf(),
2374
+ severity,
2375
+ report,
2376
+ )
2377
+ const animateScope = next.animateScope ?? 'partial'
2378
+ if (animateScope === 'full') {
2379
+ const nextPending: PendingDeferredLengthTargets = {}
2380
+ for (const key of Object.keys(stillDeferred)) {
2381
+ nextPending[key] = { ...pendingDeferredAnimate?.[key], to: stillDeferred[key] }
2382
+ }
2383
+ // Origin pairs (review 73044cc0e1bd): a projection key holding a pending measured
2384
+ // origin keeps its pair — the full-projection abandon law applies to keys the
2385
+ // projection OMITS, never to origins it still names. The pair is SPREAD, never
2386
+ // reconstructed (review 759cc100e882): the per-label update stamped its OWN transition
2387
+ // on the entry, and the full sync must not launder it back to the mount fallback.
2388
+ for (const key of Object.keys(animateEffective as Target)) {
2389
+ if (key in stillDeferred) continue
2390
+ const prior = pendingDeferredAnimate?.[key]
2391
+ if (prior?.from !== undefined) {
2392
+ nextPending[key] = {
2393
+ ...prior,
2394
+ to: (animateEffective as Record<string, unknown>)[key],
2395
+ }
2396
+ }
2397
+ }
2398
+ pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
2399
+ if (pendingDeferredAnimate === null) {
2400
+ pendingDeferredTransition = undefined
2401
+ mountDeferredNeedsInstantFlush = false
2402
+ }
2403
+ // Do not clear mountDeferredNeedsInstantFlush otherwise — re-sync before first
2404
+ // onLayout must preserve the mount initial:false instant latch
2405
+ // (review-1785230277710-lhz6ub).
2406
+ } else {
2407
+ const nextPending: PendingDeferredLengthTargets = { ...pendingDeferredAnimate }
2408
+ for (const key of Object.keys(animateEffective as Target)) {
2409
+ if (key in stillDeferred) {
2410
+ nextPending[key] = { ...pendingDeferredAnimate?.[key], to: stillDeferred[key] }
2411
+ } else {
2412
+ const prior = pendingDeferredAnimate?.[key]
2413
+ if (prior?.from !== undefined) {
2414
+ // Spread preserves the stamped transition (review 759cc100e882) — the per-label
2415
+ // application's own transition rides into the flush, not the mount fallback.
2416
+ nextPending[key] = {
2417
+ ...prior,
2418
+ to: (animateEffective as Record<string, unknown>)[key],
2419
+ }
2420
+ } else delete nextPending[key]
2421
+ }
2422
+ }
2423
+ pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
2424
+ if (pendingDeferredAnimate === null) {
2425
+ pendingDeferredTransition = undefined
2426
+ mountDeferredNeedsInstantFlush = false
2427
+ }
2428
+ }
2429
+ },
2430
+
2431
+ update(next) {
2432
+ commandLaneIsDeclarative = next.animateScope !== undefined
2433
+ const h = requireHandle()
2434
+ if (next.removalValues !== activeRemovalValues) {
2435
+ activeRemovalValues = next.removalValues
2436
+ handedOffRemovalKeys.clear()
2437
+ }
2438
+ // CAPTURE + validate the transition ONCE (review majors 51/61): this update reads its transition
2439
+ // for the R8-F1 filter, per-prop defaults, AND up to THREE driver commands (array start, scalar
2440
+ // retarget, color start) that each re-capture. The frozen snapshot is what validation AND every
2441
+ // command see — a malicious accessor cannot skew the lanes/drivers or slip an unvalidated value.
2442
+ // Materialize the animate arrays ONCE, then validate the SNAPSHOT (review major 60): shape gate
2443
+ // first (no element read), then one frozen snapshot for validation, filtering, resolution, and the
2444
+ // diff base — a direct consumer's accessor-backed array can't validate one value and forward another.
2445
+ const animateShapeRefusal = targetShapeRefusal(next.animate, { componentId })
2446
+ if (animateShapeRefusal !== null) throw animateShapeRefusal
2447
+ const authoredTransition = captureAndValidateTransition(
2448
+ next.transition,
2449
+ componentId,
2450
+ Object.keys(next.animate),
2451
+ )
2452
+ // T21 (REQ-API-053): the orchestration-computed delay seeds the VALIDATED snapshot as the
2453
+ // per-key default — authored delays at every altitude win by the seeding spread, and the
2454
+ // downstream lanes (flat command transition, map selection, count-aware default merge)
2455
+ // execute it through the shipped T18-b delay machinery unchanged.
2456
+ const transition =
2457
+ next.orchestrationDelaySeconds === undefined
2458
+ ? authoredTransition
2459
+ : seedOrchestrationDelay(authoredTransition, next.orchestrationDelaySeconds)
2460
+ const animateSnapshot = materializeTargetArrays(next.animate, componentId)
2461
+ validateAgainstNativeHost(animateSnapshot, componentId)
2462
+ // Filter R8-F1-incompatible arrays under severity FIRST (major 42): an indirect consumer (a
2463
+ // gesture retarget batch riding the element-transition fallback) reaches update() past its
2464
+ // supplying gate — report + drop the property (production) or throw (development) before any diff.
2465
+ // Mount-refused keys are terminal for this mount (review 39c6a3e7ee90): they drop out of
2466
+ // every lane here instead of crashing the fixed-at-mount invariant on a later update.
2467
+ const compatAnimate = dropMountRefusedKeys(
2468
+ filterStartCompat(animateSnapshot, transition, severity, report, componentId),
2469
+ mountRefusedKeys,
2470
+ )
2471
+ // numeric→auto / auto→auto retarget re-measure (review 356417a77aec): a cached auto
2472
+ // measure is STALE the moment an auto target arrives for that dimension — it may have
2473
+ // been seeded from a constraint episode (numeric onLayout) or from content that since
2474
+ // changed. Dropping it re-enters the deferred lane so the host drops the painted
2475
+ // constraint and re-measures UNCONSTRAINED content; the flush then commands the fresh
2476
+ // measure. The deferred flush's own re-entry opts out — it resolves auto against the
2477
+ // measure it just seeded, and the pending queue was already drained before re-entry.
2478
+ if (next.skipAutoMeasureInvalidation !== true && hooks.lengthLayoutContextBox !== undefined) {
2479
+ const autoBox = hooks.lengthLayoutContextBox
2480
+ let nextBox: LengthLayoutContext | null = null
2481
+ for (const key of Object.keys(compatAnimate)) {
2482
+ if (key !== 'width' && key !== 'height') continue
2483
+ if (!containsAutoLengthTarget((compatAnimate as Record<string, unknown>)[key])) continue
2484
+ if (containsAutoLengthTarget(pendingDeferredAnimate?.[key]?.to)) continue
2485
+ if (nextBox === null) nextBox = { ...autoBox.current }
2486
+ if (key === 'width') {
2487
+ const { autoWidth: _dropped, ...rest } = nextBox
2488
+ nextBox = rest
2489
+ } else {
2490
+ const { autoHeight: _dropped, ...rest } = nextBox
2491
+ nextBox = rest
2492
+ }
2493
+ }
2494
+ if (nextBox !== null) autoBox.current = nextBox
2495
+ }
2496
+ const { resolved, deferred: stillDeferred } = resolveAnimateTargets(
2497
+ compatAnimate,
2498
+ lengthLayoutOf(),
2499
+ severity,
2500
+ report,
2501
+ )
2502
+ // Pending-origin pairing (review 73044cc0e1bd): an incoming RESOLVED key whose pending
2503
+ // entry holds a measured origin (an initial-only mount pending, or a paired mount
2504
+ // origin) never retargets from the temporary 0 seed. An origin that measures NOW starts
2505
+ // the pair immediately; one still needing the element box pairs into pending and waits
2506
+ // for the flush. A boundary-refused origin is reported and dropped (production) — the
2507
+ // target then rides the ordinary scalar lane.
2508
+ const deferredPairKeys = new Set<string>()
2509
+ const pairedStarts: Record<string, PropTarget> = {}
2510
+ for (const key of Object.keys(resolved)) {
2511
+ if (key in stillDeferred) continue
2512
+ const prior = pendingDeferredAnimate?.[key]
2513
+ if (prior?.from === undefined) continue
2514
+ try {
2515
+ const from = numericValue(key, prior.from, lengthLayoutOf())
2516
+ const to = resolved[key]!
2517
+ const perProp = perPropTransition(key, to, transition, motionPolicyOf())
2518
+ pairedStarts[key] = {
2519
+ to,
2520
+ from,
2521
+ ...(perProp === undefined ? {} : { transition: perProp }),
2522
+ }
2523
+ } catch (error) {
2524
+ if (isDeferrableElementMeasureError(error)) {
2525
+ deferredPairKeys.add(key)
2526
+ continue
2527
+ }
2528
+ if (!isMeasureResolveBoundaryError(error)) throw error
2529
+ if (severity === 'development') throw error
2530
+ report(error)
2531
+ }
2532
+ }
2533
+ // Deferred pending queue (majors 9044e8b70004 + 7c3a91e2b0d4):
2534
+ // - supersession: keys resolved in this update leave pending
2535
+ // - stillDeferred keys re-enter / stay
2536
+ // - partial updates (gesture/label): absence does NOT abandon other pending keys
2537
+ // - full declarative animate: rebuild from stillDeferred only (absent = abandoned)
2538
+ // Mount seeds pending; flush drains it. Transition OWNERSHIP is per entry (review
2539
+ // 93aeec05a9a8): this update's transition stamps only the keys IT defers or pairs —
2540
+ // and an update with NO authored transition stamps the explicit default (review
2541
+ // 7d395c90c954): supersession always transfers ownership, never retains the prior
2542
+ // application's transition.
2543
+ const stampTransition = (pair: PendingDeferredLengthTarget): PendingDeferredLengthTarget =>
2544
+ transition === undefined ? { ...pair, transition: null } : { ...pair, transition }
2545
+ const animateScope = next.animateScope ?? 'partial'
2546
+ if (animateScope === 'full') {
2547
+ const nextPending: PendingDeferredLengthTargets = {}
2548
+ for (const key of Object.keys(stillDeferred)) {
2549
+ nextPending[key] = stampTransition({
2550
+ ...pendingDeferredAnimate?.[key],
2551
+ to: stillDeferred[key],
2552
+ })
2553
+ }
2554
+ for (const key of deferredPairKeys) {
2555
+ nextPending[key] = stampTransition({
2556
+ to: (compatAnimate as Record<string, unknown>)[key],
2557
+ from: pendingDeferredAnimate![key]!.from,
2558
+ })
2559
+ }
2560
+ pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
2561
+ // Re-defer with a commanding update is post-mount — clear mount instant latch (and a
2562
+ // supersession / empty full snapshot drains pending without flush — same latch end).
2563
+ mountDeferredNeedsInstantFlush = false
2564
+ } else {
2565
+ const nextPending: PendingDeferredLengthTargets = {
2566
+ ...pendingDeferredAnimate,
2567
+ }
2568
+ for (const key of Object.keys(compatAnimate)) {
2569
+ if (key in stillDeferred) {
2570
+ nextPending[key] = stampTransition({
2571
+ ...pendingDeferredAnimate?.[key],
2572
+ to: stillDeferred[key],
2573
+ })
2574
+ } else if (deferredPairKeys.has(key)) {
2575
+ nextPending[key] = stampTransition({
2576
+ to: (compatAnimate as Record<string, unknown>)[key],
2577
+ from: pendingDeferredAnimate![key]!.from,
2578
+ })
2579
+ } else delete nextPending[key]
2580
+ }
2581
+ pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
2582
+ if (pendingDeferredAnimate === null) {
2583
+ pendingDeferredTransition = undefined
2584
+ mountDeferredNeedsInstantFlush = false
2585
+ }
2586
+ }
2587
+ const resolvedColors = resolveColors(compatAnimate)
2588
+ const resolvedDiscretes = resolveDiscretes(compatAnimate)
2589
+ // Diff RESOLVED values (M2.2 no-op invariant): scalars by ===, keyframe arrays ELEMENT-WISE
2590
+ // (sameTargetValue) so a new-but-equal array on an unrelated re-render issues no command.
2591
+ // Only changed keys ride a command (unlisted keys hold, REQ-API-002/012). A changed SCALAR
2592
+ // RETARGETS (velocity-continuous); a changed ARRAY re-STARTS — retarget is scalar-only, and a
2593
+ // keyframe array is an absolute sequence Motion restarts on change (R8 M2).
2594
+ const changedScalars: Record<string, number> = {}
2595
+ const changedArrayKeys: string[] = []
2596
+ // Context-only re-resolutions of an unchanged authored array: retarget the resting final
2597
+ // value instead of restarting (review 26cc511b0927) — filled by the array plan below.
2598
+ const responsiveFinalRetargets: Record<string, number> = {}
2599
+ for (const key of Object.keys(resolved)) {
2600
+ // Paired keys never ride the ordinary lanes: a deferred pair waits for the flush, an
2601
+ // already-measurable pair starts below from its measured origin — never the 0 seed.
2602
+ if (deferredPairKeys.has(key) || key in pairedStarts) continue
2603
+ const value = resolved[key]!
2604
+ const prev = current[key]
2605
+ // A removal episode is an execution edge, even when the declarative null-first target is
2606
+ // unchanged: it must restart from the one captured removal origin rather than continue an
2607
+ // older generator/velocity that presence is not measuring (REQ-PRESENCE-020).
2608
+ const removalHandoff =
2609
+ next.removalValues !== undefined &&
2610
+ Array.isArray(value) &&
2611
+ value[0] === null &&
2612
+ !handedOffRemovalKeys.has(key)
2613
+ const equalKeyframeReplay = next.restartEqualKeyframes === true && Array.isArray(value)
2614
+ if (
2615
+ prev !== undefined &&
2616
+ sameTargetValue(prev, value) &&
2617
+ !removalHandoff &&
2618
+ !equalKeyframeReplay
2619
+ )
2620
+ continue
2621
+ if (typeof value === 'number') changedScalars[key] = value
2622
+ else changedArrayKeys.push(key)
2623
+ }
2624
+ // Discrete diff base is the declared keyword (scalar-only lane): unchanged keyword ⇒ no
2625
+ // command (the no-op re-render discipline).
2626
+ const changedDiscreteKeys = Object.keys(resolvedDiscretes).filter(
2627
+ (key) => currentDiscreteCanonical[key] !== resolvedDiscretes[key],
2628
+ )
2629
+ const changedColorKeys = Object.keys(resolvedColors).filter(
2630
+ // Diff the WHOLE declared target canonical (major 37): a changed MIDDLE keyframe re-commands
2631
+ // even when the endpoints match. The no-op invariant still holds — an unrelated re-render
2632
+ // passing the same color/array canonicalizes identically ⇒ no command.
2633
+ (key) => {
2634
+ const target = resolvedColors[key]!
2635
+ return (
2636
+ (next.restartEqualKeyframes === true && isColorArrayTarget(target)) ||
2637
+ currentColorCanonical[key] !== colorTargetCanonical(target) ||
2638
+ (next.removalValues !== undefined &&
2639
+ isColorArrayTarget(target) &&
2640
+ target[0] === null &&
2641
+ !handedOffRemovalKeys.has(key))
2642
+ )
2643
+ },
2644
+ )
2645
+ if (
2646
+ Object.keys(changedScalars).length === 0 &&
2647
+ changedArrayKeys.length === 0 &&
2648
+ changedColorKeys.length === 0 &&
2649
+ changedDiscreteKeys.length === 0 &&
2650
+ Object.keys(pairedStarts).length === 0 &&
2651
+ Object.keys(responsiveFinalRetargets).length === 0
2652
+ )
2653
+ return false
2654
+ // (`transition` — the per-call transition, r9 major c7de6fd1030b; captured once above, major 51.)
2655
+ // The live committed value of a key, or a LOUD refusal if the key was never mounted (major 39:
2656
+ // the array lane silently invented 0 for an unmounted key; the animated set is fixed at mount).
2657
+ let committed: Readonly<Record<string, number>> | undefined
2658
+ const committedValueOf = (key: string): number => {
2659
+ committed ??= binding.driver.committed(h)
2660
+ const value = committed[key]
2661
+ if (value === undefined) {
2662
+ throw new Error(
2663
+ `L1: cannot update '${key}': it was not mounted on this element (the animated key set ` +
2664
+ 'is fixed at mount, REQ-API-002/012).',
2665
+ )
2666
+ }
2667
+ return value
2668
+ }
2669
+ const numericFromCurrent = (key: string): number => {
2670
+ if (next.removalValues === undefined) return committedValueOf(key)
2671
+ const value = next.removalValues[key]
2672
+ if (typeof value !== 'number') {
2673
+ throw new Error(
2674
+ `presence removal snapshot '${key}' must be numeric for a null-first numeric target ` +
2675
+ `(received ${typeof value}; REQ-PRESENCE-020).`,
2676
+ )
2677
+ }
2678
+ return value
2679
+ }
2680
+ const colorFromCurrent = (key: string, live: readonly RGBA[]): RGBA => {
2681
+ if (next.removalValues === undefined) {
2682
+ return parseColor(projectColorSequence(live, committedValueOf(key)))
2683
+ }
2684
+ const value = next.removalValues[key]
2685
+ if (typeof value !== 'string') {
2686
+ throw new Error(
2687
+ `presence removal snapshot '${key}' must be a color string for a null-first color target ` +
2688
+ `(received ${typeof value}; REQ-PRESENCE-020).`,
2689
+ )
2690
+ }
2691
+ return parseColor(value)
2692
+ }
2693
+ // BUILD every plan purely, up front, so the whole update is ATOMIC (major 36): the numeric-array
2694
+ // start + the color start are the driver's only FALLIBLE commands (an R8-F1 >2-keyframe explicit
2695
+ // spring). Pre-validating them BEFORE any command/seed means a rejection mutates NOTHING — no
2696
+ // scalar retarget, no endpoint seed, no `current` skew. A keyframe array re-STARTS (retarget is
2697
+ // scalar-only) from the live committed value: continuity for a null-first array; a non-null-first
2698
+ // array's tick-0 is keyframes[0] regardless. EXCEPTION (review 26cc511b0927): the SAME authored
2699
+ // array re-resolving against changed context is a responsive remeasure, not a replacement —
2700
+ // its resting final value RETARGETS (scalar lane, no first-keyframe replay).
2701
+ const arrayTargets: Record<string, PropTarget> = {}
2702
+ for (const key of changedArrayKeys) {
2703
+ const to = resolved[key]! as PropTargetTo
2704
+ const raw = (compatAnimate as Record<string, unknown>)[key]
2705
+ const lastRaw = lastCommandedRawKeyframes.get(key)
2706
+ // The removal handoff takes precedence over the responsive-retarget shortcut (review
2707
+ // f48c90b9ad72): the SAME already-commanded null-first relative array as the exit target
2708
+ // must restart from the captured REQ-PRESENCE-020 drop-time origin — never continue the
2709
+ // older live flight presence is not measuring.
2710
+ const removalHandoff =
2711
+ next.removalValues !== undefined &&
2712
+ Array.isArray(raw) &&
2713
+ raw[0] === null &&
2714
+ !handedOffRemovalKeys.has(key)
2715
+ if (
2716
+ !removalHandoff &&
2717
+ next.restartEqualKeyframes !== true &&
2718
+ lastRaw !== undefined &&
2719
+ sameRawKeyframes(lastRaw, raw) &&
2720
+ Array.isArray(raw) &&
2721
+ restsAtRelativeForm(raw) &&
2722
+ Array.isArray(to) &&
2723
+ typeof to[to.length - 1] === 'number'
2724
+ ) {
2725
+ responsiveFinalRetargets[key] = to[to.length - 1] as number
2726
+ lastCommandedRawKeyframes.set(key, raw)
2727
+ continue
2728
+ }
2729
+ const from =
2730
+ Array.isArray(to) && to[0] === null ? numericFromCurrent(key) : committedValueOf(key)
2731
+ const perProp = perPropTransition(key, to, transition, motionPolicyOf())
2732
+ arrayTargets[key] = { to, from, ...(perProp !== undefined ? { transition: perProp } : {}) }
2733
+ if (Array.isArray(raw)) lastCommandedRawKeyframes.set(key, raw)
2734
+ }
2735
+ // L1 §semantics 5, oracle-faithful: re-seed from the CURRENT DISPLAYED color (C0 — the
2736
+ // handoff frame is byte-continuous) through the live sequence and restart progress 0→100.
2737
+ // Velocity is not transferable across endpoint pairs for mixed values — motion's own mixed-value
2738
+ // interrupts restart the same way. Projection clamps/rounds overshot RGBA before it becomes the
2739
+ // next committed origin, so interruption starts from exactly the frame React Native displayed.
2740
+ const colorTargets: Record<string, PropTarget> = {}
2741
+ const pendingColorSeeds: {
2742
+ key: string
2743
+ declaredTarget: ColorTarget
2744
+ sequence: readonly RGBA[]
2745
+ }[] = []
2746
+ // The discrete mirror (T23 B2b): re-seed from the keyword DISPLAYED at the live progress
2747
+ // (projectDiscreteStep — the same seam the animated style binds) and restart 0→100.
2748
+ const discreteTargets: Record<string, PropTarget> = {}
2749
+ const pendingDiscreteSeeds: {
2750
+ key: string
2751
+ declaredTarget: string
2752
+ pair: readonly [string, string]
2753
+ }[] = []
2754
+ for (const key of changedDiscreteKeys) {
2755
+ const live = discretes[key]
2756
+ if (live === undefined) {
2757
+ // A mount-refused discrete start (T23 B2a2) is terminal for the key: the one
2758
+ // report fired at mount; retargets skip silently (the numeric refused-seed law).
2759
+ if (mountRefusedKeys.has(key)) continue
2760
+ throw new Error(`T23: discrete '${key}' has no registered pair on this element.`)
2761
+ }
2762
+ const target = resolvedDiscretes[key]!
2763
+ const liveKeyword = projectDiscreteStep(live[0], live[1], committedValueOf(key))
2764
+ const pair: readonly [string, string] = [liveKeyword, target]
2765
+ pendingDiscreteSeeds.push({
2766
+ key,
2767
+ declaredTarget: target,
2768
+ pair,
2769
+ })
2770
+ const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
2771
+ discreteTargets[key] = {
2772
+ to: PROGRESS_END,
2773
+ from: PROGRESS_START,
2774
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2775
+ }
2776
+ }
2777
+ for (const key of changedColorKeys) {
2778
+ const live = colors[key]
2779
+ if (live === undefined) {
2780
+ throw new Error(`L1: color '${key}' has no registered sequence on this element.`)
2781
+ }
2782
+ const target = resolvedColors[key]!
2783
+ const nullFirst = isColorArrayTarget(target) && target[0] === null
2784
+ const liveColor = nullFirst
2785
+ ? colorFromCurrent(key, live)
2786
+ : parseColor(projectColorSequence(live, committedValueOf(key)))
2787
+ pendingColorSeeds.push({
2788
+ key,
2789
+ declaredTarget: target,
2790
+ sequence: buildColorSequence(target, liveColor),
2791
+ })
2792
+ if (isColorArrayTarget(target)) {
2793
+ const progressTo = colorProgressArray(target.length)
2794
+ const perProp = perPropTransition(key, progressTo, transition, motionPolicyOf())
2795
+ colorTargets[key] = {
2796
+ to: progressTo,
2797
+ from: PROGRESS_START,
2798
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2799
+ }
2800
+ } else {
2801
+ // Mirror mount: scalar colors must carry perPropTransition so skipAnimations/isStatic
2802
+ // force INSTANT on retarget (REQ-API-035 law (d); review r2 major 4). Pre-fix this
2803
+ // branch emitted bare {to, from} and the color start rode the command-level timed tween.
2804
+ const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
2805
+ colorTargets[key] = {
2806
+ to: PROGRESS_END,
2807
+ from: PROGRESS_START,
2808
+ ...(perProp !== undefined ? { transition: perProp } : {}),
2809
+ }
2810
+ }
2811
+ }
2812
+ // Every changed SCALAR key must be MOUNTED too, proven BEFORE any command (review major 52): the
2813
+ // array/color lanes proved their keys in the build phase above (committedValueOf), but an unmounted
2814
+ // scalar was previously left to the driver's retarget — which threw AFTER the array start had
2815
+ // already committed (non-atomic). The fixed-at-mount set is the authority (a mounted-but-unstepped
2816
+ // key is absent from `committed`, so `committed` alone can't decide membership).
2817
+ for (const key of Object.keys(changedScalars)) {
2818
+ if (!registeredKeys.has(key)) {
2819
+ throw new Error(
2820
+ `L1: cannot update '${key}': it was not mounted on this element (the animated key set ` +
2821
+ 'is fixed at mount, REQ-API-002/012).',
2822
+ )
2823
+ }
2824
+ }
2825
+ // R8-F1-incompatible arrays were filtered up-front (compatAnimate) so neither arrayTargets nor
2826
+ // colorTargets can hold one — every command below is infallible (major 36 atomicity).
2827
+ // COMMIT: the color seeds publish before the color start.
2828
+ // REQ-API-035: retarget carries a single command-level transition (no per-key slot). Split the
2829
+ // batch when reducedMotion applies so transform keys force INSTANT while non-transforms keep the
2830
+ // timed transition (review r1 major 2: every(TRANSFORM) left mixed batches fully animated).
2831
+ // skipAnimations / isStatic force INSTANT on every key.
2832
+ const scalarKeys = Object.keys(changedScalars)
2833
+ const livePolicy = motionPolicyOf()
2834
+ const baseRetargetTransition = transition ?? {}
2835
+ // Paired starts carry their measured origin explicitly (review 73044cc0e1bd) — they join
2836
+ // the start command rather than retargeting from the temporary seed.
2837
+ const startTargets: Record<string, PropTarget> = { ...pairedStarts, ...arrayTargets }
2838
+ if (Object.keys(startTargets).length > 0) {
2839
+ commandThroughSeverity(h, {
2840
+ kind: 'start',
2841
+ targets: startTargets,
2842
+ transition: transition ?? {},
2843
+ })
2844
+ }
2845
+ // Responsive remeasure (review 26cc511b0927): the SAME authored array re-resolved against
2846
+ // changed context — retarget the resting final value, never a sequence restart from the
2847
+ // first keyframe. The retarget rides the SAME livePolicy law as the scalar lane (review
2848
+ // 5c9810960d1a): skipAnimations/isStatic force INSTANT everywhere; shouldReduceMotion
2849
+ // forces INSTANT on transform keys and keeps the timed transition elsewhere.
2850
+ const responsiveKeys = Object.keys(responsiveFinalRetargets)
2851
+ if (responsiveKeys.length > 0) {
2852
+ if (livePolicy.skipAnimations) {
2853
+ commandThroughSeverity(h, {
2854
+ kind: 'retarget',
2855
+ targets: responsiveFinalRetargets,
2856
+ transition: INSTANT_TRANSITION,
2857
+ })
2858
+ } else if (livePolicy.shouldReduceMotion) {
2859
+ const transformTargets: Record<string, number> = {}
2860
+ const nonTransformTargets: Record<string, number> = {}
2861
+ for (const key of responsiveKeys) {
2862
+ if (TRANSFORM_KEY_SET.has(key)) transformTargets[key] = responsiveFinalRetargets[key]!
2863
+ else nonTransformTargets[key] = responsiveFinalRetargets[key]!
2864
+ }
2865
+ if (Object.keys(transformTargets).length > 0) {
2866
+ commandThroughSeverity(h, {
2867
+ kind: 'retarget',
2868
+ targets: transformTargets,
2869
+ transition: INSTANT_TRANSITION,
2870
+ })
2871
+ }
2872
+ if (Object.keys(nonTransformTargets).length > 0) {
2873
+ commandThroughSeverity(h, {
2874
+ kind: 'retarget',
2875
+ targets: nonTransformTargets,
2876
+ transition: baseRetargetTransition,
2877
+ })
2878
+ }
2879
+ } else {
2880
+ commandThroughSeverity(h, {
2881
+ kind: 'retarget',
2882
+ targets: responsiveFinalRetargets,
2883
+ transition: baseRetargetTransition,
2884
+ })
2885
+ }
2886
+ }
2887
+ if (scalarKeys.length > 0) {
2888
+ if (livePolicy.skipAnimations) {
2889
+ commandThroughSeverity(h, {
2890
+ kind: 'retarget',
2891
+ targets: changedScalars,
2892
+ transition: INSTANT_TRANSITION,
2893
+ })
2894
+ } else if (livePolicy.shouldReduceMotion) {
2895
+ const transformTargets: Record<string, number> = {}
2896
+ const nonTransformTargets: Record<string, number> = {}
2897
+ for (const key of scalarKeys) {
2898
+ if (TRANSFORM_KEY_SET.has(key)) transformTargets[key] = changedScalars[key]!
2899
+ else nonTransformTargets[key] = changedScalars[key]!
2900
+ }
2901
+ if (Object.keys(transformTargets).length > 0) {
2902
+ commandThroughSeverity(h, {
2903
+ kind: 'retarget',
2904
+ targets: transformTargets,
2905
+ transition: INSTANT_TRANSITION,
2906
+ })
2907
+ }
2908
+ if (Object.keys(nonTransformTargets).length > 0) {
2909
+ commandThroughSeverity(h, {
2910
+ kind: 'retarget',
2911
+ targets: nonTransformTargets,
2912
+ transition: baseRetargetTransition,
2913
+ })
2914
+ }
2915
+ } else {
2916
+ commandThroughSeverity(h, {
2917
+ kind: 'retarget',
2918
+ targets: changedScalars,
2919
+ transition: baseRetargetTransition,
2920
+ })
2921
+ }
2922
+ }
2923
+ if (Object.keys(colorTargets).length > 0 || Object.keys(discreteTargets).length > 0) {
2924
+ for (const { key, declaredTarget, sequence } of pendingColorSeeds) {
2925
+ seed(key, declaredTarget, sequence)
2926
+ }
2927
+ for (const { key, declaredTarget, pair } of pendingDiscreteSeeds) {
2928
+ seedDiscrete(key, declaredTarget, pair)
2929
+ }
2930
+ // REQ-API-035: skipAnimations/isStatic force INSTANT on every key — the color start's
2931
+ // command-level transition is the fallback when a target omits per-prop (drivers read
2932
+ // target.transition ?? command.transition). Per-prop already covers array colors via
2933
+ // perPropTransition above; under skipAnimations the command slot is INSTANT too so no
2934
+ // color key can fall back to a timed tween (review r2 major 4).
2935
+ commandThroughSeverity(h, {
2936
+ kind: 'start',
2937
+ targets: { ...colorTargets, ...discreteTargets },
2938
+ transition: livePolicy.skipAnimations ? INSTANT_TRANSITION : (transition ?? {}),
2939
+ })
2940
+ }
2941
+ if (next.removalValues !== undefined) {
2942
+ for (const key of changedArrayKeys) {
2943
+ const target = resolved[key]
2944
+ if (Array.isArray(target) && target[0] === null) handedOffRemovalKeys.add(key)
2945
+ }
2946
+ for (const key of changedColorKeys) {
2947
+ const target = resolvedColors[key]
2948
+ if (target !== undefined && isColorArrayTarget(target) && target[0] === null) {
2949
+ handedOffRemovalKeys.add(key)
2950
+ }
2951
+ }
2952
+ }
2953
+ // The diff base records COMMANDED values only: a deferred pair (review 73044cc0e1bd) is
2954
+ // not applied yet — the flush publishes its `current` when it commands the pair.
2955
+ // Resting-form membership (review 1578a42974bc): a key rests at auto only when its FINAL
2956
+ // authored keyframe is auto — an auto MIDDLE keyframe is a measurement episode, not the
2957
+ // resting form. remeasureAutoRests re-seeds the released keys on content drift so the next
2958
+ // edge originates from the drifted content, never a stale measure. A commanded key leaves
2959
+ // the initial-only relative-rest lane too (review 7cec8ad963d8): its owning animate target
2960
+ // re-resolves it through the ordinary update lane.
2961
+ for (const key of Object.keys(startTargets)) {
2962
+ if (restsAtAutoForm((compatAnimate as Record<string, unknown>)[key])) {
2963
+ autoRestKeys.add(key)
2964
+ } else {
2965
+ autoRestKeys.delete(key)
2966
+ }
2967
+ relativeRestForms.delete(key)
2968
+ // Non-array authority took the key (review b327265f3dc5): a later identical array is a
2969
+ // NEW authored episode (restart), never a context-only re-resolution of the old one.
2970
+ if (!Array.isArray((compatAnimate as Record<string, unknown>)[key])) {
2971
+ lastCommandedRawKeyframes.delete(key)
2972
+ }
2973
+ }
2974
+ for (const key of scalarKeys) {
2975
+ if (restsAtAutoForm((compatAnimate as Record<string, unknown>)[key])) {
2976
+ autoRestKeys.add(key)
2977
+ } else {
2978
+ autoRestKeys.delete(key)
2979
+ }
2980
+ relativeRestForms.delete(key)
2981
+ lastCommandedRawKeyframes.delete(key)
2982
+ }
2983
+ const nextCurrent = { ...current }
2984
+ for (const key of Object.keys(resolved)) {
2985
+ if (!deferredPairKeys.has(key)) nextCurrent[key] = resolved[key]!
2986
+ }
2987
+ current = nextCurrent
2988
+ return true
2989
+ },
2990
+
2991
+ applyTransitionEnd(target) {
2992
+ const h = requireHandle()
2993
+ // BUILD every jump first (the major-36 atomicity shape): a refused key mutates nothing.
2994
+ const numericJumps: Record<string, number> = {}
2995
+ const discreteJumps: { key: string; keyword: string }[] = []
2996
+ const colorJumps: { key: string; color: RGBA }[] = []
2997
+ for (const key of Object.keys(target)) {
2998
+ if (!registeredKeys.has(key)) {
2999
+ throw new Error(
3000
+ `T23: cannot apply transitionEnd '${key}': it was not mounted on this element ` +
3001
+ '(the animated key set is fixed at mount, REQ-API-002/012).',
3002
+ )
3003
+ }
3004
+ const value = target[key]
3005
+ if (isDiscreteKey(key)) {
3006
+ discreteJumps.push({ key, keyword: discreteValue(key, value) })
3007
+ } else if (isColorKey(key)) {
3008
+ colorJumps.push({ key, color: colorValue(key, value) })
3009
+ } else {
3010
+ const resolved = numericTargetValue(key, value, lengthLayoutOf())
3011
+ if (typeof resolved !== 'number') {
3012
+ throw new Error(
3013
+ `T23: transitionEnd '${key}' must jump to a single numeric value ` +
3014
+ '(keyframe arrays cannot jump).',
3015
+ )
3016
+ }
3017
+ numericJumps[key] = resolved
3018
+ }
3019
+ }
3020
+ // COMMIT: progress lanes re-seed settled (constant projection at the jumped value) and
3021
+ // start INSTANT at PROGRESS_END; numerics ride one INSTANT retarget. Diff bases move so
3022
+ // a later identical animate is a no-op.
3023
+ const progressTargets: Record<string, PropTarget> = {}
3024
+ for (const { key, keyword } of discreteJumps) {
3025
+ seedDiscrete(key, keyword, [keyword, keyword])
3026
+ progressTargets[key] = { to: PROGRESS_END, from: PROGRESS_END }
3027
+ }
3028
+ for (const { key, color } of colorJumps) {
3029
+ seed(key, color, [color, color])
3030
+ progressTargets[key] = { to: PROGRESS_END, from: PROGRESS_END }
3031
+ }
3032
+ if (Object.keys(progressTargets).length > 0) {
3033
+ commandThroughSeverity(h, {
3034
+ kind: 'start',
3035
+ targets: progressTargets,
3036
+ transition: INSTANT_TRANSITION,
3037
+ })
3038
+ }
3039
+ if (Object.keys(numericJumps).length > 0) {
3040
+ commandThroughSeverity(h, {
3041
+ kind: 'retarget',
3042
+ targets: numericJumps,
3043
+ transition: INSTANT_TRANSITION,
3044
+ })
3045
+ current = { ...current, ...numericJumps }
3046
+ }
3047
+ },
3048
+
3049
+ currentColor(key: string): string {
3050
+ // The deterministic observable of L1 §semantics 3: the SAME projectColorSequence seam the
3051
+ // animated style binds, evaluated at the driver's live progress (major 35 — projects THROUGH
3052
+ // the sequence; a scalar color is the 2-element endpoint case).
3053
+ const h = requireHandle()
3054
+ const c = colors[key]
3055
+ if (c === undefined) {
3056
+ throw new Error(
3057
+ `L1: '${key}' is not a color key mounted on this element (specs/L1-BUILD-PACKET.md).`,
3058
+ )
3059
+ }
3060
+ const progress = binding.driver.committed(h)[key]
3061
+ if (progress === undefined) {
3062
+ throw new Error(`L1: color '${key}' has no registered driver prop on this element.`)
3063
+ }
3064
+ return projectColorSequence(c, progress)
3065
+ },
3066
+
3067
+ currentDiscrete(key: string): string {
3068
+ // The discrete deterministic observable (T23 B2b): the SAME projectDiscreteStep seam the
3069
+ // animated style binds, evaluated at the driver's live progress.
3070
+ const h = requireHandle()
3071
+ const pair = discretes[key]
3072
+ if (pair === undefined) {
3073
+ throw new Error(`T23: '${key}' is not a discrete key mounted on this element.`)
3074
+ }
3075
+ const progress = binding.driver.committed(h)[key]
3076
+ if (progress === undefined) {
3077
+ throw new Error(`T23: discrete '${key}' has no registered driver prop on this element.`)
3078
+ }
3079
+ return projectDiscreteStep(pair[0], pair[1], progress)
3080
+ },
3081
+
3082
+ projectLatestValues(raw) {
3083
+ // T23 C: the onUpdate projection — the driver marshal crosses the RAW committed record
3084
+ // (numerics as-is; color/discrete lanes as [0,100] progress). Project each progress lane
3085
+ // through the SAME seam its observable binds (currentColor / currentDiscrete — one truth
3086
+ // per family); numeric keys pass through untouched. Pure JS, no UI crossing: the record
3087
+ // already crossed with the marshal.
3088
+ const out: Record<string, string | number> = {}
3089
+ for (const key of Object.keys(raw)) {
3090
+ const value = raw[key]!
3091
+ const colorSequence = colors[key]
3092
+ if (colorSequence !== undefined) {
3093
+ out[key] = projectColorSequence(colorSequence, value)
3094
+ continue
3095
+ }
3096
+ const pair = discretes[key]
3097
+ if (pair !== undefined) {
3098
+ out[key] = projectDiscreteStep(pair[0], pair[1], value)
3099
+ continue
3100
+ }
3101
+ out[key] = value
3102
+ }
3103
+ return out
3104
+ },
3105
+
3106
+ committedValues(keys) {
3107
+ const committed = binding.driver.committed(requireHandle())
3108
+ const snapshot: Record<string, ResolvedValue> = {}
3109
+ for (const key of keys) {
3110
+ if (!registeredKeys.has(key)) {
3111
+ throw new Error(
3112
+ `cannot snapshot committed '${key}': it was not registered on this MotionView controller.`,
3113
+ )
3114
+ }
3115
+ const color = colors[key]
3116
+ if (color !== undefined) {
3117
+ const progress = committed[key]
3118
+ if (progress === undefined) {
3119
+ throw new Error(`L1: color '${key}' has no registered driver prop on this element.`)
3120
+ }
3121
+ snapshot[key] = projectColorSequence(color, progress)
3122
+ continue
3123
+ }
3124
+ const pair = discretes[key]
3125
+ if (pair !== undefined) {
3126
+ const progress = committed[key]
3127
+ if (progress === undefined) {
3128
+ throw new Error(`T23: discrete '${key}' has no registered driver prop on this element.`)
3129
+ }
3130
+ snapshot[key] = projectDiscreteStep(pair[0], pair[1], progress)
3131
+ continue
3132
+ }
3133
+ const value = committed[key]
3134
+ if (value === undefined) {
3135
+ throw new Error(
3136
+ `cannot snapshot committed '${key}': it is registered on the MotionView controller ` +
3137
+ 'but absent from the driver.',
3138
+ )
3139
+ }
3140
+ snapshot[key] = value
3141
+ }
3142
+ return Object.freeze(snapshot)
3143
+ },
3144
+
3145
+ variantValueState() {
3146
+ if (unmounted) {
3147
+ throw new Error('MotionView controller cannot read variant value state after unmount().')
3148
+ }
3149
+ const h = requireHandle()
3150
+ const current: Record<string, ResolvedValue> = {}
3151
+ const velocity: Record<string, number> = {}
3152
+ for (const key of registeredKeys) {
3153
+ const live = binding.driver.liveFor(h, key)
3154
+ const color = colors[key]
3155
+ if (color !== undefined) {
3156
+ current[key] = projectColorSequence(color, live.value)
3157
+ velocity[key] = 0
3158
+ continue
3159
+ }
3160
+ const pair = discretes[key]
3161
+ if (pair !== undefined) {
3162
+ current[key] = projectDiscreteStep(pair[0], pair[1], live.value)
3163
+ velocity[key] = 0
3164
+ continue
3165
+ }
3166
+ current[key] = live.value
3167
+ velocity[key] = live.velocity
3168
+ }
3169
+ return { current: Object.freeze(current), velocity: Object.freeze(velocity) }
3170
+ },
3171
+
3172
+ unmount() {
3173
+ binding.driver.setActive(requireHandle(), false)
3174
+ unmounted = true
3175
+ },
3176
+ }
3177
+ }
3178
+
3179
+ // Re-exported so the checks screen asserts the exact error class the pipeline throws.
3180
+ export { InvalidTargetError }