@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,1168 @@
1
+ // REQ-API-020/-022/-041 + REQ-GESTURE-022/-024 composition (FLAG 5a + R15 D1,
2
+ // specs/SPEC-GESTURE.md) — the drag gesture WIRING: the pure orchestrator MotionView plugs into
3
+ // Gesture.Pan(). It owns the protocol between the recognizer, the per-axis handoff bindings, and
4
+ // the abstract Driver:
5
+ //
6
+ // grab → ONE live-read per axis (REQ-DRIVER-025) + ONE interrupting write at the live values
7
+ // (REQ-DRIVER-024; a grab IS the interruption, REQ-GESTURE-012), sessions seeded C0
8
+ // update → per-axis session-constrained values → ONE write per recognizer event
9
+ // (REQ-GESTURE-002; REQ-GESTURE-024 per-axis channels, one recognizer lifecycle)
10
+ // release → ONE velocity-seeded `start` whose targets cover EVERY dragged axis
11
+ // (REQ-DRIVER-023; C0/C1 per REQ-GESTURE-013), spring = core's shared release config
12
+ // cancel → the same command shape with the handoffs' zero-velocity pre-grab return
13
+ //
14
+ // Nested drags ride Motion's global drag lock (REQ-GESTURE-024): a non-propagating element
15
+ // acquires the lock at grab and holds it until finalize — a second element's grab is swallowed
16
+ // whole (no read, no write, no callback); `dragPropagation` opts out both ways. Pure over the
17
+ // Driver contract so the reference driver proves the exact protocol the worklet driver receives
18
+ // on device. Callbacks carry Motion's (event, info) shape: DragInfo mirrors motion's PanInfo
19
+ // ({point, delta, offset, velocity}, each 2D) built from the recognizer's own fields —
20
+ // informational payloads validate fail-loud like every other recognizer read.
21
+
22
+ import {
23
+ DEFAULT_DRAG_ELASTIC,
24
+ describeValue,
25
+ DRAG_INERTIA_DEFAULTS,
26
+ dragInertiaBounce,
27
+ GESTURE_RELEASE_SPRING,
28
+ progressWithinSpan,
29
+ resolveDirectionLockAxis,
30
+ resolveViewportConstraints,
31
+ resolvedDragAxes,
32
+ validateMeasureReplacementBox,
33
+ type AxisSpan,
34
+ type DragAxis,
35
+ type DragInfo,
36
+ type Driver,
37
+ type ElementHandle,
38
+ type GestureHandoffSession,
39
+ type MeasureDragConstraintsCallback,
40
+ type PropTarget,
41
+ type PropTransition,
42
+ type ReleaseHandoff,
43
+ type ResolvedDragConfig,
44
+ type Transition,
45
+ } from '@unrulysystems/native-motion-core'
46
+ import {
47
+ createDragHandoffBinding,
48
+ type DragHandoffBinding,
49
+ type DragPanEventShape,
50
+ } from './dragHandoffBinding'
51
+ import { sharedDragPropagationLock, type DragPropagationLock } from './dragPropagationLock'
52
+ import { wrapDriverForExternalDragValues, type ExternalDragValues } from './externalDragDriver'
53
+
54
+ export type { ExternalDragValues }
55
+
56
+ // `DragInfo` (Motion's PanInfo shape) is the shared core type so the (event, info) callback
57
+ // payload is identical cross-engine (REQ-API-020); built here natively from the recognizer's
58
+ // absolute/translation/velocity fields.
59
+ //
60
+ // The native event object is the recognizer's own payload (the platform-faithful analog of
61
+ // motion's DOM PointerEvent first argument — a recorded platform difference, not a divergence in
62
+ // the info shape).
63
+ export type DragEventCallback = (event: unknown, info: DragInfo) => void
64
+
65
+ export interface DragCallbacks {
66
+ readonly onDragStart?: DragEventCallback | undefined
67
+ readonly onDrag?: DragEventCallback | undefined
68
+ readonly onDragEnd?: DragEventCallback | undefined
69
+ // REQ-API-042 (R15 D2): the pin's measure hook — receives the measured viewport constraints
70
+ // at every grab (layout-relative, per the pin's BoundingBox) and may return a mutated box,
71
+ // which replaces the measured one for that grab. LATEST like every callback.
72
+ readonly onMeasureDragConstraints?: MeasureDragConstraintsCallback | undefined
73
+ // R15 D4 (REQ-API-044 / REQ-GESTURE-025): fires once when the direction lock is acquired.
74
+ readonly onDirectionLock?: ((axis: DragAxis) => void) | undefined
75
+ }
76
+
77
+ // The measured boxes the grab-time seam returns: the draggable's CURRENT layout frame and the
78
+ // constraint element's frame, already in the same coordinate space (window coords).
79
+ export interface ConstraintsMeasureBoxes {
80
+ readonly draggableBox: { readonly x: AxisSpan; readonly y: AxisSpan }
81
+ readonly containerBox: { readonly x: AxisSpan; readonly y: AxisSpan }
82
+ }
83
+
84
+ // The grab-time measurement seam (REQ-GESTURE-028): MotionView supplies a closure that
85
+ // measures ITS host (the draggable) and the constraint element via measureInWindow (async —
86
+ // the pin's synchronous page-box read has no native analog). The ref wrapper is passed so the
87
+ // seam resolves `.current` at the measure instant, never earlier.
88
+ export type ConstraintsMeasure = (container: {
89
+ readonly current: unknown
90
+ }) => Promise<ConstraintsMeasureBoxes>
91
+
92
+ export interface DragGestureWiringOptions {
93
+ readonly driver: Driver
94
+ readonly handle: ElementHandle
95
+ readonly config: ResolvedDragConfig
96
+ // U7f: latest resolved config at grab (pin getProps per session). When omitted, `config`
97
+ // is the session. Empty axes (`drag={false}`) no-op the grab.
98
+ readonly getConfig?: (() => ResolvedDragConfig) | undefined
99
+ readonly callbacks?: DragCallbacks
100
+ // REQ-API-041: default false — this element acquires the pin's per-axis global drag lock at
101
+ // grab; true skips the lock for itself (never acquires, never blocked).
102
+ readonly dragPropagation?: boolean | undefined
103
+ // The lock scope: app-global by default (the pin's module-global analog — review
104
+ // 97bf73b720a8, never a per-root scope); tests inject their own.
105
+ readonly lock?: DragPropagationLock | undefined
106
+ // REQ-GESTURE-028: REQUIRED when any axis carries constraintsRef (a host wiring bug without
107
+ // it, fail-loud at grab). The grab-time measure seam.
108
+ readonly measureConstraints?: ConstraintsMeasure | undefined
109
+ // The deferred-begin failure channel (REQ-GESTURE-028): an unmounted/unmeasurable container
110
+ // or a snap-vs-measured-bounds contradiction reports here — severity-adapted by the caller
111
+ // (review r4 major 5): MotionView's DEVELOPMENT adapter throws the typed error, which rides
112
+ // this async delivery as an unhandled rejection — the dev crash surface; production reports
113
+ // and refuses that grab, never a half-started session.
114
+ readonly reportConstraintFailure?: ((error: Error) => void) | undefined
115
+ // REQ-GESTURE-028 (review major 3): the mid-drag re-measure loop — while a ref-constrained
116
+ // drag is ACTIVE, re-measure the boxes per frame tick and re-resolve constraints + rescale
117
+ // the position on change (the pin's ResizeObserver + window-resize path,
118
+ // scalePositionWithinConstraints). When absent, the mid-drag re-measure simply never runs
119
+ // (the per-grab law still holds); MotionView injects the platform frame ticker, tests a
120
+ // manual one. Repeating: the callback fires once per frame until the returned cancel runs.
121
+ readonly frameTicker?: ((callback: () => void) => () => void) | undefined
122
+ /**
123
+ * R15 D5 (REQ-API-046): external `_dragX`/`_dragY` — when set for an axis, session live
124
+ * reads/writes/release land on the MotionValue instead of the host transform channel.
125
+ */
126
+ readonly externalDragValues?: ExternalDragValues | undefined
127
+ }
128
+
129
+ // The handlers MotionView binds onto Gesture.Pan() (runOnJS(true), the ratified JS routing).
130
+ export interface DragGestureWiring {
131
+ handlePanStart(event: DragPanEventShape): void
132
+ handlePanUpdate(event: DragPanEventShape): void
133
+ handlePanEnd(event: DragPanEventShape, success: boolean): void
134
+ handlePanFinalize(event: DragPanEventShape, success: boolean): void
135
+ /**
136
+ * R15 D3 (REQ-GESTURE-026): pin snapToCursor — re-seat each dragged axis so the pointer is at
137
+ * the element center. `layoutBox` is the VISUAL frame in page/window space (measure includes
138
+ * the live transform); formula `value = point − midpoint + current` (pin VisualElementDragControls
139
+ * snapToCursor — sanctioned G-INV-2 jump at start-invocation only).
140
+ */
141
+ snapToCursor(
142
+ point: { readonly absoluteX: number; readonly absoluteY: number },
143
+ layoutBox: {
144
+ readonly x: number
145
+ readonly y: number
146
+ readonly width: number
147
+ readonly height: number
148
+ },
149
+ ): void
150
+ // UNMOUNT teardown (review 1d6e5b08344c): RNGH drops the recognizer's handlers on unmount
151
+ // without dispatching a JS finalize, so an active drag whose host unmounts would hold the
152
+ // app-global lock forever — every later non-propagating drag in the tree blocked. MotionView
153
+ // calls this from its unmount cleanup: the lock releases and every later recognizer event
154
+ // (a handler can briefly outlive its host) no-ops. The session itself dies with the host —
155
+ // no cancel command is issued against a dead handle.
156
+ dispose(): void
157
+ // The per-axis handoff sessions, exposed for the verification seam (state assertions) — the
158
+ // wiring owns their lifecycle; consumers never call them directly.
159
+ readonly sessions: Readonly<Partial<Record<DragAxis, GestureHandoffSession>>>
160
+ }
161
+
162
+ // The release spring is core's shared constant lifted into the public Transition shape — one
163
+ // config, never a duplicated literal. R15 D5 (REQ-API-045): dragTransition may override
164
+ // bounceStiffness/bounceDamping (pin inertia → spring mapping, curve-parity law).
165
+ function releaseTransitionFor(config: ResolvedDragConfig): Transition {
166
+ const t = config.dragTransition
167
+ return {
168
+ type: 'spring',
169
+ stiffness: t?.bounceStiffness ?? GESTURE_RELEASE_SPRING.stiffness,
170
+ damping: t?.bounceDamping ?? GESTURE_RELEASE_SPRING.damping,
171
+ }
172
+ }
173
+
174
+ // R16 (REQ-GESTURE-023/-027): the free-drag release's per-target inertia descriptor — the pin's
175
+ // drag-layer physics verbatim (timeConstant 750, power 0.8, restDelta 1, restSpeed 10), the
176
+ // elastic-conditional boundary-spring pair (dragElastic truthy → 200/40, falsy → 1e6/1e7) with
177
+ // `dragTransition` bounce overrides feeding the BOUNDARY spring (their pin meaning; the D5
178
+ // spring-mapping is superseded), and the grab-time bounds for the walls.
179
+ function inertiaTransitionFor(
180
+ config: ResolvedDragConfig,
181
+ axis: DragAxis,
182
+ handoff: ReleaseHandoff & { readonly curve: 'inertia' },
183
+ ): PropTransition {
184
+ const axisConfig = config[axis]
185
+ // The pin selects the bounce pair from the ELEMENT dragElastic (default 0.35 truthy → 200/40);
186
+ // the resolved per-constraint elastic carries it when constraints exist.
187
+ const elastic =
188
+ axisConfig?.constraints?.elastic ?? axisConfig?.constraintsRef?.elastic ?? DEFAULT_DRAG_ELASTIC
189
+ const bounce = dragInertiaBounce(elastic)
190
+ const t = config.dragTransition
191
+ return {
192
+ type: 'inertia',
193
+ power: DRAG_INERTIA_DEFAULTS.power,
194
+ timeConstant: DRAG_INERTIA_DEFAULTS.timeConstant,
195
+ bounceStiffness: t?.bounceStiffness ?? bounce.bounceStiffness,
196
+ bounceDamping: t?.bounceDamping ?? bounce.bounceDamping,
197
+ restDelta: DRAG_INERTIA_DEFAULTS.restDelta,
198
+ restSpeed: DRAG_INERTIA_DEFAULTS.restSpeed,
199
+ ...(handoff.min !== undefined ? { min: handoff.min } : {}),
200
+ ...(handoff.max !== undefined ? { max: handoff.max } : {}),
201
+ }
202
+ }
203
+
204
+ function snapToOriginApplies(config: ResolvedDragConfig, axis: DragAxis): boolean {
205
+ const s = config.dragSnapToOrigin
206
+ if (s === true) return true
207
+ if (s === false) return false
208
+ return s === axis
209
+ }
210
+
211
+ // REQ-GESTURE-028 (review r4 minor 7) — the deferred-begin's two bounds. The async measure
212
+ // must never retain the wiring forever: a native measureInWindow callback that never arrives
213
+ // would pin the lock and every buffered event indefinitely. measureInWindow lands in 1–2
214
+ // frames (~16–33ms); 1000ms is generous bounded headroom before the grab/poll fails loud.
215
+ const MEASURE_TIMEOUT_MS = 1000
216
+ // The pending grab's buffered updates are capped: a measure pending longer than 64 recognizer
217
+ // events is pathological — refuse the grab (fail-loud) rather than retain events indefinitely.
218
+ const MAX_BUFFERED_PAN_UPDATES = 64
219
+
220
+ function measureWithTimeout(
221
+ measure: ConstraintsMeasure,
222
+ ref: { readonly current: unknown },
223
+ ): Promise<ConstraintsMeasureBoxes> {
224
+ let timer: ReturnType<typeof setTimeout> | undefined
225
+ const racing = new Promise<ConstraintsMeasureBoxes>((_resolve, reject) => {
226
+ timer = setTimeout(() => {
227
+ reject(
228
+ new Error(
229
+ `dragConstraints measure timed out after ${MEASURE_TIMEOUT_MS}ms — the native ` +
230
+ 'measureInWindow callback never arrived (REQ-GESTURE-028; the bounded-wait law).',
231
+ ),
232
+ )
233
+ }, MEASURE_TIMEOUT_MS)
234
+ })
235
+ return Promise.race([
236
+ measure(ref).finally(() => {
237
+ if (timer !== undefined) clearTimeout(timer)
238
+ }),
239
+ racing,
240
+ ])
241
+ }
242
+
243
+ // Same fail-loud field law as the binding: info payloads read the recognizer's 2D fields and a
244
+ // missing field is a platform/caller bug named at the boundary — never coerced.
245
+ function requireInfoNumber(value: unknown, field: string): number {
246
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
247
+ // describeValue (core, single-sourced) is a SAFE formatter (review r4 minor 844ee5e): a BigInt
248
+ // recognizer field must produce this typed error naming the field, never a raw JSON.stringify
249
+ // serialization TypeError.
250
+ throw new Error(
251
+ `drag wiring: Pan event field '${field}' is ${describeValue(value)} — a finite number ` +
252
+ 'is required for the Motion (event, info) callback payload (REQ-API-020).',
253
+ )
254
+ }
255
+ return value
256
+ }
257
+
258
+ interface InfoEventShape extends DragPanEventShape {
259
+ readonly absoluteX?: unknown
260
+ readonly absoluteY?: unknown
261
+ }
262
+
263
+ // Where this element stands with Motion's global drag lock across one recognizer stream:
264
+ // 'open' (no grab yet, or propagating), 'acquired' (we hold it until finalize), 'blocked'
265
+ // (another element holds it — our whole stream stays inert).
266
+ type LockState = 'open' | 'acquired' | 'blocked'
267
+
268
+ export function createDragGestureWiring(options: DragGestureWiringOptions): DragGestureWiring {
269
+ const handle = options.handle
270
+ const getConfig = options.getConfig ?? (() => options.config)
271
+ let config = options.config
272
+ // R15 D5: optional external-value driver wrap (REQ-API-046) — all live/write/command sites
273
+ // below use this driver so external axes never touch the host transform channel.
274
+ const driver =
275
+ options.externalDragValues !== undefined
276
+ ? wrapDriverForExternalDragValues(options.driver, handle, options.externalDragValues)
277
+ : options.driver
278
+ const callbacks = options.callbacks ?? {}
279
+ let dragPropagation = options.dragPropagation ?? config.dragPropagation
280
+ const lock = options.lock ?? sharedDragPropagationLock
281
+ // One binding per dragged axis (REQ-GESTURE-024: independent per-axis channels sharing this
282
+ // ONE recognizer lifecycle). Single-axis drags get exactly one binding — no forked path.
283
+ // U7f: `drag={false}` is zero-axis; bindings install at grab from the live session config.
284
+ let axes = resolvedDragAxes(config)
285
+ const bindings: Partial<Record<DragAxis, DragHandoffBinding>> = {}
286
+ const sessions: Partial<Record<DragAxis, GestureHandoffSession>> = {}
287
+ const installBindings = (next: ResolvedDragConfig): readonly DragAxis[] => {
288
+ const nextAxes = resolvedDragAxes(next)
289
+ config = next
290
+ axes = nextAxes
291
+ dragPropagation = options.dragPropagation ?? next.dragPropagation
292
+ for (const axis of ['x', 'y'] as const) {
293
+ delete bindings[axis]
294
+ delete sessions[axis]
295
+ }
296
+ for (const axis of nextAxes) {
297
+ const binding = createDragHandoffBinding(axis, next[axis]!, next.dragMomentum)
298
+ bindings[axis] = binding
299
+ sessions[axis] = binding.session
300
+ }
301
+ return nextAxes
302
+ }
303
+ if (axes.length > 0) {
304
+ installBindings(config)
305
+ }
306
+ // The lock owner's identity: this wiring (one per mounted drag element). A dedicated token —
307
+ // never the handle (driver-owned) or the callbacks (consumer-owned).
308
+ const lockOwner: object = {}
309
+ let lockState: LockState = 'open'
310
+ // Set by dispose() (unmount): late recognizer events no-op and the held lock is freed.
311
+ let disposed = false
312
+ // Per-gesture delta base: the previous event's point; null between gestures.
313
+ let lastPoint: { x: number; y: number } | null = null
314
+ // Absolute prop values at grab (per axis) — DragInfo.offset is constrained session value minus
315
+ // these seeds (element drag displacement), not raw recognizer translation.
316
+ const grabSeeds: Partial<Record<DragAxis, number>> = {}
317
+ // R15 D4 (REQ-GESTURE-025): session-scoped direction lock; null until threshold crossed.
318
+ let lockedAxis: DragAxis | null = null
319
+ let directionLockNotified = false
320
+ // The deferred-begin state (REQ-GESTURE-028): a grab carrying element-ref constraints holds
321
+ // its full validated start event + buffered updates while the grab-time measure is in flight
322
+ // (native measureInWindow is async; the value holds C0 until the measured bounds land).
323
+ interface PendingDeferredGrab {
324
+ readonly event: InfoEventShape
325
+ readonly info: DragInfo
326
+ readonly updates: InfoEventShape[]
327
+ // The recognizer may END before the measure lands (a quick flick — review r6 major 1):
328
+ // the end (and its finalize) QUEUE here, never silently dropped (G-INV-8). When the
329
+ // measure lands the FULL lifecycle flushes: begin → buffered updates → the release
330
+ // handoff → finalize. Only dispose / the buffer cap / a measure failure cancels.
331
+ endEvent?: { readonly event: DragPanEventShape; readonly success: boolean }
332
+ finalized?: boolean
333
+ }
334
+ let pendingGrab: PendingDeferredGrab | null = null
335
+ // The deferred begin ABORTED (the grab-time measure failed): the rest of THIS recognizer
336
+ // stream is dead — trailing updates and ends no-op (the pin's "the element simply does not
337
+ // drag"), never a session call against an idle session (the 2026-08-04 device crash class:
338
+ // update after measure failure → active() in 'idle'). The NEXT pan start re-arms.
339
+ let grabAborted = false
340
+
341
+ // Cancel a pending deferred begin (dispose, the bounded-buffer overflow, a host error): drop
342
+ // the queued lifecycle and free the lock — the drag never began, so no callbacks and no
343
+ // session (Motion's end-without-start is nothing). The grab-instant interrupt already froze
344
+ // any in-flight animation AT the grab value — the cancel target equals the frozen value
345
+ // (pre-grab == grab instant), so no return handoff is owed.
346
+ function cancelPendingGrab(): void {
347
+ if (pendingGrab === null) return
348
+ pendingGrab = null
349
+ grabAborted = true
350
+ if (lockState === 'acquired') {
351
+ lock.release(lockOwner)
352
+ lockState = 'open'
353
+ }
354
+ }
355
+
356
+ // The mid-drag re-measure loop (REQ-GESTURE-028, review major 3 — the pin's ResizeObserver +
357
+ // window-resize scalePositionWithinConstraints path): while a ref-constrained drag is ACTIVE
358
+ // the boxes re-measure per frame tick; on change the constraints re-resolve AND the position
359
+ // rescales to preserve the relative progress within the new bounds. `lastResolvedPerAxis`
360
+ // tracks the bounds the active grab began (or last rescaled) with — the diff + the progress
361
+ // base. The loop is self-terminating (no active ref session → cancel) and bounded (one
362
+ // async measure in flight; the stream's abort/dispose paths stop it).
363
+ let stopMeasurePoll: (() => void) | null = null
364
+ let pollMeasureInFlight = false
365
+ let lastResolvedPerAxis: Partial<Record<DragAxis, AxisSpan>> = {}
366
+
367
+ function stopPoll(): void {
368
+ stopMeasurePoll?.()
369
+ stopMeasurePoll = null
370
+ }
371
+
372
+ function tickMeasurePoll(): void {
373
+ if (pollMeasureInFlight || pendingGrab !== null) return
374
+ const activeRefAxes = axes.filter(
375
+ (axis) =>
376
+ config[axis]?.constraintsRef !== undefined && bindings[axis]!.session.state() === 'active',
377
+ )
378
+ if (activeRefAxes.length === 0) {
379
+ stopPoll()
380
+ return
381
+ }
382
+ pollMeasureInFlight = true
383
+ const epoch = gestureEpoch
384
+ void measureWithTimeout(
385
+ options.measureConstraints!,
386
+ config[activeRefAxes[0]!]!.constraintsRef!.ref,
387
+ ).then(
388
+ (boxes) => {
389
+ pollMeasureInFlight = false
390
+ // Stale resolves die: the stream aborted/disposed, or the epoch moved (a release or a
391
+ // re-grab happened while this measure was in flight — review r2 major 2).
392
+ if (disposed || grabAborted || epoch !== gestureEpoch) return
393
+ try {
394
+ adoptMeasuredBoxes(boxes, activeRefAxes)
395
+ } catch (error) {
396
+ // A throwing measure hook mid-drag: report and stop the poll — the active grab keeps
397
+ // its last resolved bounds (symmetric to the measure-failure path).
398
+ stopPoll()
399
+ options.reportConstraintFailure?.(
400
+ error instanceof Error ? error : new Error(describeValue(error)),
401
+ )
402
+ }
403
+ },
404
+ (cause) => {
405
+ pollMeasureInFlight = false
406
+ // The same staleness guard as the fulfillment branch (review r4 major 4): a poll
407
+ // rejected after abort/dispose/epoch-move is DISCARDED — never a stale report, never a
408
+ // stopPoll on the ticker now serving the next gesture.
409
+ if (disposed || grabAborted || epoch !== gestureEpoch) return
410
+ // A mid-drag measure failure (the container unmounted): report through the channel and
411
+ // stop the poll — the active grab keeps its last resolved bounds (the pin's invariant
412
+ // surfaces on its own channel; the stream is NOT aborted — the gesture itself is fine).
413
+ stopPoll()
414
+ options.reportConstraintFailure?.(
415
+ cause instanceof Error ? cause : new Error(describeValue(cause)),
416
+ )
417
+ },
418
+ )
419
+ }
420
+
421
+ // Adopt a poll resolve PER AXIS (review r13 major 1): an axis whose container and draggable
422
+ // boxes moved by the SAME delta is invariant on that axis (unchanged, or a shared ancestor
423
+ // translate/scroll) — never fabricated by pairing a stale draggable with a moved container.
424
+ // A container-only change on an axis adopts with the last adopted (coherent) draggable span
425
+ // for THAT axis; a draggable-only change on an axis is lag (or the deferred app shift) —
426
+ // never adopted. The rescale diffs per axis, so untouched axes are untouched.
427
+ function adoptMeasuredBoxes(boxes: ConstraintsMeasureBoxes, refAxes_: readonly DragAxis[]): void {
428
+ const adopted = lastAdoptedBoxes
429
+ if (adopted === null) return
430
+ let adoptX = false
431
+ let adoptY = false
432
+ let invX = true
433
+ let invY = true
434
+ for (const axis of ['x', 'y'] as const) {
435
+ const draggableDeltaMin = boxes.draggableBox[axis].min - adopted.draggableBox[axis].min
436
+ const draggableDeltaMax = boxes.draggableBox[axis].max - adopted.draggableBox[axis].max
437
+ const containerDeltaMin = boxes.containerBox[axis].min - adopted.containerBox[axis].min
438
+ const containerDeltaMax = boxes.containerBox[axis].max - adopted.containerBox[axis].max
439
+ if (draggableDeltaMin === containerDeltaMin && draggableDeltaMax === containerDeltaMax) {
440
+ continue // invariant on this axis
441
+ }
442
+ if (axis === 'x') invX = false
443
+ else invY = false
444
+ if (sameSpan(boxes.containerBox[axis], adopted.containerBox[axis])) continue // lag — never adopted
445
+ if (axis === 'x') adoptX = true
446
+ else adoptY = true
447
+ }
448
+ if (adoptX || adoptY) {
449
+ // Per axis: invariant axes pair the SAMPLE's boxes (their relative geometry is unchanged);
450
+ // changed axes — adopting OR lag-only — pair the ADOPTED draggable span (never the
451
+ // lag-suspect sample side, review r14 major 3) with the current container span.
452
+ const coherentBoxes: ConstraintsMeasureBoxes = {
453
+ draggableBox: {
454
+ x: invX ? boxes.draggableBox.x : adopted.draggableBox.x,
455
+ y: invY ? boxes.draggableBox.y : adopted.draggableBox.y,
456
+ },
457
+ containerBox: boxes.containerBox,
458
+ }
459
+ lastAdoptedBoxes = coherentBoxes
460
+ rescaleAfterBoxChange(applyMeasureHook(coherentBoxes, refAxes_), refAxes_)
461
+ return
462
+ }
463
+ if (invX && invY) {
464
+ lastAdoptedBoxes = boxes // a shared translate on every axis — track, never fabricate
465
+ }
466
+ // else: lag-only changes — keep the baseline (the lag must not poison it).
467
+ }
468
+
469
+ // Re-resolve + rescale after the measured boxes changed (the pin's math: preserve the
470
+ // relative progress within the old bounds — calcOrigin — applied to the new bounds — mix).
471
+ // The measure hook already ran (adoptMeasuredBoxes' shared seam).
472
+ function rescaleAfterBoxChange(
473
+ finalPerAxis: Partial<Record<DragAxis, AxisSpan>>,
474
+ refAxes_: readonly DragAxis[],
475
+ ): void {
476
+ const values: Record<string, number> = {}
477
+ let changed = false
478
+ for (const axis of refAxes_) {
479
+ const next = finalPerAxis[axis]!
480
+ const prev = lastResolvedPerAxis[axis]
481
+ if (prev === undefined || (prev.min === next.min && prev.max === next.max)) continue
482
+ lastResolvedPerAxis[axis] = next
483
+ const session = bindings[axis]!.session
484
+ if (session.state() !== 'active') continue
485
+ const live = driver.liveFor(handle, axis)
486
+ // The progress-preserving rescale requires FINITE spans on both sides: an open-sided
487
+ // span (a legal hook replacement) has no proportional position to preserve — keep the
488
+ // live value (the constraints still update; review r4 major 3 — never write NaN).
489
+ const rescaled =
490
+ Number.isFinite(prev.min) &&
491
+ Number.isFinite(prev.max) &&
492
+ Number.isFinite(next.min) &&
493
+ Number.isFinite(next.max)
494
+ ? next.min + (next.max - next.min) * progressWithinSpan(live.value, prev)
495
+ : live.value
496
+ session.updateConstraints(
497
+ { min: next.min, max: next.max, elastic: config[axis]!.constraintsRef!.elastic },
498
+ rescaled,
499
+ )
500
+ values[axis] = rescaled
501
+ changed = true
502
+ }
503
+ if (changed) driver.write(handle, values)
504
+ }
505
+
506
+ function startMeasurePoll(): void {
507
+ if (options.frameTicker === undefined || stopMeasurePoll !== null) return
508
+ stopMeasurePoll = options.frameTicker(tickMeasurePoll)
509
+ }
510
+
511
+ // The hook's replacement box is a boundary INPUT, validated immediately by CORE's single
512
+ // cross-engine verdict (review r3 major 3 + r7 major 3): complete, finite or
513
+ // correct-polarity-infinite, ordered. A malformed replacement is a typed refusal —
514
+ // severity-adapted by the caller's report channel (the begin aborts the grab; the poll
515
+ // keeps its last resolved bounds).
516
+
517
+ // The pin's measure-hook seam, shared by the grab-time begin AND the mid-drag re-measure
518
+ // (the pin invokes resolveRefConstraints — hook included — on BOTH paths, review r2 major 1):
519
+ // compute calcViewportConstraints over BOTH axes of the measured boxes (the complete
520
+ // geometry, never a fabricated zero span on the non-dragged axis — review r1 major 2),
521
+ // offer it to onMeasureDragConstraints ONCE (the hasMutatedConstraints seam), and use the
522
+ // possibly-replaced bounds per ref axis.
523
+ function applyMeasureHook(
524
+ boxes: ConstraintsMeasureBoxes,
525
+ refAxes_: readonly DragAxis[],
526
+ ): Partial<Record<DragAxis, AxisSpan>> {
527
+ const measuredBoth = {
528
+ x: resolveViewportConstraints(boxes.draggableBox.x, boxes.containerBox.x),
529
+ y: resolveViewportConstraints(boxes.draggableBox.y, boxes.containerBox.y),
530
+ }
531
+ let finalPerAxis: Partial<Record<DragAxis, AxisSpan>> = {}
532
+ for (const axis of refAxes_) {
533
+ finalPerAxis[axis] = measuredBoth[axis]
534
+ }
535
+ if (callbacks.onMeasureDragConstraints !== undefined) {
536
+ // The hook's box is a boundary input; a THROWING hook or a malformed replacement refuses
537
+ // the PROPERTY, never the gesture (review r14 major 4, BRIEF's single severity policy —
538
+ // cross-engine: web's wrapper drops the replacement and keeps motion's own measured box).
539
+ // The measured bounds stand; the failure reports through the channel per sighting.
540
+ try {
541
+ const replaced = callbacks.onMeasureDragConstraints({
542
+ top: measuredBoth.y.min,
543
+ left: measuredBoth.x.min,
544
+ bottom: measuredBoth.y.max,
545
+ right: measuredBoth.x.max,
546
+ })
547
+ if (replaced) {
548
+ // The pin's truthiness no-op (hasMutatedConstraints = !!userConstraints): any falsy
549
+ // return means "keep the measured box". A TRUTHY return is validated by core's single
550
+ // verdict (complete/finite/ordered or the typed refusal).
551
+ const legal = validateMeasureReplacementBox(replaced)
552
+ finalPerAxis = {}
553
+ for (const axis of refAxes_) {
554
+ finalPerAxis[axis] =
555
+ axis === 'x'
556
+ ? { min: legal.left, max: legal.right }
557
+ : { min: legal.top, max: legal.bottom }
558
+ }
559
+ }
560
+ } catch (error) {
561
+ options.reportConstraintFailure?.(
562
+ error instanceof Error ? error : new Error(describeValue(error)),
563
+ )
564
+ }
565
+ }
566
+ return finalPerAxis
567
+ }
568
+
569
+ // The gesture epoch (review r2 major 2): bumped at every grab and every stream end — an
570
+ // async poll dispatched under one gesture resolves under the epoch check, so a stale
571
+ // measure (release + rapid re-grab meanwhile) is discarded, never accepted into the new
572
+ // gesture's session. The deferred begin's own promise rides the pendingGrab token already.
573
+ let gestureEpoch = 0
574
+ // The adoption rule (2026-08-04 walk finding, review r3 major 2 + r5 major 2): only a
575
+ // CONTAINER-side change is adopted, and only with the LAST ADOPTED draggable span — never
576
+ // the poll sample's draggable box. The draggable's layout is static under an active drag
577
+ // (the drag moves via transform), so any sample-side change there is the async measure's
578
+ // frame lag (the finger moves between the UI measure and the JS delivery; under steady
579
+ // motion two consecutive lagged samples can coincide, which is why the earlier
580
+ // two-sightings debounce still drifted). The container side carries no finger lag, and a
581
+ // continuously animated container re-resolves every frame. An app-driven draggable layout
582
+ // shift mid-drag is the pin's didUpdate delta-follow path — deferred (Finding D2e).
583
+ let lastAdoptedBoxes: ConstraintsMeasureBoxes | null = null
584
+
585
+ function sameSpan(a: AxisSpan, b: AxisSpan): boolean {
586
+ return a.min === b.min && a.max === b.max
587
+ }
588
+
589
+ function buildInfo(
590
+ event: InfoEventShape,
591
+ offsetOverride?: { readonly x: number; readonly y: number },
592
+ ): DragInfo {
593
+ const point = {
594
+ x: requireInfoNumber(event.absoluteX, 'absoluteX'),
595
+ y: requireInfoNumber(event.absoluteY, 'absoluteY'),
596
+ }
597
+ // Default offset is raw translation (grab path before sessions have advanced). Callers that
598
+ // have just applied session updates pass constrained element displacement (value − grabSeed).
599
+ const offset = offsetOverride ?? {
600
+ x: requireInfoNumber(event.translationX, 'translationX'),
601
+ y: requireInfoNumber(event.translationY, 'translationY'),
602
+ }
603
+ const info: DragInfo = {
604
+ point,
605
+ delta:
606
+ lastPoint === null
607
+ ? { x: 0, y: 0 }
608
+ : { x: point.x - lastPoint.x, y: point.y - lastPoint.y },
609
+ offset,
610
+ velocity: {
611
+ x: requireInfoNumber(event.velocityX, 'velocityX'),
612
+ y: requireInfoNumber(event.velocityY, 'velocityY'),
613
+ },
614
+ }
615
+ lastPoint = point
616
+ return info
617
+ }
618
+
619
+ function constrainedOffset(): { x: number; y: number } {
620
+ const axisOffset = (axis: DragAxis): number => {
621
+ const binding = bindings[axis]
622
+ const seed = grabSeeds[axis]
623
+ if (binding === undefined || seed === undefined || binding.session.state() !== 'active') {
624
+ return 0
625
+ }
626
+ return binding.session.value() - seed
627
+ }
628
+ return { x: axisOffset('x'), y: axisOffset('y') }
629
+ }
630
+
631
+ function anySessionActive(): boolean {
632
+ return axes.some((axis) => bindings[axis]!.session.state() === 'active')
633
+ }
634
+
635
+ // Release and cancel share the command shape: the sessions' handoffs ARE the seed
636
+ // (REQ-GESTURE-022); the driver's spring integrates them on the UI thread (REQ-DRIVER-023).
637
+ // ONE command covers every handed-off axis (REQ-GESTURE-024) so the release edge is atomic.
638
+ //
639
+ // TERMINAL TOTALITY (G-INV-7, review r4 major 759836): mutate-THEN-info. The handoffs are
640
+ // already computed from the release-critical fields (axis translations/velocities), so the
641
+ // release/cancel command lands FIRST — the gesture's real outcome (settling to the targets).
642
+ // Only THEN is the callback `info` built: a malformed INFO-ONLY field (absoluteX/Y) throws
643
+ // AFTER the gesture has terminated cleanly (sessions idle, driver settling), losing the
644
+ // callback but never stranding the gesture active. This is deliberately OPPOSITE
645
+ // handlePanStart (validate-before-mutate): a bad GRAB must not interrupt the in-flight
646
+ // animation; a bad terminal must still end.
647
+ function issueHandoffs(
648
+ handoffs: Readonly<Partial<Record<DragAxis, ReleaseHandoff>>>,
649
+ event: InfoEventShape,
650
+ ): void {
651
+ const targets: Record<string, PropTarget> = {}
652
+ for (const axis of axes) {
653
+ const handoff = handoffs[axis]
654
+ if (handoff === undefined) continue
655
+ targets[axis] =
656
+ handoff.curve === 'inertia'
657
+ ? // Free-drag release (R16): the two-phase inertia generator rides the per-target
658
+ // transition; `to` is the mirror terminal (the clamped ideal), the descriptor owns
659
+ // the curve.
660
+ {
661
+ to: handoff.target,
662
+ from: handoff.from,
663
+ velocity: handoff.velocity,
664
+ transition: inertiaTransitionFor(config, axis, handoff),
665
+ }
666
+ : { to: handoff.target, from: handoff.from, velocity: handoff.velocity }
667
+ }
668
+ if (Object.keys(targets).length === 0) return // nothing active handed off (defensive)
669
+ driver.command(handle, {
670
+ kind: 'start',
671
+ targets,
672
+ transition: releaseTransitionFor(config),
673
+ // The finger let go (or the gesture cancelled): THIS start is the release edge — it ends
674
+ // the gesture's ownership of the axis props so the settle spring takes over with the
675
+ // seeded platform velocity. A declarative `animate`/mount command during the drag never
676
+ // carries this, so it can never masquerade as the release (review r8 major 24).
677
+ releasesGesture: true,
678
+ })
679
+ const info = buildInfo(event) // AFTER the command — a malformed info-only field throws terminated
680
+ lastPoint = null // the gesture is over; the next grab starts a fresh delta base
681
+ callbacks.onDragEnd?.(event, info)
682
+ }
683
+
684
+ // Complete a deferred begin once the grab-time measure lands (REQ-GESTURE-028): compute the
685
+ // pin's viewport constraints per axis, apply the measure hook, validate snap against the
686
+ // measured bounds, then run the EXACT grab protocol of the immediate path (live-read →
687
+ // per-axis begin with the measured bounds → interrupting write → onDragStart) and flush the
688
+ // buffered updates in order.
689
+ function beginAfterMeasure(
690
+ pending: PendingDeferredGrab,
691
+ boxes: ConstraintsMeasureBoxes,
692
+ refAxes: readonly DragAxis[],
693
+ ): void {
694
+ try {
695
+ // The complete measured box + the measure hook, through the SHARED seam (review r2
696
+ // major 1 — the pin applies the hook on every resolveRefConstraints path).
697
+ const finalPerAxis = applyMeasureHook(boxes, refAxes)
698
+ // Snap-vs-measured-bounds (the numeric path's contradiction law at the grab-time measure,
699
+ // REQ-GESTURE-015): a snap outside the measured bounds would settle out of range.
700
+ for (const axis of refAxes) {
701
+ const snap = config[axis]?.snap
702
+ const bounds = finalPerAxis[axis]!
703
+ if (snap !== undefined) {
704
+ for (const point of snap.points) {
705
+ if (point < bounds.min || point > bounds.max) {
706
+ throw new Error(
707
+ `dragSnapPoints.${axis} contains ${point}, outside the measured dragConstraints ` +
708
+ `[${bounds.min}, ${bounds.max}] — a snap outside the hard bounds would settle ` +
709
+ 'out of range (REQ-GESTURE-015).',
710
+ )
711
+ }
712
+ }
713
+ }
714
+ }
715
+ const values: Record<string, number> = {}
716
+ const velocities: Record<string, number> = {}
717
+ for (const axis of axes) {
718
+ const live = driver.liveFor(handle, axis)
719
+ const measured = finalPerAxis[axis]
720
+ bindings[axis]!.onStart(
721
+ pending.event,
722
+ { value: live.value },
723
+ measured === undefined
724
+ ? undefined
725
+ : {
726
+ min: measured.min,
727
+ max: measured.max,
728
+ elastic: config[axis]!.constraintsRef!.elastic,
729
+ },
730
+ )
731
+ values[axis] = live.value
732
+ velocities[axis] = live.velocity
733
+ }
734
+ driver.write(handle, values, velocities)
735
+ callbacks.onDragStart?.(pending.event, pending.info)
736
+ for (const update of pending.updates) applyPanUpdate(update)
737
+ // The grab began with the measured bounds: arm the mid-drag re-measure loop (review
738
+ // major 3) with THESE bounds as the diff/progress base and the adoption marker.
739
+ lastResolvedPerAxis = { ...finalPerAxis }
740
+ lastAdoptedBoxes = boxes
741
+ startMeasurePoll()
742
+ // A queued end/finalize (the flick that ended before the measure landed, review r6
743
+ // major 1) flushes through the NORMAL lifecycle — the release handoff (or the
744
+ // cancel-to-pre-grab) starts, then finalize runs its totality/lock law. No motion is
745
+ // ever silently dropped (G-INV-8).
746
+ if (pending.endEvent !== undefined) {
747
+ routeEnd(pending.endEvent.event, pending.endEvent.success)
748
+ }
749
+ if (pending.finalized === true) {
750
+ routeFinalize(pending.endEvent?.event ?? pending.event)
751
+ }
752
+ } catch (error) {
753
+ let finalizeFailure: Error | undefined
754
+ if (anySessionActive()) {
755
+ // Post-activation failure — a throwing consumer callback during the begin OR the
756
+ // queued end/finalize replay (review r7 major 1): the immediate path relies on the
757
+ // LATER recognizer finalize to clean up, but here the queued terminal events were
758
+ // already consumed — nothing later cancels. Force the totality path NOW: cancel the
759
+ // active sessions to pre-grab and free the lock, then report through the channel.
760
+ grabAborted = true
761
+ try {
762
+ routeFinalize(pending.event)
763
+ } catch (secondary) {
764
+ // The forced finalize can itself fail (review r8 major 3) — capture, never swallow;
765
+ // it reports after the primary below.
766
+ finalizeFailure =
767
+ secondary instanceof Error ? secondary : new Error(describeValue(secondary))
768
+ }
769
+ } else {
770
+ // Pre-activation failure (snap validation, the hook, liveFor): the begin never completed,
771
+ // so the rest of the recognizer stream is DEAD (review major 1 — a trailing update
772
+ // crashed active()-in-'idle' here). A POST-activation failure (a throwing consumer
773
+ // callback) keeps the gesture: the active session owns the lock until finalize (the D1 law).
774
+ grabAborted = true
775
+ if (lockState === 'acquired') {
776
+ lock.release(lockOwner)
777
+ lockState = 'open'
778
+ }
779
+ }
780
+ options.reportConstraintFailure?.(
781
+ error instanceof Error ? error : new Error(describeValue(error)),
782
+ )
783
+ if (finalizeFailure !== undefined) {
784
+ // The forced finalize's own failure (driver/command/info/consumer — G-INV-7's own
785
+ // vocabulary) rides the channel AFTER the primary, never swallowed (review r8 major 3;
786
+ // a suppressed secondary would hide a terminal-handoff failure).
787
+ options.reportConstraintFailure?.(finalizeFailure)
788
+ }
789
+ }
790
+ }
791
+
792
+ // The update hot path, shared by the recognizer handler and the deferred-begin flush
793
+ // (REQ-GESTURE-002: one write per recognizer event — both axes in one write,
794
+ // REQ-GESTURE-024). Validate the full event before mutating (major 6): buildInfo first,
795
+ // then each session owns its axis's per-event decision (dead-zone, clamp, elastic) and the
796
+ // constrained values land together. The write carries the recognizer's per-axis velocities
797
+ // — the platform numbers, no estimator (release-side C1, G-INV-3): liveFor tracks the
798
+ // finger velocity so the release seam is continuous (review r4 c14e15).
799
+ function applyPanUpdate(event: InfoEventShape): void {
800
+ // Pin updateAxis (VisualElementDragControls.ts:320): on→off mid-drag freezes further
801
+ // writes. Live empty axes means drag={false} this render.
802
+ const liveAxes = resolvedDragAxes(getConfig())
803
+ if (liveAxes.length === 0) return
804
+ // Direction lock uses RAW pointer translation (pin: lock before constraints apply).
805
+ const rawX = requireInfoNumber(event.translationX, 'translationX')
806
+ const rawY = requireInfoNumber(event.translationY, 'translationY')
807
+ if (config.dragDirectionLock) {
808
+ if (lockedAxis === null) {
809
+ const next = resolveDirectionLockAxis(rawX, rawY)
810
+ if (next === null) return
811
+ lockedAxis = next
812
+ if (!directionLockNotified) {
813
+ directionLockNotified = true
814
+ callbacks.onDirectionLock?.(next)
815
+ }
816
+ return
817
+ }
818
+ }
819
+ const activeAxes = (
820
+ lockedAxis === null ? axes : axes.filter((axis) => axis === lockedAxis)
821
+ ).filter((axis) => liveAxes.includes(axis))
822
+ if (activeAxes.length === 0) return
823
+ const values: Record<string, number> = {}
824
+ const velocities: Record<string, number> = {}
825
+ const velX = requireInfoNumber(event.velocityX, 'velocityX')
826
+ const velY = requireInfoNumber(event.velocityY, 'velocityY')
827
+ for (const axis of activeAxes) {
828
+ values[axis] = bindings[axis]!.onUpdate(event)
829
+ velocities[axis] = axis === 'x' ? velX : velY
830
+ }
831
+ driver.write(handle, values, velocities)
832
+ // DragInfo after session updates: offset is constrained element displacement (value − grab).
833
+ const info = buildInfo(event, constrainedOffset())
834
+ callbacks.onDrag?.(event, info)
835
+ }
836
+
837
+ // The successful-release path (REQ-GESTURE-022), shared by handlePanEnd and the
838
+ // deferred-begin's queued-end flush (review r6 major 1 — one lifecycle, never forked).
839
+ function routeEnd(event: DragPanEventShape, success: boolean): void {
840
+ if (!success) {
841
+ // Unsuccessful end (RNGH fires onEnd(event,false) before onFinalize): NOT a release —
842
+ // finalize owns the cancel. routeEnd(false) is a no-op, so there is nothing to do here
843
+ // and nothing to validate (no release fields consumed).
844
+ return
845
+ }
846
+ // Successful release, in three ordered acts PER AXIS that the pin would startAnimation
847
+ // for (shouldDrag / currentDirection). R15 D4 seal H1/H1′: under dragDirectionLock a locked
848
+ // session must not free-drag the unlocked axis on end — beginRelease of a never-updated
849
+ // axis still sees raw finger translation as "moved" and would catch-up + seed velocity.
850
+ // Unlock axes cancel-to-live in the SAME issueHandoffs batch (pin: one onDragEnd; never
851
+ // leave them active for routeFinalize to fire a second terminal).
852
+ const releaseAxes: readonly DragAxis[] =
853
+ config.dragDirectionLock && lockedAxis !== null
854
+ ? axes.filter((axis) => axis === lockedAxis)
855
+ : axes
856
+ // 1. COMMIT the final drag sample on every release axis (validate-then-apply; a malformed
857
+ // field throws WITHOUT ending that axis's session, so finalize — the totality guarantor —
858
+ // cancels it). The end translation is never dropped (G-INV-8; the M1 session commits
859
+ // it too, r11 major 32).
860
+ const releases: Partial<
861
+ Record<DragAxis, { fingerValue: number; velocity: number; moved: boolean }>
862
+ > = {}
863
+ for (const axis of releaseAxes) releases[axis] = bindings[axis]!.beginRelease(event)
864
+ // 2. Catch the driver up to the finger's final position on every MOVED axis — decided by
865
+ // the RAW recognizer translation, NOT the constrained value (r12 major 10): a finger
866
+ // dragging PAST a hard bound moves while its clamped value stays pinned at the bound,
867
+ // and that movement must re-anchor the contention to the bound. A held axis does NOT
868
+ // write, so a contending declarative drift (REQ-DRIVER-026) remains its release origin
869
+ // — the animate wrote last (last-writer-wins). ONE write covers every moved axis.
870
+ const catchUp: Record<string, number> = {}
871
+ const catchUpVelocities: Record<string, number> = {}
872
+ for (const axis of releaseAxes) {
873
+ const release = releases[axis]!
874
+ if (release.moved) {
875
+ catchUp[axis] = release.fingerValue
876
+ catchUpVelocities[axis] = release.velocity
877
+ }
878
+ }
879
+ if (Object.keys(catchUp).length > 0) driver.write(handle, catchUp, catchUpVelocities)
880
+ // 3. A moved axis starts from the validated constrained sample just written. The live
881
+ // driver may queue that write on the UI thread, so a synchronous read here can still
882
+ // see the pre-sample value. A held axis performs no write and MUST read the actual live
883
+ // contending drift (REQ-GESTURE-022/REQ-DRIVER-025). Then issueHandoffs mutates-then-
884
+ // info (terminal totality, r4 759836) with ONE atomic release command.
885
+ const handoffs: Partial<Record<DragAxis, ReleaseHandoff>> = {}
886
+ for (const axis of releaseAxes) {
887
+ const release = releases[axis]!
888
+ const origin = release.moved ? release.fingerValue : driver.liveFor(handle, axis).value
889
+ // dragMomentum=false is gated INSIDE the session (R16): the projection velocity itself is
890
+ // zeroed (settle-at-release), so the handoff arrives already gated — no wiring-side gate.
891
+ let handoff = bindings[axis]!.finishRelease(release.velocity, origin)
892
+ // R15 D5: dragSnapToOrigin forces the release target to the origin on the named axes.
893
+ if (snapToOriginApplies(config, axis)) {
894
+ handoff =
895
+ handoff.curve === 'inertia'
896
+ ? // The pin's zero-bounds inertia ({min:0,max:0}): the generator's boundary spring
897
+ // pulls the decay to 0 (R16 — REQ-GESTURE-027).
898
+ {
899
+ curve: 'inertia',
900
+ from: handoff.from,
901
+ velocity: handoff.velocity,
902
+ target: 0,
903
+ min: 0,
904
+ max: 0,
905
+ }
906
+ : { curve: 'spring', from: handoff.from, velocity: handoff.velocity, target: 0 }
907
+ }
908
+ handoffs[axis] = handoff
909
+ }
910
+ // Direction-lock unlock axes: cancel-to-live (no free-drag) in this same handoff batch so
911
+ // finalize sees all sessions idle and does not re-issue onDragEnd (seal H1′).
912
+ if (config.dragDirectionLock && lockedAxis !== null) {
913
+ for (const axis of axes) {
914
+ if (axis === lockedAxis) continue
915
+ if (bindings[axis]!.session.state() !== 'active') continue
916
+ const liveOrigin = driver.liveFor(handle, axis).value
917
+ const cancelHandoff = bindings[axis]!.routeFinalize(false, liveOrigin)
918
+ if (cancelHandoff !== null) handoffs[axis] = cancelHandoff
919
+ }
920
+ }
921
+ issueHandoffs(handoffs, event)
922
+ }
923
+
924
+ // The finalize totality path (G-INV-7), shared by handlePanFinalize and the deferred-begin's
925
+ // queued-finalize flush (review r6 major 1).
926
+ function routeFinalize(event: DragPanEventShape): void {
927
+ const state = lockState
928
+ try {
929
+ if (state === 'blocked') return
930
+ // Totality guarantor (G-INV-7, review r4 759836): finalize ALWAYS runs last. If any
931
+ // axis's session is still active — an unsuccessful end, OR a successful end whose
932
+ // release-critical fields were malformed and threw — force the cancel to the pre-grab
933
+ // value (event-independent) via issueHandoffs BEFORE any info build. A successful
934
+ // release already left every session idle, so this returns. (The RNGH `success` flag is
935
+ // not needed for the decision — session state is the totality signal.)
936
+ if (!anySessionActive()) return
937
+ const handoffs: Partial<Record<DragAxis, ReleaseHandoff>> = {}
938
+ for (const axis of axes) {
939
+ if (bindings[axis]!.session.state() !== 'active') continue
940
+ // The cancel ORIGIN is the driver's LIVE value on that axis at this instant
941
+ // (REQ-GESTURE-022, symmetric to the release path): starting the return spring from
942
+ // the finger would rewind a contending declarative drift (REQ-DRIVER-026), a visible
943
+ // backward jump (r11 major 31). The read is harmless (no mutation, never throws for
944
+ // the registered axis) so it precedes the totality-guaranteed cancel.
945
+ const releaseOrigin = driver.liveFor(handle, axis).value
946
+ const handoff = bindings[axis]!.routeFinalize(false, releaseOrigin)
947
+ if (handoff !== null) handoffs[axis] = handoff
948
+ }
949
+ issueHandoffs(handoffs, event)
950
+ } finally {
951
+ // The recognizer stream is over (finalize always runs last): free the lock so the
952
+ // element blocked behind us can drag next, and reset for the next grab.
953
+ if (state === 'acquired') lock.release(lockOwner)
954
+ lockState = 'open'
955
+ }
956
+ }
957
+
958
+ return {
959
+ snapToCursor(point, layoutBox) {
960
+ if (disposed) return
961
+ // Pin: layout measurement includes the current transform — midpoint is the visual center.
962
+ const midX = layoutBox.x + layoutBox.width / 2
963
+ const midY = layoutBox.y + layoutBox.height / 2
964
+ const values: Partial<Record<DragAxis, number>> = {}
965
+ const velocities: Partial<Record<DragAxis, number>> = {}
966
+ for (const axis of axes) {
967
+ const live = driver.liveFor(handle, axis)
968
+ const mid = axis === 'x' ? midX : midY
969
+ const pointer = axis === 'x' ? point.absoluteX : point.absoluteY
970
+ values[axis] = pointer - mid + live.value
971
+ velocities[axis] = live.velocity
972
+ }
973
+ if (Object.keys(values).length > 0) driver.write(handle, values, velocities)
974
+ },
975
+
976
+ handlePanStart(event) {
977
+ if (disposed) return
978
+ const live = getConfig()
979
+ if (installBindings(live).length === 0) return
980
+ grabAborted = false // a fresh grab re-arms after an aborted deferred begin
981
+ gestureEpoch += 1 // a new gesture: in-flight polls from any previous one resolve stale
982
+ // Direction lock is session-scoped — every grab starts unlocked (REQ-GESTURE-025).
983
+ lockedAxis = null
984
+ directionLockNotified = false
985
+ // REQ-GESTURE-028 deferred-begin PRE-GATE: ref constraints REQUIRE the measure seam — a
986
+ // host wiring bug, fail-loud BEFORE the lock is ever touched (review r4 minor 5: the
987
+ // error path used to leak the acquired lock and wedge every later drag).
988
+ const refAxes = axes.filter((axis) => config[axis]?.constraintsRef !== undefined)
989
+ if (refAxes.length > 0 && options.measureConstraints === undefined) {
990
+ throw new Error(
991
+ 'drag wiring: constraintsRef requires the measureConstraints seam (REQ-GESTURE-028; host wiring law).',
992
+ )
993
+ }
994
+ // The lock gate precedes EVERYTHING (REQ-GESTURE-024): a lock-blocked grab consumes
995
+ // nothing — no validation, no read, no write, no callback — exactly Motion's "the element
996
+ // simply does not drag" while another non-propagating element holds the lock.
997
+ if (!dragPropagation) {
998
+ // The pin's per-axis lock (motion-dom set-active): a single-axis drag takes its axis;
999
+ // a both-axes drag needs BOTH axes free (atomic — never a partial hold).
1000
+ if (lock.acquire(lockOwner, axes)) {
1001
+ lockState = 'acquired'
1002
+ } else {
1003
+ lockState = 'blocked'
1004
+ return
1005
+ }
1006
+ }
1007
+ // REQ-GESTURE-028 deferred-begin branch: element-ref constraints measure at grab and
1008
+ // begin when the bounds land (native-motion re-measures at EVERY grab — the freshness
1009
+ // superset of the pin's cache-and-invalidate machinery; native measureInWindow is
1010
+ // async, so the value holds C0 meanwhile).
1011
+ if (refAxes.length > 0) {
1012
+ try {
1013
+ lastPoint = null
1014
+ const info = buildInfo(event)
1015
+ // The grab-instant interrupt (REQ-GESTURE-012/-022, review r4 major 2): the live read
1016
+ // + interrupting write happen NOW — an in-flight animation freezes AT the grab (the
1017
+ // pin's synchronous start does this inline). The deferred begin re-reads live when
1018
+ // the measure lands (the same frozen numbers unless a declarative drift contended
1019
+ // meanwhile — REQ-DRIVER-026's live-read law covers that).
1020
+ const interruptValues: Record<string, number> = {}
1021
+ const interruptVelocities: Record<string, number> = {}
1022
+ for (const axis of axes) {
1023
+ const live = driver.liveFor(handle, axis)
1024
+ interruptValues[axis] = live.value
1025
+ interruptVelocities[axis] = live.velocity
1026
+ }
1027
+ driver.write(handle, interruptValues, interruptVelocities)
1028
+ const pending: PendingDeferredGrab = { event, info, updates: [] }
1029
+ pendingGrab = pending
1030
+ void measureWithTimeout(
1031
+ // Non-null: the pre-gate above throws when the seam is absent for ref axes.
1032
+ options.measureConstraints!,
1033
+ config[refAxes[0]!]!.constraintsRef!.ref,
1034
+ ).then(
1035
+ (boxes) => {
1036
+ if (pendingGrab !== pending) return // cancelled meanwhile (finalize/end/dispose)
1037
+ pendingGrab = null
1038
+ beginAfterMeasure(pending, boxes, refAxes)
1039
+ },
1040
+ (cause) => {
1041
+ if (pendingGrab !== pending) return
1042
+ pendingGrab = null
1043
+ grabAborted = true
1044
+ if (lockState === 'acquired') {
1045
+ lock.release(lockOwner)
1046
+ lockState = 'open'
1047
+ }
1048
+ options.reportConstraintFailure?.(
1049
+ cause instanceof Error ? cause : new Error(describeValue(cause)),
1050
+ )
1051
+ },
1052
+ )
1053
+ } catch (error) {
1054
+ cancelPendingGrab()
1055
+ // The failure may precede the pending-grab assignment (buildInfo / liveFor / the
1056
+ // interrupt write) — cancelPendingGrab no-ops then, so free the just-acquired lock
1057
+ // directly (review r5 major 1; the immediate path's catch law: release only when no
1058
+ // session is active).
1059
+ if (lockState === 'acquired' && !anySessionActive()) {
1060
+ lock.release(lockOwner)
1061
+ lockState = 'open'
1062
+ }
1063
+ throw error
1064
+ }
1065
+ return
1066
+ }
1067
+ try {
1068
+ // VALIDATE the FULL consumed event BEFORE any driver/session mutation (review major 6,
1069
+ // G-INV-7/-8): buildInfo reads + fail-loud-validates every field the start path consumes
1070
+ // — BOTH axes' translations (for begin) AND the DragInfo fields (translationX/Y,
1071
+ // velocityX/Y, absoluteX/Y) the onDragStart callback needs. A malformed field throws
1072
+ // HERE, before liveFor/begin/write, so the in-flight animation is never interrupted and
1073
+ // no held-active state is established (buildInfo only touches lastPoint, reset first for
1074
+ // a fresh grab's zero delta — never the driver or sessions). Only after full validation
1075
+ // does the write interrupt at the grabbed values (REQ-DRIVER-024) so the element renders
1076
+ // the same numbers the sessions seeded from (C0, REQ-GESTURE-012). The grab preserves
1077
+ // the interrupted animations' velocities (grab-side C1, G-INV-3): the write carries
1078
+ // live.velocity per axis so liveFor stays continuous across the grab edge until the
1079
+ // first update reports the finger velocity (review r4 c14e15).
1080
+ lastPoint = null
1081
+ for (const axis of axes) delete grabSeeds[axis]
1082
+ const info = buildInfo(event)
1083
+ const values: Record<string, number> = {}
1084
+ const velocities: Record<string, number> = {}
1085
+ for (const axis of axes) {
1086
+ const live = driver.liveFor(handle, axis)
1087
+ bindings[axis]!.onStart(event, { value: live.value })
1088
+ grabSeeds[axis] = live.value
1089
+ values[axis] = live.value
1090
+ velocities[axis] = live.velocity
1091
+ }
1092
+ driver.write(handle, values, velocities)
1093
+ callbacks.onDragStart?.(event, info)
1094
+ } catch (error) {
1095
+ // Free the just-acquired lock ONLY when the failure left no session active (a
1096
+ // pre-mutation validation failure). A POST-activation failure — e.g. a throwing
1097
+ // consumer onDragStart after the sessions began — must NOT free it: the active
1098
+ // session owns the lock until its finalize, and releasing here would admit a
1099
+ // second non-propagating drag while the first still runs (review round-4 major —
1100
+ // the global exclusion contract).
1101
+ if (lockState === 'acquired' && !anySessionActive()) {
1102
+ lock.release(lockOwner)
1103
+ lockState = 'open'
1104
+ }
1105
+ throw error
1106
+ }
1107
+ },
1108
+
1109
+ handlePanUpdate(event) {
1110
+ if (disposed || lockState === 'blocked' || grabAborted) return
1111
+ // A deferred begin is in flight (REQ-GESTURE-028): buffer updates — they flush in order
1112
+ // when the measured bounds land (fail-loud validation happens at the flush).
1113
+ if (pendingGrab !== null) {
1114
+ // Bounded buffer (review r4 minor 7): a measure pending past the cap is pathological —
1115
+ // refuse the grab through the channel rather than retain events indefinitely.
1116
+ if (pendingGrab.updates.length >= MAX_BUFFERED_PAN_UPDATES) {
1117
+ cancelPendingGrab()
1118
+ options.reportConstraintFailure?.(
1119
+ new Error(
1120
+ `deferred begin buffered ${MAX_BUFFERED_PAN_UPDATES} pan updates without the ` +
1121
+ 'measure landing — the grab is refused (REQ-GESTURE-028; the bounded-buffer law).',
1122
+ ),
1123
+ )
1124
+ return
1125
+ }
1126
+ pendingGrab.updates.push(event)
1127
+ return
1128
+ }
1129
+ applyPanUpdate(event)
1130
+ },
1131
+
1132
+ handlePanEnd(event, success) {
1133
+ if (disposed || lockState === 'blocked' || grabAborted) return
1134
+ gestureEpoch += 1 // the stream ends: an in-flight poll resolves stale from here
1135
+ // An end before the measure lands QUEUES on the pending grab (review r6 major 1 — never
1136
+ // a silent drop): the full lifecycle flushes when the measure resolves.
1137
+ if (pendingGrab !== null) {
1138
+ pendingGrab.endEvent = { event, success }
1139
+ return
1140
+ }
1141
+ routeEnd(event, success)
1142
+ },
1143
+
1144
+ handlePanFinalize(event) {
1145
+ if (disposed) return
1146
+ gestureEpoch += 1 // finalize is the terminal edge: in-flight polls resolve stale
1147
+ // A finalize before the measure lands queues with the pending end (review r6 major 1).
1148
+ if (pendingGrab !== null) {
1149
+ pendingGrab.finalized = true
1150
+ return
1151
+ }
1152
+ routeFinalize(event)
1153
+ },
1154
+
1155
+ dispose() {
1156
+ // Idempotent: MotionView's cleanup and a stray recognizer path may both reach here. The
1157
+ // lock frees only if THIS wiring holds it (release is owner-checked by the lock itself).
1158
+ if (disposed) return
1159
+ disposed = true
1160
+ stopPoll() // the mid-drag re-measure loop dies with its wiring
1161
+ cancelPendingGrab() // a measure in flight never begins after unmount (review r4 major 1)
1162
+ if (lockState === 'acquired') lock.release(lockOwner)
1163
+ lockState = 'open'
1164
+ },
1165
+
1166
+ sessions,
1167
+ }
1168
+ }