@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,756 @@
1
+ // REQ-GESTURE-026 / REQ-API-043 (R15 D3) — MotionRoot hub touch stream (packet Finding 8).
2
+ //
3
+ // The pin's DragControls.start creates a window-level PanSession so a sibling handle works.
4
+ // Native has no window pointer tracking: MotionRoot hosts a manual-activation pan that
5
+ // records touches, stays inert until a hub start correlates a touch, then activates and
6
+ // drives the subscribed wiring with the recognizer's own translation/velocity stream
7
+ // (REQ-GESTURE-014 — no estimator).
8
+ //
9
+ // Pure stream state machine — MotionRoot binds it to Gesture.Pan(); MotionView registers
10
+ // its wiring as the hub-start target.
11
+
12
+ import type { DragControlOptions } from '@unrulysystems/native-motion-core'
13
+ import type { DragGestureWiring } from './dragGestureWiring'
14
+ import type { DragPanEventShape } from './dragHandoffBinding'
15
+
16
+ /** Screen-space point used for touch↔start correlation. */
17
+ export interface HubTouchPoint {
18
+ readonly id: number
19
+ readonly absoluteX: number
20
+ readonly absoluteY: number
21
+ }
22
+
23
+ /** A pending down-touch recorded by the root pan before a hub start arms it. */
24
+ export interface RecordedHubTouch {
25
+ readonly point: HubTouchPoint
26
+ readonly recordedAtMs: number
27
+ }
28
+
29
+ /** Page-point correction from MotionConfig (REQ-API-043 ingest boundary). */
30
+ export type HubPagePointTransform = (point: { readonly x: number; readonly y: number }) => {
31
+ readonly x: number
32
+ readonly y: number
33
+ }
34
+
35
+ /** One armed hub session: correlated touch + wiring + start options. */
36
+ export interface ArmedHubSession {
37
+ readonly touchId: number
38
+ readonly originAbsoluteX: number
39
+ readonly originAbsoluteY: number
40
+ readonly wiring: DragGestureWiring
41
+ readonly options: DragControlOptions
42
+ /** True after the first onStart / activation past distanceThreshold. */
43
+ activated: boolean
44
+ /**
45
+ * When true, pan updates are held until snapToCursor's measure lands — the sanctioned
46
+ * G-INV-2 jump must complete before tracking (REQ-GESTURE-026; seal review major).
47
+ */
48
+ pendingSnap: boolean
49
+ /** Grab-origin absolute position for snapToCursor / dead-zone math (screen-raw). */
50
+ readonly startAbsoluteX: number
51
+ readonly startAbsoluteY: number
52
+ /** Optional page-point transform applied to absolute coords at the event feed. */
53
+ readonly transformPagePoint: HubPagePointTransform | undefined
54
+ /** Arm-time report sink for transform/lifecycle failures (G-INV-8; never silent). */
55
+ readonly report: ((error: Error) => void) | undefined
56
+ /** Latest translated payload (for hub stop/cancel with real absolute coords). */
57
+ lastPayload:
58
+ | (DragPanEventShape & { readonly absoluteX: number; readonly absoluteY: number })
59
+ | null
60
+ /** Monotonic arm epoch — stale snap measure callbacks refuse when mismatched. */
61
+ readonly armEpoch: number
62
+ }
63
+
64
+ export type HubPanPayload = DragPanEventShape & {
65
+ readonly absoluteX: number
66
+ readonly absoluteY: number
67
+ }
68
+
69
+ // U7d (REQ-API-030 global form, packet F1/F2) — the tap-observation channel. The pin's global
70
+ // press observes window pointer events without competing; the hub stream is its native analog:
71
+ // an ADDITIVE subscriber view of the root touches. Observation is unconditional — a child
72
+ // recognizer claiming the touch (or an armed hub drag) never suppresses it (F2).
73
+ export interface HubTapTouchPoint {
74
+ readonly id: number
75
+ readonly absoluteX: number
76
+ readonly absoluteY: number
77
+ }
78
+
79
+ export interface HubTapObserver {
80
+ /** `primary` is true for the FIRST touch of a multi-touch sequence (pin isPrimaryPointer). */
81
+ onTouchDown(touch: HubTapTouchPoint, primary: boolean): void
82
+ onTouchUp(touch: HubTapTouchPoint): void
83
+ onTouchCancel(touch: HubTapTouchPoint): void
84
+ }
85
+
86
+ export interface DragHubStream {
87
+ /** Record a down-touch (root pan onTouchesDown). */
88
+ recordTouch(point: HubTouchPoint, nowMs?: number): void
89
+ /** Drop a touch that ended without arming (onTouchesUp/Cancelled). */
90
+ forgetTouch(id: number): void
91
+ /**
92
+ * Arm the stream for a hub-originated session.
93
+ * Correlates `startPoint` against recorded touches (nearest within TOLERANCE_PX).
94
+ * Ambiguous multi-touch (two equally near) fails loud when severity is development.
95
+ * Returns false when no touch correlates (fail-closed — no silent free-drag).
96
+ */
97
+ arm(input: {
98
+ readonly wiring: DragGestureWiring
99
+ readonly options: DragControlOptions
100
+ readonly startPoint: { readonly absoluteX: number; readonly absoluteY: number }
101
+ readonly report?: ((error: Error) => void) | undefined
102
+ /** When true, hold pan feed until `releaseSnapGate()` (snapToCursor measure). */
103
+ readonly pendingSnap?: boolean | undefined
104
+ /** Page-point correction applied to absolute coords at the event feed. */
105
+ readonly transformPagePoint?: HubPagePointTransform | undefined
106
+ }): boolean
107
+ /** Current armed session, if any. */
108
+ readonly armed: ArmedHubSession | null
109
+ /**
110
+ * Current arm epoch (0 when never armed). Snap measure callbacks capture the epoch at
111
+ * start and refuse to apply when it no longer matches (cancel / re-arm raced the measure).
112
+ */
113
+ readonly armEpoch: number
114
+ /**
115
+ * Root pan activated — feed start to the wiring with translation relative to arm origin.
116
+ * distanceThreshold gates the first activation (default 3, pin PanSession).
117
+ */
118
+ onPanStart(
119
+ event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
120
+ ): void
121
+ onPanUpdate(
122
+ event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
123
+ ): void
124
+ onPanEnd(event: DragPanEventShape, success: boolean): void
125
+ onPanFinalize(event: DragPanEventShape, success: boolean): void
126
+ /**
127
+ * Clear the snap gate so tracking may begin (called after snapToCursor measure/write).
128
+ * Flushes a held first-move if one arrived while pending.
129
+ */
130
+ releaseSnapGate(): void
131
+ /**
132
+ * Hub cancel/stop terminal: if activated, end the wiring with the latest payload
133
+ * (absolute coords preserved); always disarm so later moves cannot revive the session.
134
+ */
135
+ terminate(success: boolean): void
136
+ /** Clear armed state without wiring end (prefer `terminate` for product cancel/stop). */
137
+ disarm(): void
138
+ /** Bound for recorded-but-unarmed touch retention (ms). */
139
+ readonly touchTtlMs: number
140
+ /**
141
+ * U7d: subscribe to the root tap-observation channel. Returns the unsubscribe. Dispatch is
142
+ * synchronous from the root pan's touch handlers, never suppressed by recognizer claims.
143
+ */
144
+ subscribeTapObserver(observer: HubTapObserver): () => void
145
+ /**
146
+ * Root pan onTouchesDown feed — CHANGED touches only (one notification per physical down).
147
+ * The stream owns the multi-touch law: the first touch of a sequence notifies `primary`.
148
+ */
149
+ notifyTapDown(touch: HubTapTouchPoint): void
150
+ /** Root pan onTouchesUp feed (changed touches). Untracked ids notify nothing. */
151
+ notifyTapUp(touch: HubTapTouchPoint): void
152
+ /** Root pan onTouchesCancelled feed (changed touches) — the press's only cancel source. */
153
+ notifyTapCancel(touch: HubTapTouchPoint): void
154
+ }
155
+
156
+ /** Correlation radius (screen px) for matching start() to a recorded touch. */
157
+ export const HUB_TOUCH_CORRELATION_PX = 24
158
+ /** Unarmed touches older than this are dropped on the next record (bounded retention). */
159
+ export const HUB_TOUCH_TTL_MS = 2000
160
+ /** Pin PanSession default distanceThreshold. */
161
+ export const HUB_DEFAULT_DISTANCE_THRESHOLD = 3
162
+
163
+ function hypot(dx: number, dy: number): number {
164
+ return Math.sqrt(dx * dx + dy * dy)
165
+ }
166
+
167
+ function eventTranslation(
168
+ event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
169
+ originX: number,
170
+ originY: number,
171
+ transform?: HubPagePointTransform,
172
+ ): HubPanPayload {
173
+ // Prefer recognizer translation when present (after activation); otherwise derive from absolute.
174
+ // ALWAYS carry absoluteX/Y — handlePanStart's buildInfo (REQ-API-020 DragInfo) requires them
175
+ // (device crash 2026-08-05: stripped absolutes → loud absoluteX undefined on hub touch-feed).
176
+ const hasTx = typeof event.translationX === 'number' && Number.isFinite(event.translationX)
177
+ const hasTy = typeof event.translationY === 'number' && Number.isFinite(event.translationY)
178
+ let ax =
179
+ typeof event.absoluteX === 'number' && Number.isFinite(event.absoluteX)
180
+ ? event.absoluteX
181
+ : hasTx
182
+ ? originX + (event.translationX as number)
183
+ : originX
184
+ let ay =
185
+ typeof event.absoluteY === 'number' && Number.isFinite(event.absoluteY)
186
+ ? event.absoluteY
187
+ : hasTy
188
+ ? originY + (event.translationY as number)
189
+ : originY
190
+ // Ingest-boundary page-point correction (REQ-API-043): absolute coords only — translations
191
+ // stay screen-delta so dead-zone / distanceThreshold stay correlation-space consistent.
192
+ // Transform throws are reported then rethrown for the caller to terminate (G-INV-7/8;
193
+ // seal review r7 major — never silent raw-coordinate fallback).
194
+ if (transform !== undefined) {
195
+ const corrected = transform({ x: ax, y: ay })
196
+ ax = corrected.x
197
+ ay = corrected.y
198
+ }
199
+ if (hasTx && hasTy) {
200
+ return {
201
+ absoluteX: ax,
202
+ absoluteY: ay,
203
+ translationX: event.translationX,
204
+ translationY: event.translationY,
205
+ velocityX: typeof event.velocityX === 'number' ? event.velocityX : 0,
206
+ velocityY: typeof event.velocityY === 'number' ? event.velocityY : 0,
207
+ }
208
+ }
209
+ return {
210
+ absoluteX: ax,
211
+ absoluteY: ay,
212
+ translationX: ax - originX,
213
+ translationY: ay - originY,
214
+ velocityX: typeof event.velocityX === 'number' ? event.velocityX : 0,
215
+ velocityY: typeof event.velocityY === 'number' ? event.velocityY : 0,
216
+ }
217
+ }
218
+
219
+ function originPayload(session: ArmedHubSession): HubPanPayload {
220
+ // Prefer lastPayload; origin is only used when no move landed. Transform failure here
221
+ // must not throw out of terminate and must not call report (dev report throws before
222
+ // cleanup — seal review r8 major). Raw origin is the safe terminal fallback.
223
+ let ax = session.originAbsoluteX
224
+ let ay = session.originAbsoluteY
225
+ if (session.transformPagePoint !== undefined) {
226
+ try {
227
+ const corrected = session.transformPagePoint({ x: ax, y: ay })
228
+ ax = corrected.x
229
+ ay = corrected.y
230
+ } catch {
231
+ // Keep raw origin — never throw / report from this pure helper.
232
+ }
233
+ }
234
+ return {
235
+ absoluteX: ax,
236
+ absoluteY: ay,
237
+ translationX: 0,
238
+ translationY: 0,
239
+ velocityX: 0,
240
+ velocityY: 0,
241
+ }
242
+ }
243
+
244
+ function asError(error: unknown, fallback: string): Error {
245
+ return error instanceof Error ? error : new Error(fallback)
246
+ }
247
+
248
+ /** Sentinel distinct from every possible thrown value (including `undefined`). */
249
+ const NO_RETAINED = Symbol('dragHubStream.noRetained')
250
+
251
+ /**
252
+ * Create a pure hub stream. MotionRoot owns one instance for the tree lifetime.
253
+ */
254
+ export function createDragHubStream(options?: {
255
+ readonly touchTtlMs?: number
256
+ readonly correlationPx?: number
257
+ readonly now?: () => number
258
+ }): DragHubStream {
259
+ const touchTtlMs = options?.touchTtlMs ?? HUB_TOUCH_TTL_MS
260
+ const correlationPx = options?.correlationPx ?? HUB_TOUCH_CORRELATION_PX
261
+ const now = options?.now ?? (() => Date.now())
262
+
263
+ const recorded = new Map<number, RecordedHubTouch>()
264
+ let armed: ArmedHubSession | null = null
265
+ let armEpoch = 0
266
+
267
+ // U7d tap-observation channel state. Deliberately separate from `recorded` (the drag
268
+ // correlation store): recorded entries TTL-prune, but an observed touch lives exactly
269
+ // down→up/cancel — a long-held touch must not age out of the press's primary tracking.
270
+ const tapObservers = new Set<HubTapObserver>()
271
+ const activeTapTouches = new Map<number, HubTapTouchPoint>()
272
+
273
+ function prune(nowMs: number): void {
274
+ for (const [id, touch] of recorded) {
275
+ if (nowMs - touch.recordedAtMs > touchTtlMs) recorded.delete(id)
276
+ }
277
+ }
278
+
279
+ /** Clear stream arm state after wiring terminal paths (always, G-INV-7). */
280
+ function clearArmed(session: ArmedHubSession): void {
281
+ recorded.delete(session.touchId)
282
+ recorded.delete(-1)
283
+ armEpoch += 1
284
+ armed = null
285
+ }
286
+
287
+ /**
288
+ * Run end then finalize, retaining the first failure. Cleanup always runs first;
289
+ * report/rethrow only AFTER clear so a throwing dev reporter cannot strand armed (r8).
290
+ */
291
+ function endFinalizeRetain(
292
+ session: ArmedHubSession,
293
+ payload: HubPanPayload,
294
+ success: boolean,
295
+ ): unknown {
296
+ let retained: unknown = NO_RETAINED
297
+ if (session.activated) {
298
+ try {
299
+ session.wiring.handlePanEnd(payload, success)
300
+ } catch (error) {
301
+ retained = error
302
+ }
303
+ try {
304
+ session.wiring.handlePanFinalize(payload, success)
305
+ } catch (error) {
306
+ if (retained === NO_RETAINED) retained = error
307
+ }
308
+ }
309
+ return retained
310
+ }
311
+
312
+ function reportAfterClear(session: ArmedHubSession, retained: unknown): void {
313
+ // NO_RETAINED is the only “none” sentinel — `throw undefined` is still a failure (r9 minor).
314
+ if (retained === NO_RETAINED) return
315
+ const err = asError(retained, 'DragControls: terminal wiring callback failed')
316
+ session.report?.(err)
317
+ throw retained === undefined
318
+ ? new Error('DragControls: terminal wiring callback failed (threw undefined)')
319
+ : retained
320
+ }
321
+
322
+ /** Translate event; on transform throw abort session AFTER cleanup (G-INV-7/8; seal r7/r8). */
323
+ function translateOrAbort(
324
+ event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
325
+ ): HubPanPayload | null {
326
+ if (armed === null) return null
327
+ try {
328
+ return eventTranslation(
329
+ event,
330
+ armed.originAbsoluteX,
331
+ armed.originAbsoluteY,
332
+ armed.transformPagePoint,
333
+ )
334
+ } catch (error) {
335
+ // Abort without re-entering transform: use lastPayload or raw origin.
336
+ // NEVER report before cleanup — development report throws (seal r8 major).
337
+ const session = armed
338
+ const payload =
339
+ session.lastPayload ??
340
+ ({
341
+ absoluteX: session.originAbsoluteX,
342
+ absoluteY: session.originAbsoluteY,
343
+ translationX: 0,
344
+ translationY: 0,
345
+ velocityX: 0,
346
+ velocityY: 0,
347
+ } satisfies HubPanPayload)
348
+ const retained = endFinalizeRetain(session, payload, false)
349
+ clearArmed(session)
350
+ // Prefer the transform failure as the primary report; still surface wiring failures.
351
+ const primary = error
352
+ try {
353
+ session.report?.(
354
+ asError(primary, 'DragControls: transformPagePoint failed during pan feed'),
355
+ )
356
+ } catch {
357
+ // report threw (dev) — still rethrow primary below
358
+ }
359
+ if (retained !== NO_RETAINED) {
360
+ try {
361
+ session.report?.(asError(retained, 'DragControls: terminal wiring callback failed'))
362
+ } catch {
363
+ /* primary rethrow wins */
364
+ }
365
+ }
366
+ throw asError(primary, 'DragControls: transformPagePoint failed during pan feed')
367
+ }
368
+ }
369
+
370
+ return {
371
+ touchTtlMs,
372
+ get armed() {
373
+ return armed
374
+ },
375
+ get armEpoch() {
376
+ return armEpoch
377
+ },
378
+
379
+ recordTouch(point, nowMs = now()) {
380
+ prune(nowMs)
381
+ recorded.set(point.id, { point, recordedAtMs: nowMs })
382
+ // Same-frame recovery: hub arm may have used synthetic touch id -1 when Pressable
383
+ // onPressIn beat onTouchesDown. When the real touch lands, rebind armed.touchId so
384
+ // MotionRoot's touchId-filtered feed accepts moves (seal review r4 major). Drop the
385
+ // synthetic -1 entry so a later same-frame start is not blocked by recorded.size > 0
386
+ // with a stale synthetic (seal review r7 major).
387
+ if (
388
+ armed !== null &&
389
+ armed.touchId === -1 &&
390
+ hypot(point.absoluteX - armed.originAbsoluteX, point.absoluteY - armed.originAbsoluteY) <=
391
+ correlationPx
392
+ ) {
393
+ recorded.delete(-1)
394
+ armed = { ...armed, touchId: point.id }
395
+ }
396
+ },
397
+
398
+ forgetTouch(id) {
399
+ recorded.delete(id)
400
+ // Do NOT terminate an activated session here — onTouchesUp fires before recognizer
401
+ // onEnd on Android; ending early drops real velocity (seal review r2 major).
402
+ // Pre-activation arm for this touch is cleared by terminate from onTouchesUp.
403
+ // Synthetic -1: any real-touch forget means the press ended before rebind (Issue 3).
404
+ if (armed !== null && !armed.activated && (armed.touchId === id || armed.touchId === -1)) {
405
+ recorded.delete(-1)
406
+ armed = null
407
+ }
408
+ },
409
+
410
+ arm(input) {
411
+ const nowMs = now()
412
+ prune(nowMs)
413
+ // Same-frame start race (device product walk 2026-08-05): Pressable.onPressIn runs on the
414
+ // JS thread BEFORE the root pan's onTouchesDown worklet→JS hop lands, so `recorded` can
415
+ // still be empty when start() arms. The pin's PanSession does not need a prior record —
416
+ // the start event IS the touch. Synthesize a touch from the start point so arm succeeds;
417
+ // the next onTouchesMove still activates the recognizer (Finding 8).
418
+ if (recorded.size === 0) {
419
+ recorded.set(-1, {
420
+ point: {
421
+ id: -1,
422
+ absoluteX: input.startPoint.absoluteX,
423
+ absoluteY: input.startPoint.absoluteY,
424
+ },
425
+ recordedAtMs: nowMs,
426
+ })
427
+ }
428
+ // Nearest recorded touch within correlation radius.
429
+ let best: { id: number; dist: number; touch: RecordedHubTouch } | null = null
430
+ let secondDist = Number.POSITIVE_INFINITY
431
+ for (const [id, touch] of recorded) {
432
+ const dist = hypot(
433
+ touch.point.absoluteX - input.startPoint.absoluteX,
434
+ touch.point.absoluteY - input.startPoint.absoluteY,
435
+ )
436
+ if (dist > correlationPx) continue
437
+ if (best === null || dist < best.dist) {
438
+ secondDist = best?.dist ?? Number.POSITIVE_INFINITY
439
+ best = { id, dist, touch }
440
+ } else if (dist < secondDist) {
441
+ secondDist = dist
442
+ }
443
+ }
444
+ if (best === null) {
445
+ input.report?.(
446
+ new Error(
447
+ `DragControls.start: no recorded touch within ${correlationPx}px of the start point ` +
448
+ `(REQ-GESTURE-026; packet Finding 8).`,
449
+ ),
450
+ )
451
+ return false
452
+ }
453
+ // Ambiguous: two touches equally near (within 1px of each other as nearest).
454
+ if (Number.isFinite(secondDist) && Math.abs(secondDist - best.dist) < 1) {
455
+ input.report?.(
456
+ new Error(
457
+ 'DragControls.start: ambiguous multi-touch correlation — refuse rather than guess ' +
458
+ '(REQ-GESTURE-026; packet Finding 8).',
459
+ ),
460
+ )
461
+ return false
462
+ }
463
+ // One session at a time: terminal any prior arm BEFORE overwrite so an activated
464
+ // session cannot strand the app-global lock (adversarial seal Issue 4).
465
+ // Multi-subscriber last-writer remains documented; this makes re-arm well-defined.
466
+ if (armed !== null) {
467
+ try {
468
+ terminateSession(false)
469
+ } catch {
470
+ // Prior terminal failures are reported inside terminate; still install the new arm.
471
+ }
472
+ }
473
+ armEpoch += 1
474
+ armed = {
475
+ touchId: best.id,
476
+ originAbsoluteX: best.touch.point.absoluteX,
477
+ originAbsoluteY: best.touch.point.absoluteY,
478
+ wiring: input.wiring,
479
+ options: input.options,
480
+ activated: false,
481
+ pendingSnap: input.pendingSnap === true,
482
+ startAbsoluteX: input.startPoint.absoluteX,
483
+ startAbsoluteY: input.startPoint.absoluteY,
484
+ transformPagePoint: input.transformPagePoint,
485
+ report: input.report,
486
+ lastPayload: null,
487
+ armEpoch,
488
+ }
489
+ return true
490
+ },
491
+
492
+ onPanStart(event) {
493
+ if (armed === null) return
494
+ const translated = translateOrAbort(event)
495
+ if (translated === null) return
496
+ armed.lastPayload = translated
497
+ if (armed.pendingSnap) return // hold until snapToCursor lands
498
+ const threshold =
499
+ typeof armed.options.distanceThreshold === 'number'
500
+ ? armed.options.distanceThreshold
501
+ : HUB_DEFAULT_DISTANCE_THRESHOLD
502
+ const moved = hypot(
503
+ typeof translated.translationX === 'number' ? translated.translationX : 0,
504
+ typeof translated.translationY === 'number' ? translated.translationY : 0,
505
+ )
506
+ if (moved < threshold && !armed.activated) {
507
+ // Below threshold: wait for updates (pin distanceThreshold gate).
508
+ return
509
+ }
510
+ if (!armed.activated) {
511
+ armed.activated = true
512
+ armed.wiring.handlePanStart(translated)
513
+ }
514
+ },
515
+
516
+ onPanUpdate(event) {
517
+ if (armed === null) return
518
+ const translated = translateOrAbort(event)
519
+ if (translated === null) return
520
+ armed.lastPayload = translated
521
+ if (armed.pendingSnap) return // hold until snapToCursor lands
522
+ const threshold =
523
+ typeof armed.options.distanceThreshold === 'number'
524
+ ? armed.options.distanceThreshold
525
+ : HUB_DEFAULT_DISTANCE_THRESHOLD
526
+ if (!armed.activated) {
527
+ const moved = hypot(
528
+ typeof translated.translationX === 'number' ? translated.translationX : 0,
529
+ typeof translated.translationY === 'number' ? translated.translationY : 0,
530
+ )
531
+ if (moved < threshold) return
532
+ armed.activated = true
533
+ armed.wiring.handlePanStart(translated)
534
+ }
535
+ armed.wiring.handlePanUpdate(translated)
536
+ },
537
+
538
+ onPanEnd(event, success) {
539
+ if (armed === null) return
540
+ const translated = translateOrAbort(event)
541
+ if (translated === null) return
542
+ armed.lastPayload = translated
543
+ if (armed.activated) {
544
+ // End may throw (consumer onDragEnd) — leave armed for finalize (RNGH ordering).
545
+ armed.wiring.handlePanEnd(translated, success)
546
+ }
547
+ // Keep armed until finalize (RNGH ordering).
548
+ },
549
+
550
+ onPanFinalize(event, success) {
551
+ if (armed === null) return
552
+ // Always clear armed even if finalize/end wiring throws (G-INV-7; seal review r6 major).
553
+ // Transform failure still finalizes with lastPayload/raw origin so the app-global lock
554
+ // releases (seal review r8 major).
555
+ const session = armed
556
+ let retained: unknown = NO_RETAINED
557
+ let transformError: unknown = NO_RETAINED
558
+ let translated: HubPanPayload
559
+ try {
560
+ translated = eventTranslation(
561
+ event,
562
+ session.originAbsoluteX,
563
+ session.originAbsoluteY,
564
+ session.transformPagePoint,
565
+ )
566
+ } catch (error) {
567
+ transformError = error
568
+ translated =
569
+ session.lastPayload ??
570
+ ({
571
+ absoluteX: session.originAbsoluteX,
572
+ absoluteY: session.originAbsoluteY,
573
+ translationX: 0,
574
+ translationY: 0,
575
+ velocityX: 0,
576
+ velocityY: 0,
577
+ } satisfies HubPanPayload)
578
+ }
579
+ if (session.activated) {
580
+ try {
581
+ session.wiring.handlePanFinalize(translated, success)
582
+ } catch (error) {
583
+ retained = error
584
+ }
585
+ }
586
+ clearArmed(session)
587
+ if (transformError !== NO_RETAINED) {
588
+ try {
589
+ session.report?.(
590
+ asError(transformError, 'DragControls: transformPagePoint failed at finalize'),
591
+ )
592
+ } catch {
593
+ /* report may throw in development */
594
+ }
595
+ }
596
+ if (retained !== NO_RETAINED) reportAfterClear(session, retained)
597
+ if (transformError !== NO_RETAINED) {
598
+ throw transformError === undefined
599
+ ? new Error('DragControls: transformPagePoint failed at finalize (threw undefined)')
600
+ : asError(transformError, 'DragControls: transformPagePoint failed at finalize')
601
+ }
602
+ },
603
+
604
+ releaseSnapGate() {
605
+ if (armed === null || !armed.pendingSnap) return
606
+ armed.pendingSnap = false
607
+ // If a move was held, flush it as the first start/update so the session begins
608
+ // after the sanctioned jump (REQ-GESTURE-026).
609
+ const held = armed.lastPayload
610
+ if (held === null) return
611
+ const threshold =
612
+ typeof armed.options.distanceThreshold === 'number'
613
+ ? armed.options.distanceThreshold
614
+ : HUB_DEFAULT_DISTANCE_THRESHOLD
615
+ if (!armed.activated) {
616
+ const moved = hypot(
617
+ typeof held.translationX === 'number' ? held.translationX : 0,
618
+ typeof held.translationY === 'number' ? held.translationY : 0,
619
+ )
620
+ if (moved < threshold) return
621
+ armed.activated = true
622
+ armed.wiring.handlePanStart(held)
623
+ }
624
+ armed.wiring.handlePanUpdate(held)
625
+ },
626
+
627
+ terminate(success) {
628
+ terminateSession(success)
629
+ },
630
+
631
+ disarm() {
632
+ if (armed !== null) armEpoch += 1
633
+ armed = null
634
+ },
635
+
636
+ subscribeTapObserver(observer) {
637
+ tapObservers.add(observer)
638
+ return () => {
639
+ tapObservers.delete(observer)
640
+ }
641
+ },
642
+
643
+ notifyTapDown(touch) {
644
+ // The hub stream's multi-touch law: the FIRST touch of a sequence is the primary
645
+ // pointer (pin isPrimaryPointer); every concurrent later touch is secondary.
646
+ const primary = activeTapTouches.size === 0
647
+ activeTapTouches.set(touch.id, touch)
648
+ for (const observer of tapObservers) observer.onTouchDown(touch, primary)
649
+ },
650
+
651
+ notifyTapUp(touch) {
652
+ // Untracked id (a down this channel never saw) notifies nothing — no phantom events.
653
+ if (!activeTapTouches.delete(touch.id)) return
654
+ for (const observer of tapObservers) observer.onTouchUp(touch)
655
+ },
656
+
657
+ notifyTapCancel(touch) {
658
+ if (!activeTapTouches.delete(touch.id)) return
659
+ for (const observer of tapObservers) observer.onTouchCancel(touch)
660
+ },
661
+ }
662
+
663
+ /**
664
+ * Shared terminal path for `terminate` and arm-overwrite (Issue 4).
665
+ * Always clear armed + bump epoch even if wiring callbacks throw (G-INV-7).
666
+ * End and finalize run separately so an onDragEnd throw still reaches finalize
667
+ * (lock release) — seal review r6. Report/rethrow only AFTER clear (seal r8).
668
+ */
669
+ function terminateSession(success: boolean): void {
670
+ if (armed === null) return
671
+ const session = armed
672
+ // Flush pending snap gate before terminal: a fast gesture can touch-up before
673
+ // measureInWindow lands; without this the held threshold-crossing move is dropped
674
+ // and activated never becomes true so end/finalize are skipped (seal review r11 major).
675
+ if (session.pendingSnap) {
676
+ session.pendingSnap = false
677
+ const held = session.lastPayload
678
+ if (held !== null && !session.activated) {
679
+ const threshold =
680
+ typeof session.options.distanceThreshold === 'number'
681
+ ? session.options.distanceThreshold
682
+ : HUB_DEFAULT_DISTANCE_THRESHOLD
683
+ const moved = hypot(
684
+ typeof held.translationX === 'number' ? held.translationX : 0,
685
+ typeof held.translationY === 'number' ? held.translationY : 0,
686
+ )
687
+ if (moved >= threshold) {
688
+ session.activated = true
689
+ try {
690
+ session.wiring.handlePanStart(held)
691
+ // Mirror releaseSnapGate: held sample is also the first update.
692
+ session.wiring.handlePanUpdate(held)
693
+ } catch (error) {
694
+ // Start/update threw after sessions may hold the lock — finalize MUST run
695
+ // so the app-global lock frees (G-INV-7; adversarial seal Issue 1).
696
+ const payload = held
697
+ const finalizeRetained = endFinalizeRetain(session, payload, false)
698
+ clearArmed(session)
699
+ // Prefer the start failure as primary; still surface finalize failure.
700
+ try {
701
+ session.report?.(
702
+ asError(error, 'DragControls: handlePanStart failed during pendingSnap flush'),
703
+ )
704
+ } catch {
705
+ /* report may throw in development */
706
+ }
707
+ if (finalizeRetained !== NO_RETAINED) {
708
+ try {
709
+ session.report?.(
710
+ asError(finalizeRetained, 'DragControls: terminal wiring callback failed'),
711
+ )
712
+ } catch {
713
+ /* primary rethrow wins */
714
+ }
715
+ }
716
+ throw asError(error, 'DragControls: handlePanStart failed during pendingSnap flush')
717
+ }
718
+ }
719
+ }
720
+ }
721
+ const payload = session.lastPayload ?? originPayload(session)
722
+ const retained = endFinalizeRetain(session, payload, success)
723
+ clearArmed(session)
724
+ reportAfterClear(session, retained)
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Extract a screen-space point from a hub start event (RN GestureResponderEvent or PointerEvent).
730
+ * Fail-closed: returns null when no usable coordinates.
731
+ */
732
+ export function extractHubStartPoint(
733
+ event: unknown,
734
+ ): { readonly absoluteX: number; readonly absoluteY: number } | null {
735
+ if (event === null || event === undefined || typeof event !== 'object') return null
736
+ const e = event as Record<string, unknown>
737
+ // React Native: nativeEvent.pageX/pageY
738
+ const native = e['nativeEvent']
739
+ if (native !== null && native !== undefined && typeof native === 'object') {
740
+ const n = native as Record<string, unknown>
741
+ if (typeof n['pageX'] === 'number' && typeof n['pageY'] === 'number') {
742
+ return { absoluteX: n['pageX'], absoluteY: n['pageY'] }
743
+ }
744
+ if (typeof n['absoluteX'] === 'number' && typeof n['absoluteY'] === 'number') {
745
+ return { absoluteX: n['absoluteX'], absoluteY: n['absoluteY'] }
746
+ }
747
+ }
748
+ // DOM PointerEvent / React PointerEvent
749
+ if (typeof e['pageX'] === 'number' && typeof e['pageY'] === 'number') {
750
+ return { absoluteX: e['pageX'], absoluteY: e['pageY'] }
751
+ }
752
+ if (typeof e['clientX'] === 'number' && typeof e['clientY'] === 'number') {
753
+ return { absoluteX: e['clientX'], absoluteY: e['clientY'] }
754
+ }
755
+ return null
756
+ }