@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,614 @@
1
+ // U7a reduced-motion device checks (the U7a packet's floor 3 — dissolved at the unit terminal
2
+ // into REQ-API-027-adjacent surface text): the
3
+ // native card mirroring the pinned catalog useReducedMotion.tsx example — an outer motion View
4
+ // carrying the animate label with initial={false}, an inner variants-only child whose embedded
5
+ // transition is SELECTED BY THE HOOK SNAPSHOT (`shouldReduceMotion ? { type: false } :
6
+ // { duration: 1 }`), visibility toggling every ~1.5s. Five claims, self-driven:
7
+ //
8
+ // 1. HOOK RESOLVE — the mounted hook's first-render value is false (the pin's matchMedia-absent
9
+ // fallback, packet F2's async transient) and the resolved value equals a DIRECT
10
+ // AccessibilityInfo.isReduceMotionEnabled() read taken at +900ms. On the proof devices the
11
+ // OS preference is OFF, so the resolve is a same-value setState bail; the one-update /
12
+ // no-subscription snapshot law is pinned by the mock-driven useReducedMotion.test.ts — the
13
+ // row says so rather than claiming a device witness it cannot have.
14
+ // 2. SNAPSHOT SELECTS THE TWEEN — with the resolved value false the example's toggle drives
15
+ // opacity through the 1s flight: mid-fade onUpdate samples must land STRICTLY between 0.02
16
+ // and 0.98 in BOTH fades (a broken label propagation animates nothing; an instant commit
17
+ // shows no interior samples), each fade must REACH its target, and the flight-activity
18
+ // span (how long the samples keep moving) must read the 1s class — all measured from the
19
+ // committed samples, never the settle marshal. The fade peak and the onAnimationComplete
20
+ // stamps are recorded as evidence, not gated: the 2026-08-18 proof showed the flight
21
+ // running with a spring OVERSHOOT (peak ≈1.046 — native's omitted-type { duration }
22
+ // mapping, FIXED by REQ-TIMING-006: the fade now tweens monotone, peak ≤ 1.0) and zero
23
+ // completion deliveries (RESOLVED 2026-08-21 by the finding-B unit: the dispatch chain was
24
+ // intact — the fade-in was superseded by the raced fade-out retarget, which the widened
25
+ // toggle gap above removes, and the card read completions once past their landing).
26
+ // 3. THE INSTANT LANE (contract 0's device witness) — a second box on an AUTHORED
27
+ // `{ type: false }` transition: on retarget the final value commits with NO flight. The
28
+ // same-tick sample must still read the origin (the commit is NEXT-FRAME, never synchronous
29
+ // at the retarget call) and the rAF chain must read the target within the 4-frame
30
+ // JS-readback band: the pin's law covers the UI-side commit (the driver's next update
31
+ // tick), while this card measures the JS readback, which adds the command crossing, the
32
+ // t=0 frame (null dt — the pin's frame.update deferral), and the marshal's setTimeout(0)
33
+ // return; the delay leg's +350ms on a 300ms delay corroborates the ~3-frame round trip.
34
+ // Any post-retarget sample strictly between origin and target is a flight and fails.
35
+ // 4. AUTHORED DELAY — the same box retargeted on `{ type: false, delay: 0.3 }`: a +150ms
36
+ // sample still reads the origin, a +600ms sample reads the target (the pin's authored-form
37
+ // asymmetry — the global instant flag zeroes delay, the authored form honors it).
38
+ // 5. CLEAN RUN — zero refusal reports through the console tap (the U5 pattern: console.error
39
+ // is the severity law's production error channel) across the whole timeline.
40
+ //
41
+ // Readback honesty: every opacity sample rides the element's own onUpdate (the driver's
42
+ // per-frame committed-values marshal) — the value the host actually received, never a JS-side
43
+ // mirror. A screenshot is the assertion; the console tap is evidence capture, never suppression.
44
+
45
+ import { useEffect, useMemo, useRef, useState } from 'react'
46
+ import { AccessibilityInfo, ScrollView, StyleSheet, Text, View } from 'react-native'
47
+ import type { Transition } from '@unrulysystems/native-motion-core'
48
+ import { MotionView } from '../../native/motion/MotionView'
49
+ import { ambientNativeSeverity } from '../../native/motion/severity'
50
+ import { useReducedMotion } from '../../native/motion/useReducedMotion'
51
+
52
+ const ROW_HOOK = 'hook resolve: first-render false, resolved value = the direct OS read'
53
+ const ROW_TWEEN = 'snapshot selects the 1s tween: mid-fade samples strictly between 0.02 and 0.98'
54
+ const ROW_INSTANT = 'authored { type: false }: no flight — next-frame commit, never synchronous'
55
+ const ROW_DELAY = 'authored { type: false, delay: 0.3 }: the authored delay is honored'
56
+ const ROW_CLEAN = 'clean run: zero refusal reports through the console tap'
57
+ const ROW_ORDER = [ROW_HOOK, ROW_TWEEN, ROW_INSTANT, ROW_DELAY, ROW_CLEAN] as const
58
+
59
+ // The instant box's two legs: 0.15 → 0.85 bare, then 0.85 → 0.15 under the authored 300ms delay.
60
+ const INSTANT_ORIGIN = 0.15
61
+ const INSTANT_TARGET = 0.85
62
+ const EPS = 0.005
63
+
64
+ // Timeline (ms). Dwells make every staged state capturable: the boot frame holds the pin
65
+ // example's hidden mount (0–1500, the initial={false} witness), the fade-in mid-flight window is
66
+ // ~1900–2300 and the fade-out's ~5000–5400, the instant-lane commit dwells 7600–8100, and the
67
+ // final verdict holds from ~9700 on. The 2026-08-21 re-base (finding-B resolution, REQ-API-060):
68
+ // the toggle gap widened 1700 → 3100ms so the 1s fade-in completes BEFORE the fade-out retarget
69
+ // even under the measured ~770ms mount→flight-start latency — at the original 3200 the fade-in
70
+ // was superseded mid-flight (peak 0.858, no completion), a healthy-but-raced flight, not a
71
+ // defect; and row 2's finalize is event-driven on the two completions (a fixed wall-clock
72
+ // finalize raced the stretched flight both ways).
73
+ const T_HOOK = 900
74
+ const T_TOGGLE_1 = 1500
75
+ const T_TOGGLE_2 = 4600
76
+ const T_INSTANT_RETARGET = 7000
77
+ const T_INSTANT_FINALIZE = 7600
78
+ const T_DELAY_RETARGET = 8100
79
+ const T_DELAY_EARLY = 8250
80
+ const T_DELAY_LATE = 8700
81
+ const T_DELAY_FINALIZE = 8900
82
+ const T_CLEAN = 9500
83
+ const T_DONE = 9700
84
+
85
+ interface RowResult {
86
+ readonly pass: boolean
87
+ readonly detail: string
88
+ }
89
+
90
+ interface OpacitySample {
91
+ readonly at: number
92
+ readonly value: number
93
+ }
94
+
95
+ function readOpacity(latest: Readonly<Record<string, string | number>>): number | null {
96
+ const raw = latest['opacity']
97
+ const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
98
+ return Number.isFinite(value) ? value : null
99
+ }
100
+
101
+ // The flight-activity span: how long the committed samples keep MOVING (a later sample differs
102
+ // by > 0.002), measured from the FIRST moving sample to the last. Generator-agnostic by design —
103
+ // the pin's 1s tween and native's 1s duration-spring both move for ~1s; a default-duration
104
+ // fallback (~0.3s) or an instant commit falls short. Latency-agnostic by the same design
105
+ // (2026-08-21, finding-B re-base): measuring from the toggle conflated the ~770ms
106
+ // mount→flight-start latency with the flight class and read 1944ms on a healthy 1s tween. The
107
+ // band tops at 2400ms because the driver's dt CEILING (40ms; owner REQ-CLOCK-003, consumer
108
+ // REQ-DRIVER-012) stretches a 1s
109
+ // flight to ~1.9s under a loaded host — a 3s flight still reads well past the band.
110
+ function activitySpan(w: readonly OpacitySample[]): number | null {
111
+ let firstMoving = -1
112
+ for (let i = 0; i < w.length - 1; i++) {
113
+ if (Math.abs(w[i + 1]!.value - w[i]!.value) > 0.002) {
114
+ firstMoving = i
115
+ break
116
+ }
117
+ }
118
+ if (firstMoving === -1) return null
119
+ for (let i = w.length - 2; i >= firstMoving; i--) {
120
+ if (Math.abs(w[i + 1]!.value - w[i]!.value) > 0.002) return w[i + 1]!.at - w[firstMoving]!.at
121
+ }
122
+ return null
123
+ }
124
+
125
+ function fmtSpan(span: number | null): string {
126
+ return span === null ? 'none' : `~${span.toFixed(0)}ms`
127
+ }
128
+
129
+ export function ReducedMotionChecksScreen() {
130
+ const severity = ambientNativeSeverity()
131
+ const shouldReduceMotion = useReducedMotion()
132
+
133
+ // The pin example's exact structure: the transition is selected by the hook snapshot and rides
134
+ // INSIDE the variant entries. Memoized on the hook value so an unrelated re-render never
135
+ // re-identities the dictionary into the label lane (R7 law b: labels execute on a definition
136
+ // change) — on the proof devices the value never changes after resolve, so this is the pin's
137
+ // `transition` constant, not a reactive re-arm.
138
+ const variants = useMemo(() => {
139
+ const transition: Transition = shouldReduceMotion ? { type: false } : { duration: 1 }
140
+ return {
141
+ visible: { opacity: 1, transition },
142
+ hidden: { opacity: 0, transition },
143
+ }
144
+ }, [shouldReduceMotion])
145
+
146
+ const [isVisible, setIsVisible] = useState(false)
147
+ const [instantTarget, setInstantTarget] = useState(INSTANT_ORIGIN)
148
+ const [instantTransition, setInstantTransition] = useState<Transition>({ type: false })
149
+ const [stage, setStage] = useState('boot: mounted hidden (initial={false}), hook resolving')
150
+ const [liveTween, setLiveTween] = useState('—')
151
+ const [liveInstant, setLiveInstant] = useState(INSTANT_ORIGIN.toFixed(3))
152
+ const [rows, setRows] = useState<Partial<Record<(typeof ROW_ORDER)[number], RowResult>>>({})
153
+
154
+ // Row 1 evidence: the first-render value, the live resolved value (a ref so the single-run
155
+ // timeline never reads a stale closure), and the distinct-value history (the snapshot law).
156
+ const mountStampRef = useRef(Date.now())
157
+ const firstRenderRef = useRef<boolean | null>(null)
158
+ if (firstRenderRef.current === null) firstRenderRef.current = shouldReduceMotion
159
+ const resolvedRef = useRef(shouldReduceMotion)
160
+ resolvedRef.current = shouldReduceMotion
161
+ const valueHistoryRef = useRef<readonly (boolean | null)[]>([])
162
+
163
+ // Row 2 evidence: every committed opacity sample + completion stamps on the tween subject.
164
+ const tweenSamplesRef = useRef<OpacitySample[]>([])
165
+ const tweenCompletionsRef = useRef<number[]>([])
166
+ const toggleStampsRef = useRef<number[]>([])
167
+
168
+ // Rows 3/4 evidence: the instant box's committed samples, the same-tick read, the retarget
169
+ // rAF chain, and the delay leg's early/late reads.
170
+ const instantSamplesRef = useRef<OpacitySample[]>([])
171
+ const instantLatestRef = useRef(INSTANT_ORIGIN)
172
+ const instantStampRef = useRef(0)
173
+ const delayStampRef = useRef(0)
174
+ const sameTickRef = useRef<number | null>(null)
175
+ const frameChainRef = useRef<OpacitySample[]>([])
176
+ const delayEarlyRef = useRef<number | null>(null)
177
+ const delayLateRef = useRef<number | null>(null)
178
+
179
+ const reportsRef = useRef<string[]>([])
180
+
181
+ useEffect(() => {
182
+ valueHistoryRef.current = [...valueHistoryRef.current, shouldReduceMotion]
183
+ }, [shouldReduceMotion])
184
+
185
+ const finalize = (id: (typeof ROW_ORDER)[number], pass: boolean, detail: string): void => {
186
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
187
+ }
188
+
189
+ useEffect(() => {
190
+ const mountStamp = mountStampRef.current
191
+ const now = (): number => Date.now() - mountStamp
192
+
193
+ // The production error channel tap (the U5 pattern): refusal reports ride consoleReporter →
194
+ // console.error. Installed for this screen's lifetime only; everything passes through.
195
+ const originalError = console.error
196
+ console.error = (...args: unknown[]) => {
197
+ for (const arg of args) {
198
+ const message = arg instanceof Error ? arg.message : typeof arg === 'string' ? arg : null
199
+ if (message !== null && /motion/i.test(message)) reportsRef.current.push(message)
200
+ }
201
+ originalError(...args)
202
+ }
203
+
204
+ const timers: ReturnType<typeof setTimeout>[] = []
205
+ const at = (ms: number, run: () => void): void => {
206
+ timers.push(setTimeout(run, ms))
207
+ }
208
+ let raf: ReturnType<typeof requestAnimationFrame> | null = null
209
+
210
+ const fmt = (value: number | null): string =>
211
+ value === null ? 'unavailable' : value.toFixed(3)
212
+
213
+ // 1. HOOK RESOLVE — compare the mounted hook's resolved value against a direct OS read.
214
+ at(T_HOOK, () => {
215
+ void AccessibilityInfo.isReduceMotionEnabled()
216
+ .then((osEnabled) => {
217
+ const resolved = resolvedRef.current
218
+ const changes = valueHistoryRef.current.length - 1
219
+ const pass = firstRenderRef.current === false && resolved === osEnabled && changes <= 1
220
+ finalize(
221
+ ROW_HOOK,
222
+ pass,
223
+ `first render ${String(firstRenderRef.current)} (the pin's matchMedia-absent ` +
224
+ `fallback — packet F2's async transient); resolved ${String(resolved)} = direct ` +
225
+ `AccessibilityInfo read ${String(osEnabled)} at +${T_HOOK}ms; ${changes} value ` +
226
+ 'change(s) this mount (OS OFF → the false→false resolve is a same-value setState ' +
227
+ 'bail; the one-update/no-subscription snapshot law is pinned by the mock-driven ' +
228
+ 'useReducedMotion.test.ts, not claimable on device)',
229
+ )
230
+ setStage('hook resolved — toggle 1: hidden → visible on the selected transition')
231
+ })
232
+ .catch((error: unknown) => {
233
+ finalize(
234
+ ROW_HOOK,
235
+ false,
236
+ `the direct AccessibilityInfo read rejected: ${error instanceof Error ? error.message : String(error)}`,
237
+ )
238
+ })
239
+ })
240
+
241
+ // 2. The pin example's toggle: visibility flips ~every 1.5s, the snapshot-selected
242
+ // transition carries it (resolved false on the proof devices → the 1s tween).
243
+ at(T_TOGGLE_1, () => {
244
+ toggleStampsRef.current = [now()]
245
+ setIsVisible(true)
246
+ setStage('fade-in: hidden → visible (mid-flight capture window ~+1.9–2.3s)')
247
+ })
248
+ at(T_TOGGLE_2, () => {
249
+ toggleStampsRef.current = [...toggleStampsRef.current, now()]
250
+ setIsVisible(false)
251
+ setStage('fade-out: visible → hidden (mid-flight capture window ~+5.0–5.4s)')
252
+ })
253
+ const finalizeTweenRow = (): void => {
254
+ const [t1, t2] = toggleStampsRef.current
255
+ const samples = tweenSamplesRef.current
256
+ const completions = tweenCompletionsRef.current
257
+ if (t1 === undefined || t2 === undefined) {
258
+ finalize(ROW_TWEEN, false, 'a toggle never fired — the timeline is broken')
259
+ return
260
+ }
261
+ // The 2026-08-21 latency-tolerant window (finding-B resolution): 1400ms raced the
262
+ // ~770ms mount→flight-start latency — the flight ends at toggle+~1800ms on the proof
263
+ // devices, so the peak/trough read landed mid-flight (0.844) even with the supersession
264
+ // removed. 2400ms covers flight + latency with margin and never reaches the next toggle.
265
+ const window = (t: number): OpacitySample[] =>
266
+ samples.filter((s) => s.at >= t && s.at <= t + 2400)
267
+ const interior = (t: number): OpacitySample[] =>
268
+ window(t).filter((s) => s.value > 0.02 && s.value < 0.98)
269
+ const in1 = interior(t1)
270
+ const in2 = interior(t2)
271
+ const w1 = window(t1)
272
+ const w2 = window(t2)
273
+ const span1 = activitySpan(w1)
274
+ const span2 = activitySpan(w2)
275
+ const peak1 = w1.reduce((max, s) => Math.max(max, s.value), 0)
276
+ const trough2 = w2.reduce((min, s) => Math.min(min, s.value), 1)
277
+ const preToggle = samples.filter((s) => s.at < t1).length
278
+ let bad: string | null = null
279
+ if (in1.length < 3) {
280
+ bad = `fade-in produced ${in1.length} interior sample(s) — a broken label propagation animates nothing, an instant commit shows none`
281
+ } else if (in1[in1.length - 1]!.value <= in1[0]!.value) {
282
+ bad = `fade-in interior samples do not climb (${in1[0]!.value.toFixed(3)} → ${in1[in1.length - 1]!.value.toFixed(3)})`
283
+ } else if (in2.length < 3) {
284
+ bad = `fade-out produced ${in2.length} interior sample(s)`
285
+ } else if (in2[in2.length - 1]!.value >= in2[0]!.value) {
286
+ bad = `fade-out interior samples do not fall (${in2[0]!.value.toFixed(3)} → ${in2[in2.length - 1]!.value.toFixed(3)})`
287
+ } else if (peak1 < 0.98) {
288
+ bad = `fade-in never reached the target (peak ${peak1.toFixed(3)})`
289
+ } else if (trough2 > 0.02) {
290
+ bad = `fade-out never reached the target (trough ${trough2.toFixed(3)})`
291
+ } else if (span1 === null || span1 < 600 || span1 > 2400) {
292
+ bad = `fade-in flight activity ${fmtSpan(span1)} — want the 1s class (600–2400ms of movement)`
293
+ } else if (span2 === null || span2 < 600 || span2 > 2400) {
294
+ bad = `fade-out flight activity ${fmtSpan(span2)} — want the 1s class`
295
+ }
296
+ // Measured evidence, never gated: the fade peak (peak ≤ 1.0 since REQ-TIMING-006 retired
297
+ // the omitted-type duration-spring mapping; the 2026-08-18 pre-fix run read ≈1.046) and
298
+ // the onAnimationComplete delivery (finding B resolved 2026-08-21: with the widened toggle
299
+ // gap the fade-in is never superseded, so BOTH completions land — the dispatch chain was
300
+ // intact all along, REQ-API-060).
301
+ const completionEvidence =
302
+ completions.length === 0
303
+ ? 'none delivered by finalize (finding-B signature — the widened gap should have ' +
304
+ 'removed the supersession; investigate before re-proofing)'
305
+ : completions.map((c) => `+${(c - t1).toFixed(0)}ms`).join(', ')
306
+ finalize(
307
+ ROW_TWEEN,
308
+ bad === null,
309
+ bad ??
310
+ `fade-in ${in1.length} interior samples ${in1[0]!.value.toFixed(3)}→${in1[in1.length - 1]!.value.toFixed(3)}, ` +
311
+ `peak ${peak1.toFixed(3)}, activity ${fmtSpan(span1)}; fade-out ${in2.length} interior ` +
312
+ `${in2[0]!.value.toFixed(3)}→${in2[in2.length - 1]!.value.toFixed(3)}, activity ` +
313
+ `${fmtSpan(span2)}; ${preToggle} pre-toggle onUpdate(s) (the initial={false} ` +
314
+ `no-entrance witness); onAnimationComplete: ${completionEvidence}; resolved snapshot ` +
315
+ 'false → variants carried { duration: 1 }',
316
+ )
317
+ setStage('tween rows finalized — instant-lane retarget next')
318
+ }
319
+ // Event-driven row-2 finalize (finding-B resolution, the U8 2295fbab analog): the fade
320
+ // completions ARE the settle edges, so the row judges them, never a wall-clock sample — a
321
+ // fixed finalize raced the latency-stretched flight both ways (supersession at 1.7s gap,
322
+ // mid-flight window read at 3.1s). Fail-closed deadline: 3s past the fade-out toggle.
323
+ const tweenRowPoll = setInterval(() => {
324
+ const [, t2] = toggleStampsRef.current
325
+ if (t2 === undefined) return
326
+ if (tweenCompletionsRef.current.length >= 2 || now() - t2 > 3000) {
327
+ clearInterval(tweenRowPoll)
328
+ finalizeTweenRow()
329
+ }
330
+ }, 200)
331
+
332
+ // 3. THE INSTANT LANE — retarget on the authored { type: false }. The same-tick read must
333
+ // still see the origin (the commit is next-frame, not synchronous); the rAF chain must
334
+ // see the target within the 4-frame JS-readback band (the pin's law covers the UI-side
335
+ // commit; the card's readback also spans the command crossing, the null-dt t=0 frame,
336
+ // and the marshal's setTimeout(0) return); no sample may land between origin and target.
337
+ at(T_INSTANT_RETARGET, () => {
338
+ instantStampRef.current = now()
339
+ sameTickRef.current = instantLatestRef.current
340
+ frameChainRef.current = []
341
+ setInstantTarget(INSTANT_TARGET)
342
+ let frame = 0
343
+ const step = (): void => {
344
+ frame += 1
345
+ frameChainRef.current = [
346
+ ...frameChainRef.current,
347
+ { at: now() - instantStampRef.current, value: instantLatestRef.current },
348
+ ]
349
+ if (frame < 5) raf = requestAnimationFrame(step)
350
+ }
351
+ raf = requestAnimationFrame(step)
352
+ setStage(`instant retarget: ${INSTANT_ORIGIN} → ${INSTANT_TARGET} on { type: false }`)
353
+ })
354
+ at(T_INSTANT_FINALIZE, () => {
355
+ const stamp = instantStampRef.current
356
+ const post = instantSamplesRef.current.filter((s) => s.at >= stamp)
357
+ const flight = post.find(
358
+ (s) => s.value > INSTANT_ORIGIN + 0.02 && s.value < INSTANT_TARGET - 0.02,
359
+ )
360
+ const sameTick = sameTickRef.current
361
+ const frames = frameChainRef.current
362
+ const targetFrame = frames.findIndex((f) => Math.abs(f.value - INSTANT_TARGET) <= EPS) + 1
363
+ const chain = frames.map((f, i) => `f${i + 1}=${f.value.toFixed(3)}`).join(' ')
364
+ const committed =
365
+ post.length > 0 && Math.abs(post[post.length - 1]!.value - INSTANT_TARGET) <= EPS
366
+ let bad: string | null = null
367
+ if (sameTick === null || Math.abs(sameTick - INSTANT_ORIGIN) > EPS) {
368
+ bad = `the same-tick sample reads ${fmt(sameTick)}, want origin ${INSTANT_ORIGIN} — a synchronous commit at the retarget call`
369
+ } else if (targetFrame === 0 || targetFrame > 4) {
370
+ bad = `the target never landed within the 4-frame JS-readback band of the retarget (chain: ${chain || 'empty'})`
371
+ } else if (flight !== undefined) {
372
+ bad = `a post-retarget sample reads ${flight.value.toFixed(3)} at +${flight.at.toFixed(0)}ms — that is a flight, not an instant commit`
373
+ } else if (!committed) {
374
+ bad = `no post-retarget onUpdate committed the target (samples: ${post.map((s) => s.value.toFixed(3)).join(', ') || 'none'})`
375
+ }
376
+ finalize(
377
+ ROW_INSTANT,
378
+ bad === null,
379
+ bad ??
380
+ `same-tick sample ${sameTick!.toFixed(3)} = origin (the commit is NOT synchronous at ` +
381
+ `the retarget call); target ${INSTANT_TARGET} read at rAF frame ${targetFrame} ` +
382
+ `(chain: ${chain}); post-retarget onUpdates [${post.map((s) => s.value.toFixed(3)).join(', ')}] — ` +
383
+ 'no flight (the ≤4-frame band is the JS readback of the UI-side next-frame commit: ' +
384
+ 'command crossing + the null-dt t=0 frame + the marshal return)',
385
+ )
386
+ setStage('instant lane committed — authored-delay leg next')
387
+ })
388
+
389
+ // 4. AUTHORED DELAY — { type: false, delay: 0.3 }: +150ms still reads the origin, +600ms
390
+ // reads the target. The pin's asymmetry: the authored form honors delay, the global
391
+ // instant flag (7b's window) zeroes it.
392
+ at(T_DELAY_RETARGET, () => {
393
+ delayStampRef.current = now()
394
+ setInstantTransition({ type: false, delay: 0.3 })
395
+ setInstantTarget(INSTANT_ORIGIN)
396
+ setStage(`delay leg: ${INSTANT_TARGET} → ${INSTANT_ORIGIN} on { type: false, delay: 0.3 }`)
397
+ })
398
+ at(T_DELAY_EARLY, () => {
399
+ delayEarlyRef.current = instantLatestRef.current
400
+ setStage('delay leg: +150ms sample taken (must still read the origin)')
401
+ })
402
+ at(T_DELAY_LATE, () => {
403
+ delayLateRef.current = instantLatestRef.current
404
+ setStage('delay leg: +600ms sample taken (must read the target)')
405
+ })
406
+ at(T_DELAY_FINALIZE, () => {
407
+ const stamp = delayStampRef.current
408
+ const post = instantSamplesRef.current.filter((s) => s.at >= stamp)
409
+ const commit = post.find((s) => Math.abs(s.value - INSTANT_ORIGIN) <= EPS)
410
+ const flight = post.find(
411
+ (s) => s.value > INSTANT_ORIGIN + 0.02 && s.value < INSTANT_TARGET - 0.02,
412
+ )
413
+ const early = delayEarlyRef.current
414
+ const late = delayLateRef.current
415
+ const commitAt = commit === undefined ? null : commit.at - stamp
416
+ let bad: string | null = null
417
+ if (early === null || Math.abs(early - INSTANT_TARGET) > EPS) {
418
+ bad = `the +150ms sample reads ${fmt(early)}, want origin ${INSTANT_TARGET} — the authored 300ms delay was not honored`
419
+ } else if (late === null || Math.abs(late - INSTANT_ORIGIN) > EPS) {
420
+ bad = `the +600ms sample reads ${fmt(late)}, want target ${INSTANT_ORIGIN} — the delayed commit never landed`
421
+ } else if (commitAt === null || commitAt < 250 || commitAt > 900) {
422
+ bad = `the commit landed at ${commitAt === null ? 'never' : `+${commitAt.toFixed(0)}ms`} — want the 300ms class (250–900ms)`
423
+ } else if (flight !== undefined) {
424
+ bad = `a post-retarget sample reads ${flight.value.toFixed(3)} — a flight under the delay leg`
425
+ }
426
+ finalize(
427
+ ROW_DELAY,
428
+ bad === null,
429
+ bad ??
430
+ `+150ms sample ${early!.toFixed(3)} = origin (delay holding), +600ms sample ` +
431
+ `${late!.toFixed(3)} = target, commit at +${commitAt!.toFixed(0)}ms — the authored ` +
432
+ '300ms delay is honored (the pin’s authored-form asymmetry: the global instant flag ' +
433
+ 'would zero it)',
434
+ )
435
+ setStage('delay leg finalized — clean-run verdict next')
436
+ })
437
+
438
+ // 5. CLEAN RUN — the console tap saw zero refusal reports across the whole timeline. The
439
+ // completion ledger is re-read here as a second witness: before the finding-B
440
+ // resolution the tween row read it once at a wall-clock finalize and a healthy
441
+ // completion landing past the read permanently read "none delivered".
442
+ at(T_CLEAN, () => {
443
+ const reports = reportsRef.current
444
+ const completions = tweenCompletionsRef.current
445
+ finalize(
446
+ ROW_CLEAN,
447
+ reports.length === 0,
448
+ (reports.length === 0
449
+ ? `0 refusal reports through the console tap across ${now().toFixed(0)}ms of ` +
450
+ 'hook resolve, two tween fades, and both instant-lane legs'
451
+ : `${reports.length} report(s): ${reports
452
+ .map((message) => `"${message.slice(0, 80)}…"`)
453
+ .join(' | ')}`) +
454
+ `; tween completions by T_CLEAN: ${
455
+ completions.length === 0
456
+ ? 'none (the finding-B signature)'
457
+ : completions.map((c) => `+${c.toFixed(0)}ms`).join(', ')
458
+ }`,
459
+ )
460
+ })
461
+ at(T_DONE, () => setStage('done'))
462
+
463
+ return () => {
464
+ clearInterval(tweenRowPoll)
465
+ for (const timer of timers) clearTimeout(timer)
466
+ if (raf !== null) cancelAnimationFrame(raf)
467
+ console.error = originalError
468
+ }
469
+ // All captured values are refs or stable setters; the timeline runs once per mount.
470
+ // eslint-disable-next-line react-hooks/exhaustive-deps
471
+ }, [])
472
+
473
+ const done = ROW_ORDER.every((id) => rows[id] !== undefined)
474
+ const failing = ROW_ORDER.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
475
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
476
+ const passed = Object.keys(rows).length - failing.length
477
+
478
+ return (
479
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
480
+ <Text style={styles.title}>U7a reduced-motion checks</Text>
481
+ <Text style={styles.subtitle}>
482
+ useReducedMotion snapshot + authored {'{ type: false }'} instant lane ({severity} severity)
483
+ </Text>
484
+ <View
485
+ style={[
486
+ styles.banner,
487
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
488
+ ]}
489
+ >
490
+ <Text style={styles.bannerText}>{verdict}</Text>
491
+ </View>
492
+ <Text style={styles.tally}>
493
+ {done
494
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROW_ORDER.length} rows`
495
+ : `${Object.keys(rows).length}/${ROW_ORDER.length} rows complete — RUNNING`}
496
+ </Text>
497
+ <Text style={styles.tallyDetail}>
498
+ {done
499
+ ? failing.length === 0
500
+ ? 'no failing rows'
501
+ : `FAILING: ${failing.join(' | ')}`
502
+ : `stage: ${stage}`}
503
+ </Text>
504
+ <Text style={styles.status}>
505
+ stage: {stage} · reduced={String(shouldReduceMotion)} · visible={String(isVisible)} · tween
506
+ opacity={liveTween} · instant opacity={liveInstant} → {instantTarget}
507
+ </Text>
508
+
509
+ {/* The subjects. Left: the pin example verbatim — outer carries the animate label with
510
+ initial={false}, the inner variants-only child resolves it (label propagation) with the
511
+ snapshot-selected transition embedded. Right: the authored { type: false } instant-lane
512
+ box. onUpdate on both is the committed-values readback. */}
513
+ <View style={styles.stageRow}>
514
+ <MotionView animate={isVisible ? 'visible' : 'hidden'} initial={false}>
515
+ <MotionView
516
+ variants={variants}
517
+ onUpdate={(latest) => {
518
+ const value = readOpacity(latest)
519
+ if (value === null) return
520
+ tweenSamplesRef.current.push({ at: Date.now() - mountStampRef.current, value })
521
+ setLiveTween(value.toFixed(3))
522
+ }}
523
+ onAnimationComplete={() => {
524
+ tweenCompletionsRef.current.push(Date.now() - mountStampRef.current)
525
+ }}
526
+ style={styles.tweenBox}
527
+ accessibilityLabel="u7a-tween-subject"
528
+ />
529
+ </MotionView>
530
+ <MotionView
531
+ animate={{ opacity: instantTarget }}
532
+ initial={false}
533
+ transition={instantTransition}
534
+ onUpdate={(latest) => {
535
+ const value = readOpacity(latest)
536
+ if (value === null) return
537
+ instantLatestRef.current = value
538
+ instantSamplesRef.current.push({ at: Date.now() - mountStampRef.current, value })
539
+ setLiveInstant(value.toFixed(3))
540
+ }}
541
+ style={styles.instantBox}
542
+ accessibilityLabel="u7a-instant-subject"
543
+ />
544
+ </View>
545
+
546
+ {ROW_ORDER.map((id) => {
547
+ const row = rows[id]
548
+ return (
549
+ <View key={id} style={styles.row}>
550
+ <Text
551
+ style={[
552
+ styles.verdict,
553
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
554
+ ]}
555
+ >
556
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
557
+ </Text>
558
+ <View style={styles.rowBody}>
559
+ <Text style={styles.rowId}>{id}</Text>
560
+ <Text style={styles.rowDetail} numberOfLines={4}>
561
+ {row === undefined ? 'pending' : row.detail}
562
+ </Text>
563
+ </View>
564
+ </View>
565
+ )
566
+ })}
567
+ </ScrollView>
568
+ )
569
+ }
570
+
571
+ const styles = StyleSheet.create({
572
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
573
+ container: { paddingVertical: 72, paddingHorizontal: 8 },
574
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
575
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
576
+ banner: {
577
+ alignSelf: 'center',
578
+ paddingHorizontal: 36,
579
+ paddingVertical: 10,
580
+ borderRadius: 10,
581
+ marginTop: 18,
582
+ marginBottom: 10,
583
+ },
584
+ pass: { backgroundColor: '#1d7a3e' },
585
+ fail: { backgroundColor: '#a32633' },
586
+ running: { backgroundColor: '#7a641d' },
587
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
588
+ tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
589
+ tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
590
+ status: {
591
+ color: '#c9c9d8',
592
+ fontSize: 11,
593
+ marginTop: 8,
594
+ textAlign: 'center',
595
+ fontFamily: 'Menlo',
596
+ },
597
+ stageRow: {
598
+ flexDirection: 'row',
599
+ marginTop: 18,
600
+ alignItems: 'center',
601
+ justifyContent: 'space-evenly',
602
+ },
603
+ // The pin example's style verbatim (useReducedMotion.tsx:4-9): 100×100 red, base opacity 1.
604
+ tweenBox: { width: 100, height: 100, backgroundColor: 'red', opacity: 1, borderRadius: 12 },
605
+ instantBox: { width: 100, height: 100, backgroundColor: '#3b82f6', borderRadius: 12 },
606
+ row: { flexDirection: 'row', marginTop: 10 },
607
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
608
+ ok: { color: '#4ade80' },
609
+ bad: { color: '#f87171' },
610
+ pending: { color: '#8a8aa0' },
611
+ rowBody: { flex: 1 },
612
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
613
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
614
+ })