@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,429 @@
1
+ // RN-free engine for the layout-identity worklet probe: the plain-object probe graph, the fixed
2
+ // two-holder registry trajectory, and the live-leg state machine. Extracted from
3
+ // LayoutIdentityWorkletProbe.tsx so the reporting semantics are unit-testable off-device (vitest
4
+ // runs this on the JS thread, where the 'worklet' directives are inert); the screen keeps the
5
+ // React/Reanimated wiring. Function-level directives on app source are workletized per-function by
6
+ // the worklets babel plugin and importable into worklets cross-module — the same pattern as
7
+ // motion/colorProjection.ts (device-proven by the L1/L4 rungs).
8
+
9
+ import {
10
+ createLayoutIdentityRegistry,
11
+ type ActivityHandle,
12
+ type Animation,
13
+ type Generator,
14
+ type GeneratorFactory,
15
+ type LayoutIdentityRegistry,
16
+ type MotionGraph,
17
+ type MotionValue,
18
+ type MotionValueEvent,
19
+ type ValueChangeListener,
20
+ type WindowSpaceRect,
21
+ MAX_VELOCITY_DELTA_MS,
22
+ } from '@unrulysystems/native-motion-core/worklet-layout'
23
+
24
+ const ID = 'probe-card'
25
+ const FROM_TOKEN = 'from-holder'
26
+ const TO_TOKEN = 'to-holder'
27
+ export const STEPS_PER_LEG = 600
28
+ export const LIVE_MAX_FRAMES = STEPS_PER_LEG * 2
29
+ export const DT_MS = 16.67
30
+ const SPRING = { stiffness: 550, damping: 45 } as const
31
+ const FROM_RECT = { x: 24, y: 420, width: 112, height: 72 } as WindowSpaceRect
32
+ const TO_RECT = { x: 176, y: 96, width: 168, height: 132 } as WindowSpaceRect
33
+
34
+ interface ProbeAnimation extends Animation {
35
+ done: boolean
36
+ /**
37
+ * Reentrancy latch for the interruption window (r21): this graph's count SCANS `done`, so
38
+ * the exactly-once guard cannot flip it before the cancel notification — the listener must
39
+ * still observe a live count. Core's markFinished maps to this latch here.
40
+ */
41
+ __latched?: boolean
42
+ /**
43
+ * Ledger/ownership teardown with or without the end-of-animation velocity cleanup (r19):
44
+ * interruption carries velocity into the successor seed, so it finishes WITHOUT cleanup;
45
+ * a direct stop() cleans up (r17).
46
+ */
47
+ __finish(runEndCleanup: boolean): void
48
+ }
49
+
50
+ interface ProbeMotionValue extends MotionValue<number> {
51
+ __active: ProbeAnimation | null
52
+ __applyAnimationSample(value: number, velocity: number): void
53
+ __endAnimation(): void
54
+ /** Fire a lifecycle event on this value (r15 e18b6f40a2d7 — the union is EMITTED, not just accepted). */
55
+ __emit(event: 'animationStart' | 'animationComplete' | 'animationCancel' | 'destroy'): void
56
+ }
57
+
58
+ interface ProbeActiveAnimation {
59
+ readonly value: ProbeMotionValue
60
+ readonly generator: Generator
61
+ readonly handle: ProbeAnimation
62
+ elapsedMs: number
63
+ }
64
+
65
+ interface ProbeGraph extends MotionGraph {
66
+ frame(dtMs: number): void
67
+ }
68
+
69
+ function createProbeMotionValue(initial: number, now: () => number): ProbeMotionValue {
70
+ 'worklet'
71
+ let value = initial
72
+ // Core MotionValueImpl's EXACT sample-window model (r20 26d0a09db7f4 — a manual-write
73
+ // timestamp alone missed same-frame compounding, identical-write suppression, and the
74
+ // clock velocity of listener writes): the animated velocity takes precedence while a
75
+ // flight drives the value; manual writes roll the frame window only when time moved.
76
+ let animatedVelocity: number | null = null
77
+ let prev: number | null = null
78
+ let prevUpdatedAtMs: number | null = null
79
+ let updatedAtMs = now()
80
+ const listeners: Array<ValueChangeListener<number>> = []
81
+ // 'change' listeners take the latest value (the pinned payload); lifecycle listeners take
82
+ // none — one storage shape, lifecycle emission casts to the payload-free call.
83
+ const eventListeners: Record<MotionValueEvent, Array<(latest: number) => void>> = {
84
+ change: [],
85
+ animationStart: [],
86
+ animationComplete: [],
87
+ animationCancel: [],
88
+ destroy: [],
89
+ }
90
+ const mv: ProbeMotionValue = {
91
+ __active: null,
92
+ __applyAnimationSample(next, nextVelocity) {
93
+ value = next
94
+ animatedVelocity = nextVelocity
95
+ for (const listener of listeners) listener(value)
96
+ for (const listener of eventListeners.change) listener(value)
97
+ },
98
+ __endAnimation() {
99
+ // Core MotionValueImpl.endAnimation() verbatim (r20): clears the animated velocity AND
100
+ // resets the clock window — the registry's EPSILON_VEL settle floor clears, and no
101
+ // stale manual sample can synthesize a velocity against the flight's final value.
102
+ animatedVelocity = null
103
+ prev = null
104
+ prevUpdatedAtMs = null
105
+ updatedAtMs = now()
106
+ },
107
+ get: () => value,
108
+ set(next) {
109
+ // Core set() verbatim (r20): window rolls only when time moved; identical writes
110
+ // do not notify.
111
+ animatedVelocity = null
112
+ const nowMs = now()
113
+ if (nowMs !== updatedAtMs) {
114
+ prev = value
115
+ prevUpdatedAtMs = updatedAtMs
116
+ updatedAtMs = nowMs
117
+ }
118
+ const previous = value
119
+ value = next
120
+ if (value !== previous) {
121
+ for (const listener of listeners) listener(value)
122
+ for (const listener of eventListeners.change) listener(value)
123
+ }
124
+ },
125
+ jump(next) {
126
+ // Core jump() VERBATIM (r16/r17/r20): set (window semantics + identical-write
127
+ // suppression + clock velocity for the change listener) -> window reset -> stop whose
128
+ // cancellation reads 0 with the animation still observable.
129
+ mv.set(next)
130
+ prev = null
131
+ prevUpdatedAtMs = null
132
+ updatedAtMs = now()
133
+ mv.stop()
134
+ },
135
+ subscribe(listener) {
136
+ listeners.push(listener)
137
+ return () => {
138
+ const at = listeners.indexOf(listener)
139
+ if (at >= 0) listeners.splice(at, 1)
140
+ }
141
+ },
142
+ getVelocity: () => {
143
+ // Core getVelocity() verbatim: the animated velocity wins while a flight drives the
144
+ // value; otherwise the clock formula over the manual-write window, stale -> 0.
145
+ if (animatedVelocity !== null) return animatedVelocity
146
+ if (prev === null || prevUpdatedAtMs === null) return 0
147
+ if (now() - updatedAtMs > MAX_VELOCITY_DELTA_MS) return 0
148
+ const dtMs = Math.min(updatedAtMs - prevUpdatedAtMs, MAX_VELOCITY_DELTA_MS)
149
+ if (dtMs <= 0) return 0
150
+ return ((value - prev) * 1000) / dtMs
151
+ },
152
+ on(event, listener) {
153
+ eventListeners[event].push(listener)
154
+ return () => {
155
+ const at = eventListeners[event].indexOf(listener)
156
+ if (at >= 0) eventListeners[event].splice(at, 1)
157
+ }
158
+ },
159
+ // Liveness IS the __active handle (set at animate, nulled at stop and natural completion).
160
+ isAnimating: () => mv.__active !== null,
161
+ // Lifecycle emission (r15 e18b6f40a2d7): mirrors uiLayoutGraph / core parity.
162
+ __emit(event) {
163
+ for (const listener of eventListeners[event]) (listener as () => void)()
164
+ },
165
+ stop() {
166
+ if (mv.__active !== null) {
167
+ // Pinned ORDER (r17): the cancellation is notified while the animation is still
168
+ // observable; the handle's ledger/liveness/velocity cleanup runs after.
169
+ mv.__emit('animationCancel')
170
+ mv.__active.stop()
171
+ mv.__active = null
172
+ }
173
+ },
174
+ destroy() {
175
+ // Pinned teardown ORDER (r16 major 1): destroy announces FIRST (the listener observes
176
+ // the live animation), then the SILENT stop — never an animationCancel from teardown.
177
+ mv.__emit('destroy')
178
+ if (mv.__active !== null) {
179
+ mv.__active.stop()
180
+ mv.__active = null
181
+ }
182
+ listeners.length = 0
183
+ eventListeners.change.length = 0
184
+ eventListeners.animationStart.length = 0
185
+ eventListeners.animationComplete.length = 0
186
+ eventListeners.animationCancel.length = 0
187
+ eventListeners.destroy.length = 0
188
+ },
189
+ }
190
+ return mv
191
+ }
192
+
193
+ function createProbeGraph(): ProbeGraph {
194
+ 'worklet'
195
+ const animations: ProbeActiveAnimation[] = []
196
+ let openActivities = 0
197
+ // The graph's frame clock (r19): raw frame time, mirroring uiLayoutGraph.
198
+ let nowMs = 0
199
+
200
+ function activeAnimationCount(): number {
201
+ 'worklet'
202
+ let count = 0
203
+ for (const animation of animations) if (!animation.handle.done) count += 1
204
+ return count
205
+ }
206
+
207
+ const graph: ProbeGraph = {
208
+ motionValue<T = number>(initial: T): MotionValue<T> {
209
+ return createProbeMotionValue(initial as number, () => nowMs) as unknown as MotionValue<T>
210
+ },
211
+ derived() {
212
+ throw new Error(
213
+ 'LayoutIdentityWorkletProbe: derived values are not used by the layout session crossing.',
214
+ )
215
+ },
216
+ animate(value: MotionValue<number>, factory: GeneratorFactory): Animation {
217
+ const motionValue = value as ProbeMotionValue
218
+ // Interruption ORDER (r19/r20, core-graph parity): the outgoing handle LATCHES first
219
+ // (core's markFinished-before-notify — a reentrant listener stop() hits the
220
+ // exactly-once latch, never a null dereference), the cancellation is notified while
221
+ // liveness is still observable (a valid listener may write the value), ownership
222
+ // clears, and only THEN does the successor seed read the CURRENT state
223
+ // (REQ-CORE-002/REQ-LAYOUT-018 continuity — including the listener write's clock
224
+ // velocity, which core's set() law now computes here too).
225
+ const outgoing = motionValue.__active
226
+ if (outgoing !== null && !outgoing.done) {
227
+ // Core's markFinished shape (r21): the reentrancy LATCH flips before the
228
+ // notification while the scan-visible `done` stays false — the listener observes a
229
+ // live count/liveness. done + ownership clear AFTER, in a finally (r22): a throwing
230
+ // listener must never leave a latched-but-never-done handle wedging the scan count.
231
+ outgoing.__latched = true
232
+ try {
233
+ motionValue.__emit('animationCancel')
234
+ } finally {
235
+ outgoing.done = true
236
+ if (motionValue.__active === outgoing) motionValue.__active = null
237
+ }
238
+ }
239
+ const generator = factory({ from: motionValue.get(), velocity: motionValue.getVelocity() })
240
+ const handle: ProbeAnimation = {
241
+ done: false,
242
+ __finish(runEndCleanup) {
243
+ if (handle.done || handle.__latched === true) return
244
+ handle.done = true
245
+ if (motionValue.__active === handle) {
246
+ motionValue.__active = null
247
+ if (runEndCleanup) motionValue.__endAnimation()
248
+ }
249
+ },
250
+ stop() {
251
+ handle.__finish(true)
252
+ },
253
+ }
254
+ motionValue.__active = handle
255
+ animations.push({ value: motionValue, generator, handle, elapsedMs: 0 })
256
+ // Core parity (graph.animate): the start event fires after the stopper is installed.
257
+ motionValue.__emit('animationStart')
258
+ return handle
259
+ },
260
+ activeAnimationCount,
261
+ registerActivity(): ActivityHandle {
262
+ openActivities += 1
263
+ let released = false
264
+ return {
265
+ release() {
266
+ if (released) return
267
+ released = true
268
+ openActivities -= 1
269
+ },
270
+ }
271
+ },
272
+ isSettled: () => activeAnimationCount() === 0 && openActivities === 0,
273
+ whenSettled() {
274
+ throw new Error(
275
+ 'LayoutIdentityWorkletProbe: whenSettled is not used by the synchronous probe graph.',
276
+ )
277
+ },
278
+ frame(dtMs) {
279
+ nowMs += dtMs
280
+ const completed: ProbeActiveAnimation[] = []
281
+ for (const animation of animations) {
282
+ if (animation.handle.done) continue
283
+ animation.elapsedMs += dtMs
284
+ const sample = animation.generator.sample(animation.elapsedMs)
285
+ animation.value.__applyAnimationSample(sample.value, sample.velocity)
286
+ if (sample.done) completed.push(animation)
287
+ }
288
+ for (const animation of completed) {
289
+ animation.handle.done = true
290
+ if (animation.value.__active === animation.handle) {
291
+ animation.value.__active = null
292
+ animation.value.__endAnimation()
293
+ animation.value.__emit('animationComplete')
294
+ }
295
+ }
296
+ },
297
+ }
298
+ return graph
299
+ }
300
+
301
+ function readTrajectorySample(registry: LayoutIdentityRegistry): readonly number[] {
302
+ 'worklet'
303
+ const from = registry.read(ID, FROM_TOKEN)
304
+ const to = registry.read(ID, TO_TOKEN)
305
+ return [
306
+ from.transform.translateX,
307
+ from.transform.translateY,
308
+ from.transform.scaleX,
309
+ from.transform.scaleY,
310
+ from.opacity,
311
+ to.transform.translateX,
312
+ to.transform.translateY,
313
+ to.transform.scaleX,
314
+ to.transform.scaleY,
315
+ to.opacity,
316
+ registry.zLifted(ID, FROM_TOKEN) ? 1 : 0,
317
+ registry.zLifted(ID, TO_TOKEN) ? 1 : 0,
318
+ ]
319
+ }
320
+
321
+ function beginProbeRegistry(): {
322
+ readonly graph: ProbeGraph
323
+ readonly registry: LayoutIdentityRegistry
324
+ } {
325
+ 'worklet'
326
+ const graph = createProbeGraph()
327
+ const registry = createLayoutIdentityRegistry({ graph, spring: SPRING })
328
+ registry.enter(ID, FROM_TOKEN, FROM_RECT)
329
+ registry.enter(ID, TO_TOKEN, TO_RECT)
330
+ return { graph, registry }
331
+ }
332
+
333
+ export function runLayoutIdentityTrajectory(): {
334
+ readonly values: readonly number[]
335
+ readonly enterSettled: boolean
336
+ readonly exitReleased: boolean
337
+ readonly activeAnimations: number
338
+ } {
339
+ 'worklet'
340
+ const { graph, registry } = beginProbeRegistry()
341
+ const values: number[] = []
342
+ for (let i = 0; i < STEPS_PER_LEG; i++) {
343
+ graph.frame(DT_MS)
344
+ values.push(...readTrajectorySample(registry))
345
+ }
346
+ const enterSettled = registry.isSettled(ID)
347
+ registry.leave(ID, TO_TOKEN)
348
+ const releasedBeforeExitSettles = registry.releasable(ID, TO_TOKEN)
349
+ for (let i = 0; i < STEPS_PER_LEG; i++) {
350
+ graph.frame(DT_MS)
351
+ values.push(...readTrajectorySample(registry))
352
+ }
353
+ return {
354
+ values,
355
+ enterSettled,
356
+ exitReleased: !releasedBeforeExitSettles && registry.releasable(ID, TO_TOKEN),
357
+ activeAnimations: graph.activeAnimationCount(),
358
+ }
359
+ }
360
+
361
+ interface LiveProbeState {
362
+ readonly graph: ProbeGraph
363
+ readonly registry: LayoutIdentityRegistry
364
+ frames: number
365
+ done: boolean
366
+ }
367
+
368
+ interface LiveProbeGlobal {
369
+ __nativeMotionLayoutIdentityProbe?: LiveProbeState
370
+ }
371
+
372
+ export function resetLiveProbe(): void {
373
+ 'worklet'
374
+ const { graph, registry } = beginProbeRegistry()
375
+ ;(globalThis as LiveProbeGlobal).__nativeMotionLayoutIdentityProbe = {
376
+ graph,
377
+ registry,
378
+ frames: 0,
379
+ done: false,
380
+ }
381
+ }
382
+
383
+ export interface LiveProbeStepResult {
384
+ // True exactly when this frame carries the run's verdict; the caller schedules the RN-side
385
+ // report only on report=true frames.
386
+ readonly report: boolean
387
+ readonly pass: boolean
388
+ readonly detail: string
389
+ }
390
+
391
+ export function stepLiveProbe(dtMs: number): LiveProbeStepResult {
392
+ 'worklet'
393
+ const state = (globalThis as LiveProbeGlobal).__nativeMotionLayoutIdentityProbe
394
+ if (state === undefined) {
395
+ return { report: true, pass: false, detail: 'live probe was not initialized on the UI runtime' }
396
+ }
397
+ if (state.done) {
398
+ // The verdict frame already reported; the callback stays active until the RN thread
399
+ // deactivates it, so frames keep arriving. Never re-report here — a pass:true "already
400
+ // settled" from this branch used to overwrite a real timeout verdict on the RN side
401
+ // (review-found fail-open, 2026-07-09).
402
+ return {
403
+ report: false,
404
+ pass: false,
405
+ detail: `verdict already reported after ${state.frames} frames`,
406
+ }
407
+ }
408
+ state.graph.frame(dtMs)
409
+ state.frames += 1
410
+ if (state.registry.isSettled(ID)) {
411
+ state.done = true
412
+ return {
413
+ report: true,
414
+ pass: true,
415
+ detail: `useFrameCallback reached layout-identity settle after ${state.frames} UI frames`,
416
+ }
417
+ }
418
+ if (state.frames >= LIVE_MAX_FRAMES) {
419
+ state.done = true
420
+ return {
421
+ report: true,
422
+ pass: false,
423
+ detail: `useFrameCallback did not settle within ${LIVE_MAX_FRAMES} UI frames`,
424
+ }
425
+ }
426
+ // pass is meaningful only on report=true frames; keep it false everywhere else so a caller
427
+ // that mistakenly reads it anyway fails closed.
428
+ return { report: false, pass: false, detail: `stepping frame ${state.frames}` }
429
+ }
@@ -0,0 +1,273 @@
1
+ // U8 arc() device checks: catalog transition-arc-playground + spring playground on the
2
+ // public MotionView path. iOS has no tap injection, so the screen self-drives.
3
+ // Discriminators (Decision 94 analog of 77/82/86/90): native is the parametric channel
4
+ // (committed x/y/pathRotation via onUpdate), not host paint. Tween y uses peak-over-flight
5
+ // like the web e2e data-peaky — a wall-clock mid sample races the 800ms linear tween.
6
+ // Host paint is the web e2e. layout.path is not this unit.
7
+
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
+ import { StyleSheet, Text, View } from 'react-native'
10
+ import { arc } from '@unrulysystems/native-motion-core'
11
+ import { MotionRoot } from '../../native/motion/MotionRoot'
12
+ import { MotionView } from '../../native/motion/MotionView'
13
+ import { ambientNativeSeverity } from '../../native/motion/severity'
14
+ import { armProofConsoleTap } from './proofConsoleTap'
15
+
16
+ const ROW_TWEEN = 'tween arc: y deflects mid-chord then settles'
17
+ const ROW_SPRING = 'spring arc: bounce overshoots the 400px target'
18
+ const ROW_ROTATE = 'rotate: pathRotation is non-zero mid-flight'
19
+ const ROW_CLEAN = 'clean run: zero unexpected reports'
20
+ const ROWS = [ROW_TWEEN, ROW_SPRING, ROW_ROTATE, ROW_CLEAN] as const
21
+
22
+ const T_TWEEN_END = 1100
23
+ const T_SPRING_END = 2200
24
+ const T_ROTATE_MID = 2450
25
+ const T_CLEAN = 3200
26
+
27
+ interface RowResult {
28
+ readonly pass: boolean
29
+ readonly detail: string
30
+ }
31
+
32
+ interface Sample {
33
+ x: number
34
+ y: number
35
+ pathRotation: number
36
+ }
37
+
38
+ export function ArcPathChecksScreen() {
39
+ const severity = ambientNativeSeverity()
40
+ const tweenPath = useMemo(() => arc({ strength: 1, peak: 0.5 }), [])
41
+ const springPath = useMemo(() => arc({ strength: 1, peak: 0.5 }), [])
42
+ const rotatePath = useMemo(() => arc({ strength: 1, peak: 0.5, rotate: true }), [])
43
+ const [tweenTarget, setTweenTarget] = useState<'a' | 'b'>('a')
44
+ const [springTarget, setSpringTarget] = useState<'a' | 'b'>('a')
45
+ const [rotateTarget, setRotateTarget] = useState<'a' | 'b'>('a')
46
+ const [stage, setStage] = useState('boot')
47
+ const [rows, setRows] = useState<Partial<Record<(typeof ROWS)[number], RowResult>>>({})
48
+ const tweenRef = useRef<Sample>({ x: 0, y: 0, pathRotation: 0 })
49
+ const springRef = useRef<Sample>({ x: 0, y: 0, pathRotation: 0 })
50
+ const rotateRef = useRef<Sample>({ x: 0, y: 0, pathRotation: 0 })
51
+ const tweenPeakY = useRef(0)
52
+ const springMaxX = useRef(0)
53
+ const rotatePeak = useRef(0)
54
+ const reportsRef = useRef<string[]>([])
55
+ const restoreTapRef = useRef<null | (() => void)>(null)
56
+ if (restoreTapRef.current === null) {
57
+ restoreTapRef.current = armProofConsoleTap(reportsRef.current, (message) =>
58
+ /motion|arc|path|InvalidTransition/i.test(message),
59
+ )
60
+ }
61
+
62
+ const finalize = (id: (typeof ROWS)[number], pass: boolean, detail: string): void => {
63
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
64
+ }
65
+
66
+ const onTween = useCallback((latest: Readonly<Record<string, string | number>>): void => {
67
+ const x = typeof latest.x === 'number' ? latest.x : tweenRef.current.x
68
+ const y = typeof latest.y === 'number' ? latest.y : tweenRef.current.y
69
+ tweenRef.current = { x, y, pathRotation: 0 }
70
+ if (Math.abs(y) > Math.abs(tweenPeakY.current)) tweenPeakY.current = y
71
+ }, [])
72
+ const onSpring = useCallback((latest: Readonly<Record<string, string | number>>): void => {
73
+ const x = typeof latest.x === 'number' ? latest.x : springRef.current.x
74
+ springRef.current = { ...springRef.current, x }
75
+ if (x > springMaxX.current) springMaxX.current = x
76
+ }, [])
77
+ const onRotate = useCallback((latest: Readonly<Record<string, string | number>>): void => {
78
+ const rot = typeof latest.pathRotation === 'number' ? latest.pathRotation : 0
79
+ rotateRef.current = { ...rotateRef.current, pathRotation: rot }
80
+ if (Math.abs(rot) > Math.abs(rotatePeak.current)) rotatePeak.current = rot
81
+ }, [])
82
+
83
+ useEffect(() => {
84
+ const timers: ReturnType<typeof setTimeout>[] = []
85
+ const at = (ms: number, run: () => void): void => {
86
+ timers.push(setTimeout(run, ms))
87
+ }
88
+
89
+ at(80, () => {
90
+ setTweenTarget('b')
91
+ setStage('tween started')
92
+ })
93
+ at(T_TWEEN_END, () => {
94
+ // Peak y, not a wall-clock mid sample: Android at f1f6c601 captured t≈1.04
95
+ // (x=417.6 y=-36.8) when the 500ms timer fired after the 800ms linear tween
96
+ // had already passed the chord. Same discriminator as the web e2e data-peaky.
97
+ const end = tweenRef.current
98
+ const peakY = tweenPeakY.current
99
+ const peakPass = Math.abs(peakY) > 80
100
+ const endPass = end.x > 380 && Math.abs(end.y) < 25
101
+ const pass = peakPass && endPass
102
+ finalize(
103
+ ROW_TWEEN,
104
+ pass,
105
+ `peak y=${peakY.toFixed(0)}; end x=${end.x.toFixed(0)} y=${end.y.toFixed(0)}`,
106
+ )
107
+ setSpringTarget('b')
108
+ setStage(pass ? 'tween pass; spring started' : 'tween FAIL; spring started')
109
+ })
110
+ at(T_SPRING_END, () => {
111
+ const pass = springMaxX.current > 410
112
+ finalize(
113
+ ROW_SPRING,
114
+ pass,
115
+ pass
116
+ ? `maxX=${springMaxX.current.toFixed(0)}`
117
+ : `maxX=${springMaxX.current.toFixed(0)} — expected >410 overshoot`,
118
+ )
119
+ setRotateTarget('b')
120
+ setStage(pass ? 'spring pass; rotate started' : 'spring FAIL; rotate started')
121
+ })
122
+ at(T_ROTATE_MID, () => {
123
+ const pass = Math.abs(rotatePeak.current) > 5
124
+ finalize(
125
+ ROW_ROTATE,
126
+ pass,
127
+ pass
128
+ ? `peak pathRotation=${rotatePeak.current.toFixed(1)}`
129
+ : `peak pathRotation=${rotatePeak.current.toFixed(1)} — expected non-zero`,
130
+ )
131
+ setStage(pass ? 'rotate pass' : 'rotate FAIL')
132
+ })
133
+ at(T_CLEAN, () => {
134
+ const pass = reportsRef.current.length === 0
135
+ finalize(
136
+ ROW_CLEAN,
137
+ pass,
138
+ pass
139
+ ? '0 unexpected reports'
140
+ : `${reportsRef.current.length} unexpected: ${reportsRef.current
141
+ .map((message) => message.slice(0, 80))
142
+ .join(' | ')}`,
143
+ )
144
+ setStage('done')
145
+ })
146
+
147
+ return () => {
148
+ for (const timer of timers) clearTimeout(timer)
149
+ restoreTapRef.current?.()
150
+ restoreTapRef.current = null
151
+ }
152
+ }, [])
153
+
154
+ const done = ROWS.every((id) => rows[id] !== undefined)
155
+ const failing = ROWS.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
156
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
157
+ const passed = Object.keys(rows).length - failing.length
158
+
159
+ return (
160
+ <MotionRoot>
161
+ <View style={styles.scroll}>
162
+ <View style={styles.container}>
163
+ <Text style={styles.title}>U8 arc path checks</Text>
164
+ <Text style={styles.subtitle}>
165
+ catalog tween + spring playgrounds ({severity} severity)
166
+ </Text>
167
+ <View
168
+ style={[
169
+ styles.banner,
170
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
171
+ ]}
172
+ >
173
+ <Text style={styles.bannerText}>{verdict}</Text>
174
+ </View>
175
+ <Text style={styles.tally}>
176
+ {done
177
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROWS.length} rows`
178
+ : `${Object.keys(rows).length}/${ROWS.length} rows complete — RUNNING`}
179
+ </Text>
180
+ <Text style={styles.tallyDetail}>
181
+ {done
182
+ ? failing.length === 0
183
+ ? 'no failing rows'
184
+ : `FAILING: ${failing.join(' | ')}`
185
+ : `stage: ${stage}`}
186
+ </Text>
187
+
188
+ <MotionView
189
+ animate={tweenTarget === 'a' ? { x: 0, y: 0 } : { x: 400, y: 0 }}
190
+ transition={{ duration: 0.8, ease: 'linear', path: tweenPath }}
191
+ onUpdate={onTween}
192
+ style={styles.box}
193
+ accessibilityLabel="u8-arc-tween-box"
194
+ />
195
+ <MotionView
196
+ animate={springTarget === 'a' ? { x: 0, y: 0 } : { x: 400, y: 0 }}
197
+ transition={{ type: 'spring', bounce: 0.6, visualDuration: 0.45, path: springPath }}
198
+ onUpdate={onSpring}
199
+ style={[styles.box, styles.boxSpring]}
200
+ accessibilityLabel="u8-arc-spring-box"
201
+ />
202
+ <MotionView
203
+ animate={rotateTarget === 'a' ? { x: 0, y: 0 } : { x: 400, y: 0 }}
204
+ transition={{ duration: 0.8, ease: 'linear', path: rotatePath }}
205
+ onUpdate={onRotate}
206
+ style={[styles.box, styles.boxRotate]}
207
+ accessibilityLabel="u8-arc-rotate-box"
208
+ />
209
+
210
+ {ROWS.map((id) => {
211
+ const row = rows[id]
212
+ return (
213
+ <View key={id} style={styles.row}>
214
+ <Text
215
+ style={[
216
+ styles.verdict,
217
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
218
+ ]}
219
+ >
220
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
221
+ </Text>
222
+ <View style={styles.rowBody}>
223
+ <Text style={styles.rowId}>{id}</Text>
224
+ <Text style={styles.rowDetail} numberOfLines={3}>
225
+ {row === undefined ? 'pending' : row.detail}
226
+ </Text>
227
+ </View>
228
+ </View>
229
+ )
230
+ })}
231
+ </View>
232
+ </View>
233
+ </MotionRoot>
234
+ )
235
+ }
236
+
237
+ const styles = StyleSheet.create({
238
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
239
+ container: { paddingVertical: 72, paddingHorizontal: 8 },
240
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
241
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
242
+ banner: {
243
+ alignSelf: 'center',
244
+ paddingHorizontal: 36,
245
+ paddingVertical: 10,
246
+ borderRadius: 10,
247
+ marginTop: 18,
248
+ marginBottom: 10,
249
+ },
250
+ pass: { backgroundColor: '#1d7a3e' },
251
+ fail: { backgroundColor: '#a32633' },
252
+ running: { backgroundColor: '#7a641d' },
253
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
254
+ tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
255
+ tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
256
+ box: {
257
+ width: 40,
258
+ height: 40,
259
+ marginTop: 12,
260
+ backgroundColor: '#ef4444',
261
+ borderRadius: 8,
262
+ },
263
+ boxSpring: { backgroundColor: '#f97316' },
264
+ boxRotate: { backgroundColor: '#eab308' },
265
+ row: { flexDirection: 'row', marginTop: 10 },
266
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
267
+ ok: { color: '#4ade80' },
268
+ bad: { color: '#f87171' },
269
+ pending: { color: '#8a8aa0' },
270
+ rowBody: { flex: 1 },
271
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
272
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
273
+ })