@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,224 @@
1
+ // Device proof for REQ-DRIVER-026 (FLAG 5a): the REAL worklet driver executes the full
2
+ // gesture↔declarative same-axis CONTENTION sequence on the UI runtime and settles at the FAR snap.
3
+ // Self-driving (no touch — so the iOS simulator proves it too, not just Android real-touch): a held
4
+ // write (grab to the finger position 110) → a declarative retarget (`animate.x → 200`) that CONTENDS,
5
+ // drifting the live value PAST the [0,240] snap midpoint (120) while the hold persists → a release
6
+ // that reads the drifted LIVE value as its origin (REQ-GESTURE-022) → settle at snap `240`. The
7
+ // pre-fix skip froze the origin at the finger (110 < 120) and settled at `0`.
8
+ //
9
+ // The driver methods are driven from JS timers (`write`/`command`/`liveFor` are the wiring's own
10
+ // calls). Phased with holds so the drift is screenshot-capturable; completion comes only from the
11
+ // driver's real settle edge. A `settled` verdict therefore proves the frame loop stopped, the release
12
+ // origin crossed the snap midpoint, and the committed endpoint is exactly 240.
13
+
14
+ import { useEffect, useRef, useState } from 'react'
15
+ import { StyleSheet, Text, View } from 'react-native'
16
+ import Animated, { useAnimatedStyle } from 'react-native-reanimated'
17
+ import {
18
+ type DragConstraints,
19
+ type ElementHandle,
20
+ type SnapConfig,
21
+ selectReleaseTarget,
22
+ } from '@unrulysystems/native-motion-core'
23
+ import { useWorkletDriver } from '../../native/driver/workletDriver'
24
+
25
+ const SNAP: SnapConfig = { points: [0, 240] } // midpoint 120: origin < 120 ⇒ 0, > 120 ⇒ 240
26
+ const CONSTRAINTS: DragConstraints = { min: 0, max: 240, elastic: 0.4 }
27
+ const FINGER = 110 // dragged-to position, held (release velocity 0); 110 < the 120 midpoint
28
+ // The contending declarative spring (a mount/`animate` feel) and the release settle spring.
29
+ const CONTEND_SPRING = { type: 'spring', stiffness: 180, damping: 20 } as const
30
+ const RELEASE_SPRING = { type: 'spring', stiffness: 400, damping: 40 } as const
31
+
32
+ // Phase timings (ms). Generous so the drift crosses the midpoint and each phase is capturable.
33
+ const CONTEND_AT = 800
34
+ const RELEASE_AT = 2400
35
+ const SETTLE_TIMEOUT_AT = 10_000
36
+ const SNAP_MIDPOINT = 120
37
+ const EXPECTED_ENDPOINT = 240
38
+
39
+ const LOG_PREFIX = 'CONTENTION_PROBE '
40
+
41
+ type Phase = 'grab' | 'contending' | 'released' | 'settled' | 'failed'
42
+
43
+ interface LoggingGlobal {
44
+ nativeLoggingHook?: (message: string, level: number) => void
45
+ }
46
+
47
+ export function ContentionProbeScreen() {
48
+ const [phase, setPhase] = useState<Phase>('grab')
49
+ const [origin, setOrigin] = useState<number | null>(null)
50
+ const [settledValue, setSettledValue] = useState<number | null>(null)
51
+ const [failure, setFailure] = useState<string | null>(null)
52
+ const releaseOriginRef = useRef<number | null>(null)
53
+ const settleEdgeCountRef = useRef(0)
54
+ const timedOutRef = useRef(false)
55
+ const handleRef = useRef<ElementHandle | null>(null)
56
+ // The LIVE worklet driver from the hook — the single useFrameCallback on the UI runtime.
57
+ const binding = useWorkletDriver({
58
+ onAllSettled: () => {
59
+ settleEdgeCountRef.current += 1
60
+ const settleEdgeCount = settleEdgeCountRef.current
61
+ const handle = handleRef.current
62
+ if (handle === null) {
63
+ setFailure('settle edge fired without a registered handle')
64
+ setPhase('failed')
65
+ return
66
+ }
67
+
68
+ const releaseOrigin = releaseOriginRef.current
69
+ const committed = binding.driver.committed(handle)['x']
70
+ const active = binding.driver.isActive(handle)
71
+ const failures: string[] = []
72
+ if (timedOutRef.current) {
73
+ failures.push(`settle edge arrived after the ${SETTLE_TIMEOUT_AT}ms timeout`)
74
+ }
75
+ if (settleEdgeCount !== 1) {
76
+ failures.push(`settle edge count=${settleEdgeCount}, want exactly 1`)
77
+ }
78
+ if (releaseOrigin === null) failures.push('settle edge fired before release')
79
+ else if (releaseOrigin <= SNAP_MIDPOINT) {
80
+ failures.push(`release origin ${releaseOrigin} did not cross ${SNAP_MIDPOINT}`)
81
+ }
82
+ if (committed === undefined) failures.push('committed x is missing')
83
+ else if (committed !== EXPECTED_ENDPOINT) {
84
+ failures.push(`committed x=${committed}, want exactly ${EXPECTED_ENDPOINT}`)
85
+ }
86
+ if (active) failures.push('driver is still active after the settle edge')
87
+
88
+ const passed = failures.length === 0
89
+ setSettledValue(committed ?? null)
90
+ setFailure(passed ? null : failures.join('; '))
91
+ setPhase(passed ? 'settled' : 'failed')
92
+ const hook = (globalThis as LoggingGlobal).nativeLoggingHook
93
+ hook?.(
94
+ `${LOG_PREFIX}${JSON.stringify({
95
+ pass: passed,
96
+ releaseOrigin,
97
+ settledX: committed ?? null,
98
+ active,
99
+ settleEdgeCount,
100
+ finger: FINGER,
101
+ failures,
102
+ })}`,
103
+ 1,
104
+ )
105
+ },
106
+ })
107
+ if (handleRef.current === null) {
108
+ handleRef.current = binding.driver.register({ x: 0 })
109
+ }
110
+ const handle = handleRef.current
111
+ const shared = binding.sharedFor(handle, 'x')
112
+
113
+ useEffect(() => {
114
+ const driver = binding.driver
115
+ // 1. Grab + drag to the finger position, HELD (velocity 0) — the driver write lane, exactly as
116
+ // the gesture wiring's handlePanStart/Update do (REQ-DRIVER-024).
117
+ driver.write(handle, { x: FINGER }, { x: 0 })
118
+
119
+ // 2. A declarative `animate.x → 200` CONTENDS while held: it seeds a generator that drifts the
120
+ // live value from the finger toward 200 without clearing the hold (REQ-DRIVER-026).
121
+ const t1 = setTimeout(() => {
122
+ setPhase('contending')
123
+ driver.command(handle, { kind: 'retarget', targets: { x: 200 }, transition: CONTEND_SPRING })
124
+ }, CONTEND_AT)
125
+
126
+ // 3. Release: read the ACTUAL live value (drifted past the midpoint) as the handoff ORIGIN, select
127
+ // the snap target from it, and start the release spring (REQ-GESTURE-022) — the wiring's own
128
+ // handlePanEnd logic, driven here without a real finger.
129
+ const t2 = setTimeout(() => {
130
+ const live = driver.liveFor(handle, 'x').value
131
+ const target = selectReleaseTarget(live, 0, CONSTRAINTS, SNAP)
132
+ releaseOriginRef.current = live
133
+ setOrigin(live)
134
+ // Preserve an early-settle failure: a later valid release must never overwrite evidence that
135
+ // the hold violated G-INV-7. The edge counter also keeps the eventual second edge failed.
136
+ setPhase((current) => (current === 'failed' ? current : 'released'))
137
+ driver.command(handle, {
138
+ kind: 'start',
139
+ targets: { x: { to: target, from: live, velocity: 0 } },
140
+ transition: RELEASE_SPRING,
141
+ releasesGesture: true,
142
+ })
143
+ }, RELEASE_AT)
144
+
145
+ // A missing settle edge is a bounded FAIL, never an indefinite wait and never timer-derived
146
+ // success. A late edge remains failed through timedOutRef, so it cannot overwrite this verdict.
147
+ const t3 = setTimeout(() => {
148
+ if (settleEdgeCountRef.current !== 0) return
149
+ timedOutRef.current = true
150
+ const committed = driver.committed(handle)['x']
151
+ const active = driver.isActive(handle)
152
+ const detail = `no settle edge by ${SETTLE_TIMEOUT_AT}ms; committed x=${String(committed)} active=${String(active)}`
153
+ setSettledValue(committed ?? null)
154
+ setFailure(detail)
155
+ setPhase('failed')
156
+ const hook = (globalThis as LoggingGlobal).nativeLoggingHook
157
+ hook?.(
158
+ `${LOG_PREFIX}${JSON.stringify({
159
+ pass: false,
160
+ releaseOrigin: releaseOriginRef.current,
161
+ settledX: committed ?? null,
162
+ active,
163
+ settleEdgeCount: 0,
164
+ finger: FINGER,
165
+ failures: [detail],
166
+ })}`,
167
+ 1,
168
+ )
169
+ }, SETTLE_TIMEOUT_AT)
170
+
171
+ return () => {
172
+ clearTimeout(t1)
173
+ clearTimeout(t2)
174
+ clearTimeout(t3)
175
+ }
176
+ }, [binding, handle])
177
+
178
+ const boxStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shared.value }] }))
179
+
180
+ return (
181
+ <View style={styles.container}>
182
+ <Text style={styles.title}>Native Motion</Text>
183
+ <Text style={styles.subtitle}>REQ-DRIVER-026: gesture↔declarative contention (worklet)</Text>
184
+ <View style={styles.track}>
185
+ <View style={[styles.snapMark, { left: 0 }]} />
186
+ <View style={[styles.snapMark, { left: 240 }]} />
187
+ <Animated.View style={[styles.box, boxStyle]} />
188
+ </View>
189
+ <Text style={styles.phase}>
190
+ phase: {phase}
191
+ {origin === null ? '' : ` · release origin x=${origin.toFixed(1)} · required >120`}
192
+ {settledValue === null ? '' : ` · committed x=${settledValue}`}
193
+ {phase === 'settled' ? ' · inactive' : ''}
194
+ </Text>
195
+ {failure === null ? null : <Text style={styles.failure}>FAIL: {failure}</Text>}
196
+ <Text style={styles.note}>
197
+ held at {FINGER} → animate x→200 drifts past 120 → release from the live value → snap 240
198
+ </Text>
199
+ </View>
200
+ )
201
+ }
202
+
203
+ const styles = StyleSheet.create({
204
+ container: {
205
+ flex: 1,
206
+ backgroundColor: '#0b0b12',
207
+ alignItems: 'center',
208
+ justifyContent: 'center',
209
+ paddingHorizontal: 24,
210
+ },
211
+ title: { color: '#fff', fontSize: 28, fontWeight: '700' },
212
+ subtitle: { color: '#8a8aa0', fontSize: 14, marginTop: 6, marginBottom: 40 },
213
+ track: { width: 240 + 64, height: 72, justifyContent: 'center' },
214
+ snapMark: {
215
+ position: 'absolute',
216
+ width: 2,
217
+ height: 72,
218
+ backgroundColor: '#33334a',
219
+ },
220
+ box: { width: 64, height: 64, borderRadius: 16, backgroundColor: '#6c8cff' },
221
+ phase: { color: '#c9c9d8', fontSize: 13, marginTop: 32, fontFamily: 'Menlo' },
222
+ failure: { color: '#ff6b6b', fontSize: 13, marginTop: 12, textAlign: 'center' },
223
+ note: { color: '#6a6a80', fontSize: 12, marginTop: 12, textAlign: 'center' },
224
+ })
@@ -0,0 +1,99 @@
1
+ // M2.1 device smoke (specs/M2.1-BUILD-PACKET.md, Milestone 3): the REAL worklet driver runs a core
2
+ // spring on the UI runtime and STOPS at settle. Phased for screenshot proof without UI automation:
3
+ // a fixed hold before the spring starts (so a "before" frame is capturable), an underdamped spring
4
+ // (visible overshoot mid-flight), then the settled hold — with the phase and the driver's live
5
+ // activity rendered on screen. When the phase reads "settled" the frame loop has already stopped
6
+ // requeuing (the onAllSettled edge fires only after the loop halts): the screenshot itself is the
7
+ // settle-cancel evidence the M2.0 smoke could never produce (its JS rAF loop ran forever).
8
+
9
+ import { useEffect, useRef, useState } from 'react'
10
+ import { StyleSheet, Text, View } from 'react-native'
11
+ import Animated, { useAnimatedStyle } from 'react-native-reanimated'
12
+ import type { ElementHandle } from '@unrulysystems/native-motion-core'
13
+ import { useWorkletDriver } from '../../native/driver/workletDriver'
14
+
15
+ const TRAVEL = 220
16
+ // Hold before the spring starts, so launch→screenshot latency lands a deterministic "before" frame.
17
+ const START_DELAY_MS = 1500
18
+ // Motion's default underdamped feel — overshoots past the target, unmistakable in a mid-flight frame.
19
+ const SPRING = { type: 'spring', stiffness: 100, damping: 10 } as const
20
+
21
+ type Phase = 'waiting' | 'animating' | 'settled'
22
+
23
+ export function DriverSmokeScreen() {
24
+ const [phase, setPhase] = useState<Phase>('waiting')
25
+ // The LIVE driver comes from the hook — it owns the single useFrameCallback (REQ-DRIVER-011).
26
+ const binding = useWorkletDriver({ onAllSettled: () => setPhase('settled') })
27
+ const handleRef = useRef<ElementHandle | null>(null)
28
+ if (handleRef.current === null) {
29
+ handleRef.current = binding.driver.register({ translateX: 0 })
30
+ }
31
+ const handle = handleRef.current
32
+ const shared = binding.sharedFor(handle, 'translateX')
33
+
34
+ useEffect(() => {
35
+ const timer = setTimeout(() => {
36
+ setPhase('animating')
37
+ binding.driver.command(handle, {
38
+ kind: 'start',
39
+ targets: { translateX: { from: 0, to: TRAVEL } },
40
+ transition: SPRING,
41
+ })
42
+ }, START_DELAY_MS)
43
+ return () => clearTimeout(timer)
44
+ }, [binding, handle])
45
+
46
+ const boxStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shared.value }] }))
47
+
48
+ return (
49
+ <View style={styles.container}>
50
+ <Text style={styles.title}>Native Motion</Text>
51
+ <Text style={styles.subtitle}>M2.1: the worklet driver springs on the UI thread</Text>
52
+ <View style={styles.track}>
53
+ <Animated.View style={[styles.box, boxStyle]} />
54
+ </View>
55
+ <Text style={styles.phase}>
56
+ phase: {phase}
57
+ {phase === 'settled' ? ' — frame loop stopped' : ''}
58
+ </Text>
59
+ </View>
60
+ )
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ container: {
65
+ flex: 1,
66
+ backgroundColor: '#0b0b12',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ paddingHorizontal: 24,
70
+ },
71
+ title: {
72
+ color: '#fff',
73
+ fontSize: 28,
74
+ fontWeight: '700',
75
+ },
76
+ subtitle: {
77
+ color: '#8a8aa0',
78
+ fontSize: 14,
79
+ marginTop: 6,
80
+ marginBottom: 40,
81
+ },
82
+ track: {
83
+ width: TRAVEL + 64,
84
+ height: 72,
85
+ justifyContent: 'center',
86
+ },
87
+ box: {
88
+ width: 64,
89
+ height: 64,
90
+ borderRadius: 16,
91
+ backgroundColor: '#6c8cff',
92
+ },
93
+ phase: {
94
+ color: '#c9c9d8',
95
+ fontSize: 14,
96
+ marginTop: 32,
97
+ fontFamily: 'Menlo',
98
+ },
99
+ })
@@ -0,0 +1,294 @@
1
+ // REQ-TIMING-006 device checks (U7a finding A / motion.duration-without-type-generator): an
2
+ // authored SCALAR `{ duration }` with NO `type` takes the pin's keyframes generator — monotone,
3
+ // easeOut by default, never the old omitted-type duration-spring (the 2026-08-18 U7a finals read
4
+ // peak 1.046 on a 0→1 fade). iOS has no tap injection, so the screen self-drives.
5
+ // Discriminators (Decision 94 analog of 77/82/86/90): native is the committed-sample channel
6
+ // (onUpdate x), not host paint. The mid-flight bands key on the sample nearest the flight's
7
+ // own t0+500ms — self-calibrating against mount→commitRect host latency (the U8 2295fbab
8
+ // wall-clock lesson), never a fixed setTimeout sample.
9
+
10
+ import { useCallback, useEffect, useRef, useState } from 'react'
11
+ import { StyleSheet, Text, View } from 'react-native'
12
+ import { MotionRoot } from '../../native/motion/MotionRoot'
13
+ import { MotionView } from '../../native/motion/MotionView'
14
+ import { ambientNativeSeverity } from '../../native/motion/severity'
15
+ import { armProofConsoleTap } from './proofConsoleTap'
16
+
17
+ const ROW_UNTYPED = 'untyped { duration }: monotone to the target, never overshoots'
18
+ const ROW_EASEOUT = 'untyped { duration }: the mid-flight value is the easeOut curve'
19
+ const ROW_LINEAR = 'authored ease spelling suppresses the easeOut default'
20
+ const ROW_TYPED_SPRING = 'typed { type: "spring", duration } control still overshoots'
21
+ const ROW_CLEAN = 'clean run: zero unexpected reports'
22
+ const ROWS = [ROW_UNTYPED, ROW_EASEOUT, ROW_LINEAR, ROW_TYPED_SPRING, ROW_CLEAN] as const
23
+
24
+ const T_A_END = 1400
25
+ const T_B_END = 2700
26
+ const T_C_END = 4000
27
+ const T_CLEAN = 4100
28
+
29
+ interface RowResult {
30
+ readonly pass: boolean
31
+ readonly detail: string
32
+ }
33
+
34
+ interface Sample {
35
+ readonly value: number
36
+ readonly at: number
37
+ }
38
+
39
+ // The sample whose timestamp is nearest the flight's own t0 + offsetMs, where t0 is the FIRST
40
+ // INTERIOR sample (value > 0.05) — never the mount/command-edge sample: on device the first
41
+ // onUpdate can carry the pre-flight value 0 one crossing before the tween clock starts (the
42
+ // 2026-08-21 first run read 57.1 ≈ easeOut(0.4) on the untyped leg and 0.0 on the linear leg,
43
+ // both traced to a mount-edge t0 ~100ms early). The flight clock is the device clock, so this
44
+ // lands at the requested progress regardless of frame cadence.
45
+ function nearestAfterT0(samples: readonly Sample[], offsetMs: number): Sample | null {
46
+ const first = samples.find((s) => s.value > 0.05)
47
+ if (first === undefined) return null
48
+ const target = first.at + offsetMs
49
+ let best: Sample | null = null
50
+ for (const sample of samples) {
51
+ if (best === null || Math.abs(sample.at - target) < Math.abs(best.at - target)) best = sample
52
+ }
53
+ return best
54
+ }
55
+
56
+ export function DurationOnlyTweenChecksScreen() {
57
+ const severity = ambientNativeSeverity()
58
+ const [targetA, setTargetA] = useState<'a' | 'b'>('a')
59
+ const [targetB, setTargetB] = useState<'a' | 'b'>('a')
60
+ const [targetC, setTargetC] = useState<'a' | 'b'>('a')
61
+ const [stage, setStage] = useState('boot')
62
+ const [rows, setRows] = useState<Partial<Record<(typeof ROWS)[number], RowResult>>>({})
63
+ const samplesA = useRef<Sample[]>([])
64
+ const samplesB = useRef<Sample[]>([])
65
+ const peakC = useRef(0)
66
+ const reportsRef = useRef<string[]>([])
67
+ const restoreTapRef = useRef<null | (() => void)>(null)
68
+ if (restoreTapRef.current === null) {
69
+ restoreTapRef.current = armProofConsoleTap(reportsRef.current, (message) =>
70
+ /motion|transition|spring|tween/i.test(message),
71
+ )
72
+ }
73
+
74
+ const finalize = (id: (typeof ROWS)[number], pass: boolean, detail: string): void => {
75
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
76
+ }
77
+
78
+ const onA = useCallback((latest: Readonly<Record<string, string | number>>): void => {
79
+ if (typeof latest.x === 'number') samplesA.current.push({ value: latest.x, at: Date.now() })
80
+ }, [])
81
+ const onB = useCallback((latest: Readonly<Record<string, string | number>>): void => {
82
+ if (typeof latest.x === 'number') samplesB.current.push({ value: latest.x, at: Date.now() })
83
+ }, [])
84
+ const onC = useCallback((latest: Readonly<Record<string, string | number>>): void => {
85
+ if (typeof latest.x === 'number' && latest.x > peakC.current) peakC.current = latest.x
86
+ }, [])
87
+
88
+ useEffect(() => {
89
+ const timers: ReturnType<typeof setTimeout>[] = []
90
+ const at = (ms: number, run: () => void): void => {
91
+ timers.push(setTimeout(run, ms))
92
+ }
93
+
94
+ at(80, () => {
95
+ setTargetA('b')
96
+ setStage('untyped { duration } flight started')
97
+ })
98
+ at(T_A_END, () => {
99
+ const samples = samplesA.current
100
+ const interior = samples.filter((s) => s.value > 0.05 && s.value < 99.95)
101
+ const peak = samples.reduce((max, s) => Math.max(max, s.value), 0)
102
+ let monotone = true
103
+ for (let index = 1; index < samples.length; index += 1) {
104
+ if (samples[index]!.value < samples[index - 1]!.value - 1e-6) monotone = false
105
+ }
106
+ const first = samples[0]
107
+ const last = samples[samples.length - 1]
108
+ const span = first !== undefined && last !== undefined ? last.at - first.at : 0
109
+ const reached = last !== undefined && last.value > 99.5
110
+ const untypedPass =
111
+ samples.length > 10 && peak <= 100.05 && monotone && reached && span >= 600 && span <= 1800
112
+ finalize(
113
+ ROW_UNTYPED,
114
+ untypedPass,
115
+ `peak ${peak.toFixed(3)} (the pre-fix spring read ≈104.6), monotone=${String(monotone)}, ` +
116
+ `${interior.length} interior samples, span ${span}ms, end ${last?.value.toFixed(2) ?? 'n/a'}`,
117
+ )
118
+ // easeOut(0.5) ≈ 0.688 — discriminates against linear (0.5) and the old duration-spring
119
+ // (≈0.9 at half its duration). The band is the sample nearest the flight's own t0+500ms.
120
+ const mid = nearestAfterT0(samples, 500)
121
+ const midPass = mid !== null && mid.value >= 58 && mid.value <= 80
122
+ finalize(
123
+ ROW_EASEOUT,
124
+ midPass,
125
+ mid === null
126
+ ? 'no samples'
127
+ : `t0+500ms sample reads ${mid.value.toFixed(1)} — easeOut ≈68.8, linear 50, spring ≈90`,
128
+ )
129
+ setTargetB('b')
130
+ setStage(untypedPass && midPass ? 'A pass; linear leg started' : 'A FAIL; linear leg started')
131
+ })
132
+ at(T_B_END, () => {
133
+ const mid = nearestAfterT0(samplesB.current, 500)
134
+ const pass = mid !== null && mid.value >= 40 && mid.value <= 60
135
+ finalize(
136
+ ROW_LINEAR,
137
+ pass,
138
+ mid === null
139
+ ? 'no samples'
140
+ : `t0+500ms sample reads ${mid.value.toFixed(1)} — linear 50 (no easeOut injection)`,
141
+ )
142
+ setTargetC('b')
143
+ setStage(pass ? 'linear pass; typed-spring control started' : 'linear FAIL; control started')
144
+ })
145
+ at(T_C_END, () => {
146
+ const pass = peakC.current > 100.5
147
+ finalize(
148
+ ROW_TYPED_SPRING,
149
+ pass,
150
+ `peak ${peakC.current.toFixed(2)} — the typed duration-spring keeps its overshoot`,
151
+ )
152
+ setStage(pass ? 'control pass' : 'control FAIL')
153
+ })
154
+ at(T_CLEAN, () => {
155
+ const pass = reportsRef.current.length === 0
156
+ finalize(
157
+ ROW_CLEAN,
158
+ pass,
159
+ pass
160
+ ? '0 unexpected reports'
161
+ : `${reportsRef.current.length} unexpected: ${reportsRef.current
162
+ .map((message) => message.slice(0, 80))
163
+ .join(' | ')}`,
164
+ )
165
+ setStage('done')
166
+ })
167
+
168
+ return () => {
169
+ for (const timer of timers) clearTimeout(timer)
170
+ restoreTapRef.current?.()
171
+ restoreTapRef.current = null
172
+ }
173
+ }, [])
174
+
175
+ const done = ROWS.every((id) => rows[id] !== undefined)
176
+ const failing = ROWS.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
177
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
178
+ const passed = Object.keys(rows).length - failing.length
179
+
180
+ return (
181
+ <MotionRoot>
182
+ <View style={styles.scroll}>
183
+ <View style={styles.container}>
184
+ <Text style={styles.title}>duration-without-type checks</Text>
185
+ <Text style={styles.subtitle}>
186
+ untyped {'{ duration }'} scalar → keyframes tween ({severity} severity)
187
+ </Text>
188
+ <View
189
+ style={[
190
+ styles.banner,
191
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
192
+ ]}
193
+ >
194
+ <Text style={styles.bannerText}>{verdict}</Text>
195
+ </View>
196
+ <Text style={styles.tally}>
197
+ {done
198
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROWS.length} rows`
199
+ : `${Object.keys(rows).length}/${ROWS.length} rows complete — RUNNING`}
200
+ </Text>
201
+ <Text style={styles.tallyDetail}>
202
+ {done
203
+ ? failing.length === 0
204
+ ? 'no failing rows'
205
+ : `FAILING: ${failing.join(' | ')}`
206
+ : `stage: ${stage}`}
207
+ </Text>
208
+
209
+ <MotionView
210
+ animate={targetA === 'a' ? { x: 0 } : { x: 100 }}
211
+ transition={{ duration: 1 }}
212
+ onUpdate={onA}
213
+ style={styles.box}
214
+ accessibilityLabel="durationonly-untyped-box"
215
+ />
216
+ <MotionView
217
+ animate={targetB === 'a' ? { x: 0 } : { x: 100 }}
218
+ transition={{ duration: 1, ease: 'linear' }}
219
+ onUpdate={onB}
220
+ style={[styles.box, styles.boxLinear]}
221
+ accessibilityLabel="durationonly-linear-box"
222
+ />
223
+ <MotionView
224
+ animate={targetC === 'a' ? { x: 0 } : { x: 100 }}
225
+ transition={{ type: 'spring', duration: 1 }}
226
+ onUpdate={onC}
227
+ style={[styles.box, styles.boxSpring]}
228
+ accessibilityLabel="durationonly-typedspring-box"
229
+ />
230
+
231
+ {ROWS.map((id) => {
232
+ const row = rows[id]
233
+ return (
234
+ <View key={id} style={styles.row}>
235
+ <Text
236
+ style={[
237
+ styles.verdict,
238
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
239
+ ]}
240
+ >
241
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
242
+ </Text>
243
+ <View style={styles.rowBody}>
244
+ <Text style={styles.rowId}>{id}</Text>
245
+ <Text style={styles.rowDetail} numberOfLines={3}>
246
+ {row === undefined ? 'pending' : row.detail}
247
+ </Text>
248
+ </View>
249
+ </View>
250
+ )
251
+ })}
252
+ </View>
253
+ </View>
254
+ </MotionRoot>
255
+ )
256
+ }
257
+
258
+ const styles = StyleSheet.create({
259
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
260
+ container: { paddingVertical: 72, paddingHorizontal: 8 },
261
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
262
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
263
+ banner: {
264
+ alignSelf: 'center',
265
+ paddingHorizontal: 36,
266
+ paddingVertical: 10,
267
+ borderRadius: 10,
268
+ marginTop: 18,
269
+ marginBottom: 10,
270
+ },
271
+ pass: { backgroundColor: '#1d7a3e' },
272
+ fail: { backgroundColor: '#a32633' },
273
+ running: { backgroundColor: '#7a641d' },
274
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
275
+ tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
276
+ tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
277
+ box: {
278
+ width: 40,
279
+ height: 40,
280
+ marginTop: 12,
281
+ backgroundColor: '#ef4444',
282
+ borderRadius: 8,
283
+ },
284
+ boxLinear: { backgroundColor: '#38bdf8' },
285
+ boxSpring: { backgroundColor: '#f97316' },
286
+ row: { flexDirection: 'row', marginTop: 10 },
287
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
288
+ ok: { color: '#4ade80' },
289
+ bad: { color: '#f87171' },
290
+ pending: { color: '#8a8aa0' },
291
+ rowBody: { flex: 1 },
292
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
293
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
294
+ })