@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,685 @@
1
+ // M2.4 gesture-binding checks (specs/M2.4-BUILD-PACKET.md §pinned semantics). All seven rows drive
2
+ // the binding with SYNTHETIC Pan-event shapes + a ManualClock/ManualScheduler graph — fully
3
+ // deterministic, no real touches (the live legs are the smoke: adb swipes on Android, a replayed
4
+ // stream on iOS). A screenshot is the assertion; try/catch is evidence capture, never suppression.
5
+
6
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
7
+ import {
8
+ ManualClock,
9
+ ManualScheduler,
10
+ createMotionGraph,
11
+ type MotionGraph,
12
+ } from '@unrulysystems/native-motion-core'
13
+ import {
14
+ createGestureBinding,
15
+ routePanEnd,
16
+ routePanFinalize,
17
+ type GestureBinding,
18
+ } from '../../native/motion/gestureBinding'
19
+ import {
20
+ createTapGestureWiring,
21
+ createGlobalTapObserver,
22
+ gatePostMountTapArrival,
23
+ gateTapCallbackProps,
24
+ } from '../../native/motion/tapGestureWiring'
25
+ import { createDragHubStream } from '../../native/motion/dragHubStream'
26
+ import { createGestureStateResolver } from '../../native/motion/gestureStateResolver'
27
+ import { gateGestureStateProps } from '../../native/motion/gestureStateGate'
28
+
29
+ const DT = 16.67
30
+
31
+ interface Row {
32
+ readonly id: string
33
+ readonly pass: boolean
34
+ readonly detail: string
35
+ }
36
+
37
+ interface Rig {
38
+ readonly graph: MotionGraph
39
+ readonly clock: ManualClock
40
+ readonly scheduler: ManualScheduler
41
+ }
42
+
43
+ function rig(): Rig {
44
+ const clock = new ManualClock()
45
+ const scheduler = new ManualScheduler(clock)
46
+ return { graph: createMotionGraph({ clock, scheduler }), clock, scheduler }
47
+ }
48
+
49
+ const SNAPPED = {
50
+ constraints: { min: 0, max: 240, elastic: 0.4 },
51
+ snap: { points: [0, 240] as const },
52
+ } as const
53
+
54
+ function bindingWith(
55
+ r: Rig,
56
+ extra: Partial<Parameters<typeof createGestureBinding>[0]> = {},
57
+ ): GestureBinding {
58
+ return createGestureBinding({
59
+ graph: r.graph,
60
+ clock: r.clock,
61
+ initial: 0,
62
+ constraints: SNAPPED.constraints,
63
+ snap: { points: [...SNAPPED.snap.points] },
64
+ ...extra,
65
+ })
66
+ }
67
+
68
+ function checkDeadZone(): Row {
69
+ const id = 'activation dead-zone: no jump at grab'
70
+ try {
71
+ const r = rig()
72
+ const b = bindingWith(r)
73
+ b.onStart({ translationX: 12, velocityX: 0 }) // recognizer already moved 12px to activate
74
+ if (b.session.value() !== 0) {
75
+ return { id, pass: false, detail: `value jumped to ${b.session.value()} at begin, want 0` }
76
+ }
77
+ b.onUpdate({ translationX: 13, velocityX: 60 })
78
+ const first = b.session.value()
79
+ if (Math.abs(first - 1) > 1e-9) {
80
+ return {
81
+ id,
82
+ pass: false,
83
+ detail: `first active frame value=${first}, want 1 (13−12 from grab)`,
84
+ }
85
+ }
86
+ return {
87
+ id,
88
+ pass: true,
89
+ detail: 'begin at translation 12 → value 0; first frame 13 → value 1 (C0)',
90
+ }
91
+ } catch (e) {
92
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
93
+ }
94
+ }
95
+
96
+ function checkDragFollowAndHandoff(): Row {
97
+ const id = 'drag-follow; §G platform velocity passes UNSCALED'
98
+ try {
99
+ const r = rig()
100
+ const b = bindingWith(r)
101
+ b.onStart({ translationX: 0, velocityX: 0 })
102
+ for (let translation = 20; translation <= 200; translation += 20) {
103
+ b.onUpdate({ translationX: translation, velocityX: 500 })
104
+ r.scheduler.frame(DT)
105
+ }
106
+ if (Math.abs(b.session.value() - 200) > 1e-9) {
107
+ return { id, pass: false, detail: `value ${b.session.value()} after dragging to 200` }
108
+ }
109
+ b.onEnd({ translationX: 200, velocityX: 833.25 })
110
+ const handoff = b.session.handoffVelocity()
111
+ if (handoff !== 833.25) {
112
+ return {
113
+ id,
114
+ pass: false,
115
+ detail: `handoff ${String(handoff)}, want the platform's exact 833.25 (§G)`,
116
+ }
117
+ }
118
+ return { id, pass: true, detail: 'followed to 200; handoff === 833.25 exactly (unscaled)' }
119
+ } catch (e) {
120
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
121
+ }
122
+ }
123
+
124
+ function checkReleaseSnap(): Row {
125
+ const id = 'release settles at the projected snap point'
126
+ try {
127
+ const r = rig()
128
+ const b = bindingWith(r)
129
+ b.onStart({ translationX: 0, velocityX: 0 })
130
+ b.onUpdate({ translationX: 150, velocityX: 900 })
131
+ b.onEnd({ translationX: 150, velocityX: 900 }) // fast rightward release from 150
132
+ r.scheduler.frames(240, DT)
133
+ const settled = b.session.value()
134
+ if (Math.abs(settled - 240) > 0.5 || !b.session.isSettled()) {
135
+ return {
136
+ id,
137
+ pass: false,
138
+ detail: `settled at ${settled} (settled=${String(b.session.isSettled())}), want snap 240`,
139
+ }
140
+ }
141
+ if (b.session.state() !== 'idle') {
142
+ return { id, pass: false, detail: `state ${b.session.state()} after settle, want idle` }
143
+ }
144
+ return {
145
+ id,
146
+ pass: true,
147
+ detail: 'released at 150 w/ +900 px/s → snapped to 240, settled, idle',
148
+ }
149
+ } catch (e) {
150
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
151
+ }
152
+ }
153
+
154
+ function checkConstraints(): Row {
155
+ const id = 'constraints: hard clamp at 0, rubber-band above 0'
156
+ try {
157
+ const rHard = rig()
158
+ const hard = bindingWith(rHard, { constraints: { min: 0, max: 240, elastic: 0 } })
159
+ hard.onStart({ translationX: 0, velocityX: 0 })
160
+ hard.onUpdate({ translationX: 300, velocityX: 0 })
161
+ const clamped = hard.session.value()
162
+ if (clamped !== 240)
163
+ return { id, pass: false, detail: `elastic 0: value ${clamped}, want hard 240` }
164
+ const rSoft = rig()
165
+ const soft = bindingWith(rSoft, { constraints: { min: 0, max: 240, elastic: 0.5 } })
166
+ soft.onStart({ translationX: 0, velocityX: 0 })
167
+ soft.onUpdate({ translationX: 300, velocityX: 0 })
168
+ const banded = soft.session.value()
169
+ if (!(banded > 240 && banded < 300)) {
170
+ return {
171
+ id,
172
+ pass: false,
173
+ detail: `elastic 0.5: value ${banded}, want strictly between 240 and 300`,
174
+ }
175
+ }
176
+ return { id, pass: true, detail: `clamped 300→240; rubber-banded 300→${banded.toFixed(1)}` }
177
+ } catch (e) {
178
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
179
+ }
180
+ }
181
+
182
+ function checkRegrabContinuity(): Row {
183
+ const id = 'mid-settle re-grab seeds from the live value'
184
+ try {
185
+ const r = rig()
186
+ const b = bindingWith(r)
187
+ b.onStart({ translationX: 0, velocityX: 0 })
188
+ b.onUpdate({ translationX: 150, velocityX: 900 })
189
+ b.onEnd({ translationX: 150, velocityX: 900 })
190
+ r.scheduler.frames(6, DT) // spring in flight toward 240
191
+ const live = b.session.value()
192
+ if (!(live > 150 && live < 240)) {
193
+ return { id, pass: false, detail: `live value ${live} not mid-flight` }
194
+ }
195
+ b.onStart({ translationX: 30, velocityX: 0 }) // re-grab mid-settle (nonzero activation offset)
196
+ if (b.session.state() !== 'active') {
197
+ return { id, pass: false, detail: `state ${b.session.state()} after re-grab, want active` }
198
+ }
199
+ const atGrab = b.session.value()
200
+ if (Math.abs(atGrab - live) > 1e-6) {
201
+ return {
202
+ id,
203
+ pass: false,
204
+ detail: `re-grab snapped ${live.toFixed(2)} → ${atGrab.toFixed(2)}`,
205
+ }
206
+ }
207
+ return { id, pass: true, detail: `re-grabbed at live ${live.toFixed(1)}, no snap-back, active` }
208
+ } catch (e) {
209
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
210
+ }
211
+ }
212
+
213
+ function checkCancelFailAndFreeDrag(): Row {
214
+ const id = 'cancel/fail → pre-grab; free-drag release projects (REQ-GESTURE-023)'
215
+ try {
216
+ const r = rig()
217
+ const b = bindingWith(r)
218
+ b.onStart({ translationX: 0, velocityX: 0 })
219
+ b.onUpdate({ translationX: 120, velocityX: 0 })
220
+ b.onCancel()
221
+ r.scheduler.frames(240, DT)
222
+ if (Math.abs(b.session.value()) > 0.5 || !b.session.isSettled()) {
223
+ return { id, pass: false, detail: `cancel settled at ${b.session.value()}, want pre-grab 0` }
224
+ }
225
+ const r2 = rig()
226
+ const b2 = bindingWith(r2)
227
+ b2.onStart({ translationX: 0, velocityX: 0 })
228
+ b2.onUpdate({ translationX: 80, velocityX: 0 })
229
+ b2.onFail()
230
+ r2.scheduler.frames(240, DT)
231
+ if (Math.abs(b2.session.value()) > 0.5) {
232
+ return { id, pass: false, detail: `fail settled at ${b2.session.value()}, want pre-grab 0` }
233
+ }
234
+ // Free-drag (no snap, no constraints): release settles at project(50, 100) = 130.
235
+ const r3 = rig()
236
+ const free = createGestureBinding({ graph: r3.graph, clock: r3.clock, initial: 0 })
237
+ free.onStart({ translationX: 0, velocityX: 0 })
238
+ free.onUpdate({ translationX: 50, velocityX: 100 })
239
+ free.onEnd({ translationX: 50, velocityX: 100 })
240
+ r3.scheduler.frames(400, DT)
241
+ if (Math.abs(free.session.value() - 130) > 1 || !free.session.isSettled()) {
242
+ return {
243
+ id,
244
+ pass: false,
245
+ detail: `free-drag settled at ${free.session.value()}, want ~130`,
246
+ }
247
+ }
248
+ return {
249
+ id,
250
+ pass: true,
251
+ detail: 'cancel + fail settle at pre-grab; free-drag release projects to ideal',
252
+ }
253
+ } catch (e) {
254
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
255
+ }
256
+ }
257
+
258
+ function checkMalformedEventGuard(): Row {
259
+ const id = 'malformed Pan events fail loud at the binding'
260
+ try {
261
+ const r = rig()
262
+ const b = bindingWith(r)
263
+ b.onStart({ translationX: 0, velocityX: 0 })
264
+ let missingError: unknown = null
265
+ try {
266
+ b.onUpdate({ velocityX: 5 })
267
+ } catch (e) {
268
+ missingError = e
269
+ }
270
+ const missingOk = missingError instanceof Error && missingError.message.includes('translationX')
271
+ let badTypeError: unknown = null
272
+ try {
273
+ b.onUpdate({ translationX: 5, velocityX: 'fast' })
274
+ } catch (e) {
275
+ badTypeError = e
276
+ }
277
+ const badTypeOk = badTypeError instanceof Error && badTypeError.message.includes('velocityX')
278
+ if (!missingOk || !badTypeOk) {
279
+ return {
280
+ id,
281
+ pass: false,
282
+ detail: `missing→${missingError instanceof Error ? missingError.message.slice(0, 50) : 'no throw'}; badType→${badTypeError instanceof Error ? badTypeError.message.slice(0, 50) : 'no throw'}`,
283
+ }
284
+ }
285
+ return {
286
+ id,
287
+ pass: true,
288
+ detail: 'missing translationX and non-numeric velocityX both throw naming the field',
289
+ }
290
+ } catch (e) {
291
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
292
+ }
293
+ }
294
+
295
+ // R5 (REQ-API-030): the tap protocol rows — the recognizer wiring exercised with scripted
296
+ // substrate event sequences (real-touch evidence rides the Android proof leg; the iOS leg
297
+ // self-describes through these rows, R5-F2).
298
+ function checkTapProtocol(): Row {
299
+ const id = 'tap protocol: up-target law (leave-and-return taps; release-outside cancels)'
300
+ try {
301
+ const events: string[] = []
302
+ const wiring = createTapGestureWiring({
303
+ callbacks: {
304
+ onTapStart: () => events.push('start'),
305
+ onTap: (_event, info) => events.push(`tap@${info.point.x},${info.point.y}`),
306
+ onTapCancel: () => events.push('cancel'),
307
+ },
308
+ getElementSize: () => ({ width: 100, height: 100 }),
309
+ })
310
+ const at = (x: number, y: number, absoluteX = x, absoluteY = y) => ({
311
+ x,
312
+ y,
313
+ absoluteX,
314
+ absoluteY,
315
+ })
316
+ wiring.handleBegin(at(12, 34))
317
+ wiring.handleEnd(at(12, 34), true) // release inside — leave-and-return still lands here
318
+ wiring.handleFinalize(at(12, 34), true)
319
+ wiring.handleBegin(at(5, 5))
320
+ wiring.handleEnd(at(200, 5), true) // recognizer ended; the UP target is OFF the element
321
+ wiring.handleFinalize(at(200, 5), true)
322
+ wiring.handleFinalize(at(0, 0), false) // never began — nothing
323
+ const want = ['start', 'tap@12,34', 'start', 'cancel']
324
+ if (events.join('|') !== want.join('|')) {
325
+ return { id, pass: false, detail: `got ${events.join('|')}, want ${want.join('|')}` }
326
+ }
327
+ return { id, pass: true, detail: 'up-target verdict: inside taps, outside cancels, once each' }
328
+ } catch (e) {
329
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
330
+ }
331
+ }
332
+
333
+ // U7d (REQ-API-030 global form): the global press observer, exercised through the REAL
334
+ // stack — root hub stream → tap-observation channel → global observer → wiring
335
+ // (globalTarget: true) — with scripted root touches. The pinned law (motion-dom
336
+ // press/index.ts: useGlobalTarget window listener + the up-verdict short-circuit): a down
337
+ // ANYWHERE under the root begins the press (containment never read), EVERY up resolves it,
338
+ // TapInfo.point carries the absolute up point; start-gates (non-primary, active drag) refuse;
339
+ // cancel arrives only from the touch-cancel path; observation is unconditional — no
340
+ // recognizer claim suppresses it (packet F1/F2).
341
+ function checkGlobalTapObserver(): Row {
342
+ const id = 'global tap: root-outside down begins; up-anywhere resolves; cancel only via stream'
343
+ try {
344
+ const events: string[] = []
345
+ let dragActive = false
346
+ const wiring = createTapGestureWiring({
347
+ callbacks: {
348
+ onTapStart: () => events.push('start'),
349
+ onTap: (_event, info) => events.push(`tap@${info.point.x},${info.point.y}`),
350
+ onTapCancel: () => events.push('cancel'),
351
+ },
352
+ // A 100x100 element at the origin — every touch below lands OUTSIDE it; the local
353
+ // form would cancel each one at the containment verdict (checkTapProtocol above).
354
+ getElementSize: () => ({ width: 100, height: 100 }),
355
+ globalTarget: true,
356
+ })
357
+ const observer = createGlobalTapObserver({ wiring, isDragActive: () => dragActive })
358
+ const stream = createDragHubStream()
359
+ stream.subscribeTapObserver(observer)
360
+ const touch = (id: number, x: number, y: number) => ({ id, absoluteX: x, absoluteY: y })
361
+
362
+ // Root-outside down BEGINS the press; the up lands far off-element and still resolves,
363
+ // with TapInfo.point carrying the absolute up point.
364
+ stream.notifyTapDown(touch(1, 400, 500))
365
+ stream.notifyTapUp(touch(1, 900, 50))
366
+ // The multi-touch law: a second (non-primary) touch neither starts nor resolves the
367
+ // press — only the touch that began it does.
368
+ stream.notifyTapDown(touch(1, 10, 10))
369
+ stream.notifyTapDown(touch(2, 20, 20))
370
+ stream.notifyTapUp(touch(2, 20, 20))
371
+ stream.notifyTapUp(touch(1, 10, 10))
372
+ // The start-gate: no press begins while a drag is active.
373
+ dragActive = true
374
+ stream.notifyTapDown(touch(1, 30, 30))
375
+ stream.notifyTapUp(touch(1, 30, 30))
376
+ dragActive = false
377
+ // Cancel arrives only from the touch-cancel path; the id is forgotten, so its later up
378
+ // (and an id the stream never saw) notify nothing.
379
+ stream.notifyTapDown(touch(1, 40, 40))
380
+ stream.notifyTapCancel(touch(1, 40, 40))
381
+ stream.notifyTapUp(touch(1, 40, 40))
382
+ stream.notifyTapUp(touch(99, 0, 0))
383
+
384
+ const want = ['start', 'tap@900,50', 'start', 'tap@10,10', 'start', 'cancel']
385
+ if (events.join('|') !== want.join('|')) {
386
+ return { id, pass: false, detail: `got ${events.join('|')}, want ${want.join('|')}` }
387
+ }
388
+ return {
389
+ id,
390
+ pass: true,
391
+ detail:
392
+ 'outside down + far outside up tapped (@900,50); 2nd finger inert; drag-gated; cancel-path only',
393
+ }
394
+ } catch (e) {
395
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
396
+ }
397
+ }
398
+
399
+ function checkTapBoundaryGates(): Row {
400
+ const id = 'tap boundary: malformed callback + post-mount arrival fail loud'
401
+ try {
402
+ let malformed: unknown = null
403
+ try {
404
+ gateTapCallbackProps({ onTap: 42 as never })
405
+ } catch (e) {
406
+ malformed = e
407
+ }
408
+ let postMount: unknown = null
409
+ try {
410
+ gatePostMountTapArrival(false, true, undefined, '<Motion.View>')
411
+ } catch (e) {
412
+ postMount = e
413
+ }
414
+ const malformedOk = malformed instanceof Error && malformed.message.includes('onTap')
415
+ const postMountOk = postMount instanceof Error && postMount.message.includes('fixed at mount')
416
+ if (!malformedOk || !postMountOk) {
417
+ return {
418
+ id,
419
+ pass: false,
420
+ detail: `malformed→${malformed instanceof Error ? 'typed' : 'no throw'}; postMount→${postMount instanceof Error ? 'typed' : 'no throw'}`,
421
+ }
422
+ }
423
+ return { id, pass: true, detail: 'both boundary violations throw typed, named errors' }
424
+ } catch (e) {
425
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
426
+ }
427
+ }
428
+
429
+ function checkPanEndRouting(): Row {
430
+ // Review-cycle-1 major 1: RNGH fires onEnd(event, success=false) for FAILED/CANCELLED gestures
431
+ // BEFORE onFinalize. An unsuccessful end must NOT release — the session stays active so the
432
+ // finalize path cancels to the pre-grab value; a successful end releases normally.
433
+ const id = 'RNGH end routing: unsuccessful end cancels, never releases'
434
+ try {
435
+ const r = rig()
436
+ const b = bindingWith(r)
437
+ b.onStart({ translationX: 0, velocityX: 0 })
438
+ b.onUpdate({ translationX: 120, velocityX: 400 })
439
+ routePanEnd(b, { translationX: 120, velocityX: 400 }, false) // FAILED/CANCELLED end
440
+ if (b.session.state() !== 'active') {
441
+ return {
442
+ id,
443
+ pass: false,
444
+ detail: `unsuccessful end moved state to ${b.session.state()}, want active`,
445
+ }
446
+ }
447
+ routePanFinalize(b, false)
448
+ r.scheduler.frames(240, DT)
449
+ if (Math.abs(b.session.value()) > 0.5 || !b.session.isSettled()) {
450
+ return {
451
+ id,
452
+ pass: false,
453
+ detail: `cancel path settled at ${b.session.value()}, want pre-grab 0`,
454
+ }
455
+ }
456
+ const r2 = rig()
457
+ const b2 = bindingWith(r2)
458
+ b2.onStart({ translationX: 0, velocityX: 0 })
459
+ b2.onUpdate({ translationX: 150, velocityX: 900 })
460
+ routePanEnd(b2, { translationX: 150, velocityX: 900 }, true) // successful release
461
+ routePanFinalize(b2, true)
462
+ r2.scheduler.frames(240, DT)
463
+ if (Math.abs(b2.session.value() - 240) > 0.5) {
464
+ return {
465
+ id,
466
+ pass: false,
467
+ detail: `successful end settled at ${b2.session.value()}, want snap 240`,
468
+ }
469
+ }
470
+ return {
471
+ id,
472
+ pass: true,
473
+ detail: 'end(false) stayed active → finalize cancelled to 0; end(true) released to 240',
474
+ }
475
+ } catch (e) {
476
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
477
+ }
478
+ }
479
+
480
+ // R6 (REQ-API-031): the gesture-STATE rows — the resolver's overlay/revert law and the
481
+ // boundary gates exercised with scripted flips (real-touch evidence rides the Android proof
482
+ // leg; the iOS leg self-describes through these rows, the R5-F2 posture).
483
+ function checkGestureStateResolver(): Row {
484
+ const id = 'gesture state: overlay + revert law (pinned priority; destination transitions)'
485
+ try {
486
+ const SOFT = { type: 'spring', stiffness: 120, damping: 20 } as const
487
+ const ELEMENT = { type: 'spring', stiffness: 300, damping: 30 } as const
488
+ let animateTarget: Record<string, number> = { scale: 1, opacity: 1 }
489
+ const resolver = createGestureStateResolver({
490
+ layers: () => ({
491
+ animate: { target: animateTarget },
492
+ whileTap: { target: { scale: 0.9 }, transition: SOFT },
493
+ whileDrag: { target: { scale: 1.1 } },
494
+ }),
495
+ baseValueFor: () => undefined,
496
+ elementTransition: () => ELEMENT,
497
+ })
498
+ // Every retarget asserts key AND transition (r27 major f69e1db7a2c4: a wrong-property
499
+ // or wrong-transition retarget must FAIL the row — R6-F1's per-key destination law).
500
+ const expectRetarget = (
501
+ label: string,
502
+ got: readonly { key: string; value: unknown; transition: unknown }[],
503
+ want: { key: string; value: number; transition: unknown },
504
+ ): string | null => {
505
+ const one = got.length === 1 ? got[0]! : undefined
506
+ if (
507
+ one === undefined ||
508
+ one.key !== want.key ||
509
+ one.value !== want.value ||
510
+ one.transition !== want.transition
511
+ ) {
512
+ return `${label}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
513
+ }
514
+ return null
515
+ }
516
+ const pressBad = expectRetarget('press', resolver.setActive('whileTap', true), {
517
+ key: 'scale',
518
+ value: 0.9,
519
+ transition: SOFT, // the layer's OWN transition
520
+ })
521
+ if (pressBad !== null) return { id, pass: false, detail: pressBad }
522
+ const dragOverBad = expectRetarget('drag-over', resolver.setActive('whileDrag', true), {
523
+ key: 'scale',
524
+ value: 1.1,
525
+ transition: ELEMENT, // whileDrag has no layer transition — element fallback
526
+ })
527
+ if (dragOverBad !== null) return { id, pass: false, detail: dragOverBad }
528
+ const dragOffBad = expectRetarget('drag-off', resolver.setActive('whileDrag', false), {
529
+ key: 'scale',
530
+ value: 0.9,
531
+ transition: SOFT, // the DESTINATION layer's (whileTap's) transition, R6-F1
532
+ })
533
+ if (dragOffBad !== null) return { id, pass: false, detail: dragOffBad }
534
+ animateTarget = { scale: 0.5, opacity: 1 } // a live rerender while pressed (r4 major 19)
535
+ const releaseBad = expectRetarget('release', resolver.setActive('whileTap', false), {
536
+ key: 'scale',
537
+ value: 0.5,
538
+ transition: ELEMENT, // the animate destination rides the element transition
539
+ })
540
+ if (releaseBad !== null) return { id, pass: false, detail: releaseBad }
541
+ return { id, pass: true, detail: 'overlay 0.9 → drag 1.1 → back to 0.9 → live revert 0.5' }
542
+ } catch (e) {
543
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
544
+ }
545
+ }
546
+
547
+ function checkGestureStateBoundary(): Row {
548
+ const id = 'gesture-state boundary: label resolution + inert options + bound-key XOR'
549
+ try {
550
+ // R7 (REQ-API-032): the label form RESOLVES against the dictionary (the R6-era refusal
551
+ // is superseded); a local miss is the pinned NO-OP — an empty layer, never an error.
552
+ const softTap = { type: 'spring', stiffness: 120, damping: 20 } as const
553
+ const resolved = gateGestureStateProps(
554
+ { whileTap: 'pressed' as never },
555
+ {
556
+ dragAxes: [],
557
+ variantsDictionary: { pressed: { scale: 0.9, transition: softTap } },
558
+ },
559
+ )
560
+ // Structural, never identity: the gate stores a RESOLVED frozen snapshot of the authored
561
+ // transition (major 0cb829 — the accepted layer never holds the caller's object), and the
562
+ // host test (gestureStateGate.test.ts "R7: a label form RESOLVES") asserts toEqual. The
563
+ // earlier `=== softTap` form read the snapshot law as a failure — broken since T19's
564
+ // per-property resolution (daf4fc8f) put resolveTransitionForKey on this path.
565
+ const attribution = resolved.whileTap?.transitions?.['scale']
566
+ const resolvedRight =
567
+ resolved.whileTap !== undefined &&
568
+ (resolved.whileTap.target as { scale?: number }).scale === 0.9 &&
569
+ attribution?.type === 'spring' &&
570
+ (attribution as { stiffness?: number }).stiffness === 120 &&
571
+ (attribution as { damping?: number }).damping === 20
572
+ if (!resolvedRight) {
573
+ return { id, pass: false, detail: 'label must resolve with per-key attribution (law b)' }
574
+ }
575
+ const miss = gateGestureStateProps({ whileTap: 'missing' as never }, { dragAxes: [] })
576
+ const missRight = miss.whileTap !== undefined && Object.keys(miss.whileTap.target).length === 0
577
+ if (!missRight) {
578
+ return { id, pass: false, detail: 'a local miss must be the empty-layer NO-OP (law a)' }
579
+ }
580
+ const delayAccepted = gateGestureStateProps(
581
+ { whileTap: { opacity: 0.5, transition: { delay: 0.2 } } },
582
+ { dragAxes: [] },
583
+ )
584
+ const delayRight =
585
+ delayAccepted.whileTap?.transition?.delay === 0.2 &&
586
+ (delayAccepted.whileTap.target as { opacity?: number }).opacity === 0.5
587
+ if (!delayRight) {
588
+ return { id, pass: false, detail: 'property-lane delay must be accepted and preserved' }
589
+ }
590
+ const reported: Error[] = []
591
+ const accepted = gateGestureStateProps(
592
+ { whileTap: { scale: 0.9, opacity: 0.5 } },
593
+ { dragAxes: [], boundKeys: ['scale'] },
594
+ {
595
+ severity: 'production',
596
+ report: (error) => reported.push(error),
597
+ componentId: '<Motion.View>' as const,
598
+ },
599
+ )
600
+ const strippedRight =
601
+ reported.length === 1 &&
602
+ accepted.whileTap !== undefined &&
603
+ !('scale' in accepted.whileTap.target) &&
604
+ (accepted.whileTap.target as { opacity?: number }).opacity === 0.5
605
+ if (!strippedRight) {
606
+ return { id, pass: false, detail: 'bound key must STRIP with one report; survivor kept' }
607
+ }
608
+ return {
609
+ id,
610
+ pass: true,
611
+ detail: 'label resolved (law a/b), delay accepted, bound key stripped + survivor kept',
612
+ }
613
+ } catch (e) {
614
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
615
+ }
616
+ }
617
+
618
+ export function GestureChecksScreen() {
619
+ const rows: Row[] = [
620
+ checkDeadZone(),
621
+ checkDragFollowAndHandoff(),
622
+ checkReleaseSnap(),
623
+ checkConstraints(),
624
+ checkRegrabContinuity(),
625
+ checkCancelFailAndFreeDrag(),
626
+ checkMalformedEventGuard(),
627
+ checkPanEndRouting(),
628
+ checkTapProtocol(),
629
+ checkGlobalTapObserver(),
630
+ checkTapBoundaryGates(),
631
+ checkGestureStateResolver(),
632
+ checkGestureStateBoundary(),
633
+ ]
634
+ const allPass = rows.every((row) => row.pass)
635
+
636
+ return (
637
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
638
+ <Text style={styles.title}>M2.4 gesture checks</Text>
639
+ <Text style={styles.subtitle}>
640
+ binding semantics vs synthetic Pan events + a manual graph
641
+ </Text>
642
+ <View style={[styles.banner, allPass ? styles.pass : styles.fail]}>
643
+ <Text style={styles.bannerText}>{allPass ? 'PASS' : 'FAIL'}</Text>
644
+ </View>
645
+ {rows.map((row) => (
646
+ <View key={row.id} style={styles.row}>
647
+ <Text style={[styles.verdict, row.pass ? styles.ok : styles.bad]}>
648
+ {row.pass ? 'PASS' : 'FAIL'}
649
+ </Text>
650
+ <View style={styles.rowBody}>
651
+ <Text style={styles.rowId}>{row.id}</Text>
652
+ <Text style={styles.rowDetail} numberOfLines={3}>
653
+ {row.detail}
654
+ </Text>
655
+ </View>
656
+ </View>
657
+ ))}
658
+ </ScrollView>
659
+ )
660
+ }
661
+
662
+ const styles = StyleSheet.create({
663
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
664
+ container: { paddingVertical: 72, paddingHorizontal: 20 },
665
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
666
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
667
+ banner: {
668
+ alignSelf: 'center',
669
+ paddingHorizontal: 36,
670
+ paddingVertical: 10,
671
+ borderRadius: 10,
672
+ marginTop: 18,
673
+ marginBottom: 10,
674
+ },
675
+ pass: { backgroundColor: '#1d7a3e' },
676
+ fail: { backgroundColor: '#a32633' },
677
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
678
+ row: { flexDirection: 'row', marginTop: 10 },
679
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
680
+ ok: { color: '#4ade80' },
681
+ bad: { color: '#f87171' },
682
+ rowBody: { flex: 1 },
683
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
684
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
685
+ })