@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,514 @@
1
+ // transition-default-selection device checks (findings F1/F5/F6) — how an under-specified flat
2
+ // property-lane transition bag resolves to a generator, proven on the committed-sample channel
3
+ // (onUpdate), not host paint. iOS has no tap injection, so the screen self-drives.
4
+ //
5
+ // The three arms, all against `motion@12.42.2`:
6
+ // F1 — a TYPED `{ type: 'tween', duration }` with no authored ease runs the pin's easeOut base
7
+ // `[0, 0, 0.58, 1]` (`animateMotionValue`'s options base, motion-value.ts:45-49), NOT the
8
+ // generator default `[0.25, 0.1, 0.35, 1]`. The sibling DurationOnlyTweenChecksScreen
9
+ // covers the UNTYPED arm; this is the typed one.
10
+ // F5 — `{ duration: 0 }` is INSTANT: the final value commits with no constructed trajectory
11
+ // (`makeAnimationInstant`, motion-value.ts:89-98). An authored `delay` is still honored.
12
+ // F6 — a flat ease-only bag is a 300ms keyframes tween carrying the authored ease; a flat
13
+ // orchestration-only bag merges the PER-KEY default, never the generic REQ-SPRING-003
14
+ // spring 100/10/1.
15
+ //
16
+ // THE F6 LEGS MUST MOUNT, NEVER RETARGET. F6's flat-arm merge lives in
17
+ // `motionViewController.perPropTransition`, which the controller consults only on START-shaped
18
+ // seams (mount entrance, array/color starts, paired/deferred flushes). The scalar UPDATE retarget
19
+ // lane does not consult it TODAY — and that gap is not a design law but finding F6-a, a DEFECT
20
+ // this unit's packet DECLARES DEFERRED. The distinction is load-bearing for whoever reads this
21
+ // next: the merge is unreachable at the retarget seam only until F6-a closes, at which point the
22
+ // lane does consult it and an F6 retarget row becomes valid (and owed). Until then, driving an F6
23
+ // leg as a retarget would exercise deferred behavior and fail legitimately, so each F6 leg is
24
+ // mounted on demand and its MOUNT is its start. Measured at the seam, a flat `{ delay: 0.2 }`
25
+ // resolves `{type:'spring', stiffness:500, damping:25, restSpeed:10, delay:0.2}` on the transform
26
+ // key `x`, and `{type:'keyframes', ease:[0.25,0.1,0.35,1], duration:0.3, delay:0.2}` on the
27
+ // non-transform key `opacity`.
28
+ //
29
+ // The per-key TRANSFORM spring is deliberately NOT a row: it settles in ~100ms, so under a 40ms dt
30
+ // clamp the whole flight is 2-3 frames and its 12.0% overshoot peak can be missed entirely — a
31
+ // band over it would flake on a loaded host instead of measuring the engine. The non-transform leg
32
+ // carries the same F6 per-key law over a 300ms flight that stays well sampled at every clamp, and
33
+ // discriminates just as sharply: the generic spring overshoots 16.3% (measured stable across
34
+ // dt 16.67/25/40) while a keyframes tween never exceeds its target at all.
35
+ //
36
+ // LOAD INDEPENDENCE is load-bearing here. The driver's dt CEILING (40ms; owner REQ-CLOCK-003,
37
+ // consumer REQ-DRIVER-012) stretches a
38
+ // flight under host load — the charter's floor cites a 1s flight reaching ~1.9s — so a mid-flight
39
+ // band keyed to a WALL-CLOCK offset reads a different PROGRESS on a busy host than on an idle one,
40
+ // and flakes. Every curve-shape band below is sampled at a fraction of the flight's OWN observed
41
+ // span (`atSpanFraction`), and every band was measured across a dt 16.67/25/40 sweep against the
42
+ // reference driver before being written down.
43
+
44
+ import { useCallback, useEffect, useRef, useState } from 'react'
45
+ import { StyleSheet, Text, View } from 'react-native'
46
+ import { MotionRoot } from '../../native/motion/MotionRoot'
47
+ import { MotionView } from '../../native/motion/MotionView'
48
+ import { ambientNativeSeverity } from '../../native/motion/severity'
49
+ import { armProofConsoleTap } from './proofConsoleTap'
50
+
51
+ const ROW_F1_EASEOUT = 'F1: typed { type: "tween", duration } with no ease rides the easeOut base'
52
+ const ROW_F1_AUTHORED = 'F1 control: an authored ease spelling suppresses the easeOut base'
53
+ const ROW_F5_INSTANT = 'F5: { duration: 0 } commits the target with NO trajectory'
54
+ const ROW_F5_DELAY = 'F5: { duration: 0, delay } holds, then commits instantly'
55
+ const ROW_F6_FLAT_EASE = 'F6: a flat ease-only bag is a 300ms keyframes tween, not a spring'
56
+ const ROW_F6_FLAT_DELAY = 'F6: a flat delay-only bag merges the per-key default at MOUNT'
57
+ const ROW_CLEAN = 'clean run: zero unexpected reports'
58
+ const ROWS = [
59
+ ROW_F1_EASEOUT,
60
+ ROW_F1_AUTHORED,
61
+ ROW_F5_INSTANT,
62
+ ROW_F5_DELAY,
63
+ ROW_F6_FLAT_EASE,
64
+ ROW_F6_FLAT_DELAY,
65
+ ROW_CLEAN,
66
+ ] as const
67
+
68
+ // Stage boundaries. Each leg is given far more than its nominal length so a dt-clamp stretch under
69
+ // host load lands inside its own window rather than being cut mid-flight (a truncated leg reads as
70
+ // a FAIL, which would be the harness lying about the engine).
71
+ const T_F1_START = 80
72
+ const T_F1_END = 2600
73
+ const T_F5_MOUNT = 2650
74
+ const T_F5_END = 4000
75
+ const T_F5_DELAY_MOUNT = 4050
76
+ const T_F5_DELAY_END = 5800
77
+ const T_F6_EASE_MOUNT = 5850
78
+ const T_F6_EASE_END = 7200
79
+ const T_F6_DELAY_MOUNT = 7250
80
+ const T_F6_DELAY_END = 8800
81
+ const T_CLEAN = 9000
82
+
83
+ const X_TARGET = 100
84
+ const OPACITY_TARGET = 1
85
+
86
+ interface RowResult {
87
+ readonly pass: boolean
88
+ readonly detail: string
89
+ }
90
+
91
+ interface Sample {
92
+ readonly value: number
93
+ readonly at: number
94
+ }
95
+
96
+ /**
97
+ * The sample nearest `fraction` of the way through the flight's OWN observed span.
98
+ *
99
+ * t0 is the first sample off the origin — never the mount/command-edge sample, because on device
100
+ * the first onUpdate can carry the pre-flight value one crossing before the tween clock starts.
101
+ * tEnd is the first sample that has effectively arrived. Normalizing by the observed span is what
102
+ * makes the curve-shape bands immune to a dt-clamp stretch: a flight that takes 1.9s instead of
103
+ * 1.0s under load is still sampled at the same PROGRESS, because the stretch scales the flight.
104
+ */
105
+ function atSpanFraction(
106
+ samples: readonly Sample[],
107
+ target: number,
108
+ fraction: number,
109
+ ): Sample | null {
110
+ const first = samples.find((s) => s.value > target * 0.005)
111
+ if (first === undefined) return null
112
+ const arrived = samples.find((s) => s.value >= target * 0.995)
113
+ if (arrived === undefined) return null
114
+ const at = first.at + (arrived.at - first.at) * fraction
115
+ let best: Sample | null = null
116
+ for (const sample of samples) {
117
+ if (sample.at < first.at || sample.at > arrived.at) continue
118
+ if (best === null || Math.abs(sample.at - at) < Math.abs(best.at - at)) best = sample
119
+ }
120
+ return best
121
+ }
122
+
123
+ /** Samples strictly between the origin and the target — the evidence that a trajectory was built. */
124
+ function interiorSamples(samples: readonly Sample[], target: number): readonly Sample[] {
125
+ return samples.filter((s) => s.value > target * 0.02 && s.value < target * 0.98)
126
+ }
127
+
128
+ function peakOf(samples: readonly Sample[]): number {
129
+ return samples.reduce((max, s) => Math.max(max, s.value), 0)
130
+ }
131
+
132
+ export function TransitionDefaultSelectionChecksScreen() {
133
+ const severity = ambientNativeSeverity()
134
+ const [targetF1, setTargetF1] = useState<'a' | 'b'>('a')
135
+ const [targetF1Control, setTargetF1Control] = useState<'a' | 'b'>('a')
136
+ // The F5/F6 legs mount on demand: their MOUNT is the start seam F6's per-key merge requires
137
+ // (see the F6-a note in the header). A boolean per leg, never a retarget.
138
+ const [mountF5, setMountF5] = useState(false)
139
+ const [mountF5Delay, setMountF5Delay] = useState(false)
140
+ const [mountF6Ease, setMountF6Ease] = useState(false)
141
+ const [mountF6Delay, setMountF6Delay] = useState(false)
142
+ const [stage, setStage] = useState('boot')
143
+ const [rows, setRows] = useState<Partial<Record<(typeof ROWS)[number], RowResult>>>({})
144
+
145
+ const samplesF1 = useRef<Sample[]>([])
146
+ const samplesF1Control = useRef<Sample[]>([])
147
+ const samplesF5 = useRef<Sample[]>([])
148
+ const samplesF5Delay = useRef<Sample[]>([])
149
+ const samplesF6Ease = useRef<Sample[]>([])
150
+ const samplesF6Delay = useRef<Sample[]>([])
151
+ // The mount edge each on-demand leg is measured against — an instant commit and an honored
152
+ // delay are both only meaningful relative to when the leg actually mounted.
153
+ const mountedAtF5Delay = useRef(0)
154
+ const mountedAtF6Delay = useRef(0)
155
+
156
+ const reportsRef = useRef<string[]>([])
157
+ const restoreTapRef = useRef<null | (() => void)>(null)
158
+ if (restoreTapRef.current === null) {
159
+ restoreTapRef.current = armProofConsoleTap(reportsRef.current, (message) =>
160
+ /motion|transition|spring|tween/i.test(message),
161
+ )
162
+ }
163
+
164
+ const finalize = (id: (typeof ROWS)[number], pass: boolean, detail: string): void => {
165
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
166
+ }
167
+
168
+ const pushX =
169
+ (sink: { current: Sample[] }) =>
170
+ (latest: Readonly<Record<string, string | number>>): void => {
171
+ if (typeof latest.x === 'number') sink.current.push({ value: latest.x, at: Date.now() })
172
+ }
173
+ const pushOpacity =
174
+ (sink: { current: Sample[] }) =>
175
+ (latest: Readonly<Record<string, string | number>>): void => {
176
+ if (typeof latest.opacity === 'number') {
177
+ sink.current.push({ value: latest.opacity, at: Date.now() })
178
+ }
179
+ }
180
+ const onF1 = useCallback(pushX(samplesF1), [])
181
+ const onF1Control = useCallback(pushX(samplesF1Control), [])
182
+ const onF5 = useCallback(pushX(samplesF5), [])
183
+ const onF5Delay = useCallback(pushX(samplesF5Delay), [])
184
+ const onF6Ease = useCallback(pushX(samplesF6Ease), [])
185
+ const onF6Delay = useCallback(pushOpacity(samplesF6Delay), [])
186
+
187
+ useEffect(() => {
188
+ const timers: ReturnType<typeof setTimeout>[] = []
189
+ const at = (ms: number, run: () => void): void => {
190
+ timers.push(setTimeout(run, ms))
191
+ }
192
+
193
+ at(T_F1_START, () => {
194
+ setTargetF1('b')
195
+ setTargetF1Control('b')
196
+ setStage('F1 typed-tween legs started (no ease / authored linear)')
197
+ })
198
+
199
+ at(T_F1_END, () => {
200
+ // Sampled at ONE FIFTH of the span, not the half. The easeOut and retired-default curves
201
+ // CROSS near f=0.35, so at the half they are only ~4.7 apart and a band admitting easeOut
202
+ // would also admit the regression. Measured across dt 16.67/25/40: easeOut 30.85..36.42,
203
+ // the retired [0.25,0.1,0.35,1] default 25.33..25.45, linear 21.67..24.00. The band admits
204
+ // easeOut at every clamp and excludes both alternatives.
205
+ const mid = atSpanFraction(samplesF1.current, X_TARGET, 0.2)
206
+ const pass = mid !== null && mid.value >= 28 && mid.value <= 40
207
+ finalize(
208
+ ROW_F1_EASEOUT,
209
+ pass,
210
+ mid === null
211
+ ? `no arrival within the window (${samplesF1.current.length} samples)`
212
+ : `fifth-span sample reads ${mid.value.toFixed(1)} — easeOut 30.9..36.4, ` +
213
+ `the retired default 25.4, linear 21.7..24.0`,
214
+ )
215
+
216
+ // The control reads cleanest at the HALF, where linear (50.0) is far from easeOut (66.7);
217
+ // its question is only whether an authored ease suppresses the base, so it does not need
218
+ // to separate linear from the retired default.
219
+ const control = atSpanFraction(samplesF1Control.current, X_TARGET, 0.5)
220
+ const controlPass = control !== null && control.value >= 44 && control.value <= 56
221
+ finalize(
222
+ ROW_F1_AUTHORED,
223
+ controlPass,
224
+ control === null
225
+ ? `no arrival within the window (${samplesF1Control.current.length} samples)`
226
+ : `half-span sample reads ${control.value.toFixed(1)} — authored linear 50.0 ` +
227
+ `(the easeOut base is suppressed, never merged; easeOut would read 66.7)`,
228
+ )
229
+
230
+ setStage(pass && controlPass ? 'F1 pass; F5 instant leg mounts' : 'F1 FAIL; F5 leg mounts')
231
+ })
232
+
233
+ at(T_F5_MOUNT, () => {
234
+ setMountF5(true)
235
+ })
236
+
237
+ at(T_F5_END, () => {
238
+ const samples = samplesF5.current
239
+ const interior = interiorSamples(samples, X_TARGET)
240
+ const arrived = samples.some((s) => s.value >= X_TARGET * 0.995)
241
+ // An instant builds NO trajectory: the final value commits and nothing lands between the
242
+ // origin and the target. Interior samples would mean a constructed animation.
243
+ const pass = arrived && interior.length === 0
244
+ finalize(
245
+ ROW_F5_INSTANT,
246
+ pass,
247
+ `${interior.length} interior samples (an instant builds none), arrived=${String(arrived)}, ` +
248
+ `${samples.length} samples total`,
249
+ )
250
+ setStage(pass ? 'F5 instant pass; delayed-instant mounts' : 'F5 FAIL; delay leg mounts')
251
+ })
252
+
253
+ at(T_F5_DELAY_MOUNT, () => {
254
+ mountedAtF5Delay.current = Date.now()
255
+ setMountF5Delay(true)
256
+ })
257
+
258
+ at(T_F5_DELAY_END, () => {
259
+ const samples = samplesF5Delay.current
260
+ const interior = interiorSamples(samples, X_TARGET)
261
+ const arrival = samples.find((s) => s.value >= X_TARGET * 0.995)
262
+ const heldMs = arrival === undefined ? 0 : arrival.at - mountedAtF5Delay.current
263
+ // The authored 500ms delay is honored, then the commit is still instant. Measured 500..525ms
264
+ // across the dt sweep; the floor is generous below because a stretch only ever makes the
265
+ // hold LONGER, and the mount edge adds host latency on top.
266
+ const pass = arrival !== undefined && interior.length === 0 && heldMs >= 350
267
+ finalize(
268
+ ROW_F5_DELAY,
269
+ pass,
270
+ arrival === undefined
271
+ ? `never committed (${samples.length} samples)`
272
+ : `held ${heldMs}ms from mount before committing (authored delay 500ms), ` +
273
+ `${interior.length} interior samples`,
274
+ )
275
+ setStage(pass ? 'F5 delay pass; F6 flat-ease mounts' : 'F5 delay FAIL; F6 ease mounts')
276
+ })
277
+
278
+ at(T_F6_EASE_MOUNT, () => {
279
+ setMountF6Ease(true)
280
+ })
281
+
282
+ at(T_F6_EASE_END, () => {
283
+ // A flat `{ ease: 'linear' }` bag is DEFINED-but-typeless: the keyframes generator at its
284
+ // 300ms default, carrying the authored ease. Measured peak exactly 1.0000x target and span
285
+ // 275..283ms across the dt sweep. The generic spring would overshoot; linear never does.
286
+ const samples = samplesF6Ease.current
287
+ const mid = atSpanFraction(samples, X_TARGET, 0.5)
288
+ const peak = peakOf(samples)
289
+ const pass = mid !== null && mid.value >= 42 && mid.value <= 58 && peak <= X_TARGET * 1.005
290
+ finalize(
291
+ ROW_F6_FLAT_EASE,
292
+ pass,
293
+ mid === null
294
+ ? `no arrival within the window (${samples.length} samples)`
295
+ : `half-span sample reads ${mid.value.toFixed(1)} (linear 50), peak ` +
296
+ `${peak.toFixed(2)} — a spring would overshoot the target`,
297
+ )
298
+ setStage(pass ? 'F6 ease pass; F6 flat-delay mounts' : 'F6 ease FAIL; delay leg mounts')
299
+ })
300
+
301
+ at(T_F6_DELAY_MOUNT, () => {
302
+ mountedAtF6Delay.current = Date.now()
303
+ setMountF6Delay(true)
304
+ })
305
+
306
+ at(T_F6_DELAY_END, () => {
307
+ // A flat `{ delay: 0.2 }` bag on the NON-TRANSFORM key `opacity` merges the per-key default
308
+ // `{type:'keyframes', ease:[0.25,0.1,0.35,1], duration:0.3, delay:0.2}` at the mount seam.
309
+ // The discriminator is binary and clock-free: a keyframes tween never exceeds its target,
310
+ // while the generic REQ-SPRING-003 spring the pre-F6 backstop resolved overshoots 16.3%
311
+ // (measured stable at dt 16.67/25/40). The delay must also survive the merge.
312
+ const samples = samplesF6Delay.current
313
+ const peak = peakOf(samples)
314
+ const arrival = samples.find((s) => s.value > OPACITY_TARGET * 0.02)
315
+ const delayedMs = arrival === undefined ? 0 : arrival.at - mountedAtF6Delay.current
316
+ const arrived = samples.some((s) => s.value >= OPACITY_TARGET * 0.995)
317
+ const pass = arrived && peak <= OPACITY_TARGET * 1.005 && delayedMs >= 120
318
+ finalize(
319
+ ROW_F6_FLAT_DELAY,
320
+ pass,
321
+ `peak ${peak.toFixed(4)} — the per-key 0.3s tween never exceeds ${OPACITY_TARGET}, ` +
322
+ `the generic spring peaks 1.163; flight began ${delayedMs}ms after mount ` +
323
+ `(authored delay 200ms), arrived=${String(arrived)}`,
324
+ )
325
+ setStage(pass ? 'F6 delay pass' : 'F6 delay FAIL')
326
+ })
327
+
328
+ at(T_CLEAN, () => {
329
+ const pass = reportsRef.current.length === 0
330
+ finalize(
331
+ ROW_CLEAN,
332
+ pass,
333
+ pass
334
+ ? '0 unexpected reports'
335
+ : `${reportsRef.current.length} unexpected: ${reportsRef.current
336
+ .map((message) => message.slice(0, 80))
337
+ .join(' | ')}`,
338
+ )
339
+ setStage('done')
340
+ })
341
+
342
+ return () => {
343
+ for (const timer of timers) clearTimeout(timer)
344
+ restoreTapRef.current?.()
345
+ restoreTapRef.current = null
346
+ }
347
+ }, [])
348
+
349
+ const done = ROWS.every((id) => rows[id] !== undefined)
350
+ const failing = ROWS.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
351
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
352
+ const passed = Object.keys(rows).length - failing.length
353
+
354
+ return (
355
+ <MotionRoot>
356
+ <View style={styles.scroll}>
357
+ <View style={styles.container}>
358
+ <Text style={styles.title}>transition-default-selection checks</Text>
359
+ <Text style={styles.subtitle}>
360
+ F1 easeOut base · F5 duration-zero instant · F6 flat sparse-bag ({severity} severity)
361
+ </Text>
362
+ <View
363
+ style={[
364
+ styles.banner,
365
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
366
+ ]}
367
+ >
368
+ <Text style={styles.bannerText}>{verdict}</Text>
369
+ </View>
370
+ <Text style={styles.tally}>
371
+ {done
372
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROWS.length} rows`
373
+ : `${Object.keys(rows).length}/${ROWS.length} rows complete — RUNNING`}
374
+ </Text>
375
+ <Text style={styles.tallyDetail}>
376
+ {done
377
+ ? failing.length === 0
378
+ ? 'no failing rows'
379
+ : `FAILING: ${failing.join(' | ')}`
380
+ : `stage: ${stage}`}
381
+ </Text>
382
+
383
+ <View style={styles.track}>
384
+ <MotionView
385
+ animate={targetF1 === 'a' ? { x: 0 } : { x: X_TARGET }}
386
+ transition={{ type: 'tween', duration: 1 }}
387
+ onUpdate={onF1}
388
+ style={styles.box}
389
+ accessibilityLabel="tds-f1-easeout-box"
390
+ />
391
+ <MotionView
392
+ animate={targetF1Control === 'a' ? { x: 0 } : { x: X_TARGET }}
393
+ transition={{ type: 'tween', duration: 1, ease: 'linear' }}
394
+ onUpdate={onF1Control}
395
+ style={[styles.box, styles.boxControl]}
396
+ accessibilityLabel="tds-f1-authored-box"
397
+ />
398
+ {mountF5 ? (
399
+ <MotionView
400
+ initial={{ x: 0 }}
401
+ animate={{ x: X_TARGET }}
402
+ transition={{ duration: 0 }}
403
+ onUpdate={onF5}
404
+ style={[styles.box, styles.boxInstant]}
405
+ accessibilityLabel="tds-f5-instant-box"
406
+ />
407
+ ) : (
408
+ <View style={[styles.box, styles.boxIdle]} />
409
+ )}
410
+ {mountF5Delay ? (
411
+ <MotionView
412
+ initial={{ x: 0 }}
413
+ animate={{ x: X_TARGET }}
414
+ transition={{ duration: 0, delay: 0.5 }}
415
+ onUpdate={onF5Delay}
416
+ style={[styles.box, styles.boxInstant]}
417
+ accessibilityLabel="tds-f5-delay-box"
418
+ />
419
+ ) : (
420
+ <View style={[styles.box, styles.boxIdle]} />
421
+ )}
422
+ {mountF6Ease ? (
423
+ <MotionView
424
+ initial={{ x: 0 }}
425
+ animate={{ x: X_TARGET }}
426
+ transition={{ ease: 'linear' }}
427
+ onUpdate={onF6Ease}
428
+ style={[styles.box, styles.boxFlat]}
429
+ accessibilityLabel="tds-f6-flatease-box"
430
+ />
431
+ ) : (
432
+ <View style={[styles.box, styles.boxIdle]} />
433
+ )}
434
+ {mountF6Delay ? (
435
+ <MotionView
436
+ initial={{ opacity: 0 }}
437
+ animate={{ opacity: OPACITY_TARGET }}
438
+ transition={{ delay: 0.2 }}
439
+ onUpdate={onF6Delay}
440
+ style={[styles.box, styles.boxFlat]}
441
+ accessibilityLabel="tds-f6-flatdelay-box"
442
+ />
443
+ ) : (
444
+ <View style={[styles.box, styles.boxIdle]} />
445
+ )}
446
+ </View>
447
+
448
+ {ROWS.map((id) => {
449
+ const row = rows[id]
450
+ return (
451
+ <View key={id} style={styles.row}>
452
+ <Text
453
+ style={[
454
+ styles.verdict,
455
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
456
+ ]}
457
+ >
458
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
459
+ </Text>
460
+ <View style={styles.rowBody}>
461
+ <Text style={styles.rowId}>{id}</Text>
462
+ <Text style={styles.rowDetail} numberOfLines={3}>
463
+ {row === undefined ? 'pending' : row.detail}
464
+ </Text>
465
+ </View>
466
+ </View>
467
+ )
468
+ })}
469
+ </View>
470
+ </View>
471
+ </MotionRoot>
472
+ )
473
+ }
474
+
475
+ const styles = StyleSheet.create({
476
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
477
+ container: { paddingVertical: 52, paddingHorizontal: 8 },
478
+ title: { color: '#fff', fontSize: 20, fontWeight: '700', textAlign: 'center' },
479
+ subtitle: { color: '#8a8aa0', fontSize: 11, marginTop: 6, textAlign: 'center' },
480
+ banner: {
481
+ alignSelf: 'center',
482
+ paddingHorizontal: 36,
483
+ paddingVertical: 8,
484
+ borderRadius: 10,
485
+ marginTop: 14,
486
+ marginBottom: 8,
487
+ },
488
+ pass: { backgroundColor: '#1d7a3e' },
489
+ fail: { backgroundColor: '#a32633' },
490
+ running: { backgroundColor: '#7a641d' },
491
+ bannerText: { color: '#fff', fontSize: 20, fontWeight: '800', letterSpacing: 2 },
492
+ tally: { color: '#e6e6f0', fontSize: 13, fontWeight: '700', textAlign: 'center' },
493
+ tallyDetail: { color: '#f87171', fontSize: 10, marginTop: 3, textAlign: 'center' },
494
+ track: { marginTop: 8, marginBottom: 4 },
495
+ box: {
496
+ width: 24,
497
+ height: 24,
498
+ marginTop: 5,
499
+ backgroundColor: '#ef4444',
500
+ borderRadius: 6,
501
+ },
502
+ boxControl: { backgroundColor: '#38bdf8' },
503
+ boxInstant: { backgroundColor: '#a78bfa' },
504
+ boxFlat: { backgroundColor: '#f97316' },
505
+ boxIdle: { backgroundColor: '#23233a' },
506
+ row: { flexDirection: 'row', marginTop: 8 },
507
+ verdict: { width: 46, fontSize: 11, fontWeight: '800', paddingTop: 1 },
508
+ ok: { color: '#4ade80' },
509
+ bad: { color: '#f87171' },
510
+ pending: { color: '#8a8aa0' },
511
+ rowBody: { flex: 1 },
512
+ rowId: { color: '#e6e6f0', fontSize: 12, fontWeight: '600' },
513
+ rowDetail: { color: '#6f6f85', fontSize: 10, marginTop: 2 },
514
+ })