@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- 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
|
+
})
|