@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,425 @@
1
+ // U6 crossfade device checks (REQ-VALUETYPE-013(f) — the packet dissolved into it at the U6
2
+ // terminal): the PUBLIC catalog Shared-layout-continuity-crossfade.tsx shape on device — the same
3
+ // layoutId="u6-box" keyed cycle as the continuity card, but under AnimatePresence (crossfade
4
+ // default), so the keyed remount keeps the outgoing shape mounted as the fading follower while
5
+ // the incoming lead flies the pin's authored 3s transition. Four claims, self-driven:
6
+ //
7
+ // 1. FIRST MOUNT ON THE CALC SHAPE — identical to the continuity card: the host-measured window
8
+ // x is viewportWidth − 100 (±2), settles 0 (the pre-U6 Yoga-drop defect lays out at x≈0).
9
+ // 2. DUAL MOUNT MID-FLIGHT — at a stamped ~0.7s into the flight BOTH boxes measure on the host:
10
+ // AnimatePresence holds the exiting shape-2 (the follower) and the layoutId identity holds
11
+ // TWO live holder records (the engine's opacity witness sees lead + follower, never ghosts).
12
+ // Counterfactuals: no presence wrapper or an instant safeToRemove unmounts the follower
13
+ // before the sample; crossfade:false-style hiding reads the follower gone from the identity.
14
+ // 3. THE FOLLOWER FADES — two witness samples 400ms apart inside the fade half: the SAME two
15
+ // tokens, the follower (fading side) strictly decreasing and still mid-fade (> 0.02 — an
16
+ // instantly vanished follower reads 0, a never-fading one reads 1 twice), the lead strictly
17
+ // increasing, the pair conserved (native's crossfade law: lead = s, follower = 1 − s).
18
+ // 4. THE LEAD SETTLES — at settle the lead measures left:100 exactly, the follower is released
19
+ // and unmounted, and the witness converges to the lead alone at opacity 1.
20
+ //
21
+ // Readback honesty: positions are measureInWindow against the host; opacities are the driver's
22
+ // LIVE holder-output cells read through the root binding's opacityWitnessFor (what the holders
23
+ // paint — verification-side instrumentation, never product code). The stage is full-bleed so the
24
+ // viewport-resolved seed reads directly in window coordinates. A screenshot is the assertion.
25
+
26
+ import { useEffect, useRef, useState, type ComponentRef } from 'react'
27
+ import { Dimensions, ScrollView, StyleSheet, Text, View } from 'react-native'
28
+ import { AnimatePresence } from '../../native/motion/AnimatePresence'
29
+ import { MotionRoot, useLayoutIdentityRootBinding } from '../../native/motion/MotionRoot'
30
+ import { MotionView, type MotionViewStyle } from '../../native/motion/MotionView'
31
+ import { ambientNativeSeverity } from '../../native/motion/severity'
32
+
33
+ // The pin's styles table verbatim (Shared-layout-continuity-crossfade.tsx:71-91); the card cycles
34
+ // 2 → 1 once (the pin's fade window lives inside one 3s flight).
35
+ const SHAPE_STYLES: readonly MotionViewStyle[] = [
36
+ { width: 100, height: 100, top: 100 },
37
+ { width: 200, height: 200, left: 100 },
38
+ { width: 100, height: 100, left: 'calc(100vw - 100px)' },
39
+ { width: 200, height: 200 },
40
+ ]
41
+ const SHAPE_COLORS = ['#09f', '#90f', '#f09', '#9f0'] as const
42
+ const CALC_SHAPE = 2
43
+ const PX_SHAPE = 1
44
+ const PX_TARGET_X = 100
45
+ const LAYOUT_ID = 'u6-box'
46
+
47
+ const ROW_MOUNT = 'first mount ON the calc shape: host x = vw − 100 (±2), no flight'
48
+ const ROW_DUAL = 'dual mount mid-flight: follower + lead BOTH measure, identity holds 2 holders'
49
+ const ROW_FADE = 'follower opacity strictly decreasing across the fade half (lead rising, sum 1)'
50
+ const ROW_SETTLE = 'lead settles at left:100; follower released + unmounted'
51
+ const ROW_ORDER = [ROW_MOUNT, ROW_DUAL, ROW_FADE, ROW_SETTLE] as const
52
+
53
+ // Timeline (ms). Dwells: boot frame 0–1600 (calc shape), dual-mount sample at ~2300, fade samples
54
+ // at ~3200/3600 (mid-flight). The settle row is EVENT-DRIVEN (the onLayoutAnimationComplete
55
+ // callback judges), never a wall-clock sample: native's flight clock starts at the first
56
+ // commitRect, one host measure roundtrip after mount (F3-family host-commit granularity — the
57
+ // pin measures synchronously in the commit), so a fixed settle sample races the engine on a
58
+ // loaded emulator (device 2026-08-20: sampled x=127 mid-flight at 5100ms; the completed state —
59
+ // lead x=100.1, follower unmounted — verified from the live hierarchy after). A watchdog fails
60
+ // the row when no settle arrives (a true stall still fails loud).
61
+ const T_MOUNT = 1200
62
+ const T_CYCLE = 1600
63
+ const T_DUAL = 2300
64
+ const T_FADE_1 = 3200
65
+ const T_FADE_2 = 3600
66
+ const T_WATCHDOG = 9000
67
+
68
+ interface RowResult {
69
+ readonly pass: boolean
70
+ readonly detail: string
71
+ }
72
+
73
+ interface OpacitySample {
74
+ readonly at: number
75
+ readonly entries: readonly { readonly token: string; readonly opacity: number }[]
76
+ }
77
+
78
+ function formatWitness(sample: OpacitySample | null): string {
79
+ if (sample === null) return 'unavailable'
80
+ return (
81
+ sample.entries
82
+ .map((entry) => `${entry.token.slice(-8)}=${entry.opacity.toFixed(2)}`)
83
+ .join(' ') || '(none)'
84
+ )
85
+ }
86
+
87
+ function CrossfadeChecks() {
88
+ const severity = ambientNativeSeverity()
89
+ const binding = useLayoutIdentityRootBinding()
90
+ const [count, setCount] = useState(CALC_SHAPE)
91
+ const [stage, setStage] = useState('boot: first mount ON the calc shape (styles[2])')
92
+ const [liveX, setLiveX] = useState('—')
93
+ const [liveWitness, setLiveWitness] = useState('—')
94
+ const [rows, setRows] = useState<Partial<Record<(typeof ROW_ORDER)[number], RowResult>>>({})
95
+
96
+ // Per-shape host refs: the exiting child keeps its own count closure, so the follower
97
+ // (shape-2) and the lead (shape-1) both stay measurable while AnimatePresence holds them.
98
+ const boxRefs = useRef(new Map<number, ComponentRef<typeof MotionView>>())
99
+ const vwRef = useRef(Dimensions.get('window').width)
100
+ const cycleStampRef = useRef(0)
101
+ const settleLatenciesRef = useRef<number[]>([])
102
+ const settleCheckedRef = useRef(false)
103
+ const settleChecksRef = useRef<(() => void) | null>(null)
104
+ const dualRef = useRef<{ followerX: number | null; leadX: number | null } | null>(null)
105
+ const witnessDualRef = useRef<OpacitySample | null>(null)
106
+ const fade1Ref = useRef<OpacitySample | null>(null)
107
+ const fade2Ref = useRef<OpacitySample | null>(null)
108
+
109
+ const finalize = (id: (typeof ROW_ORDER)[number], pass: boolean, detail: string): void => {
110
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
111
+ }
112
+
113
+ useEffect(() => {
114
+ const vw = vwRef.current
115
+ const calcX = vw - 100
116
+ const measureShape = (shape: number, done: (x: number | null) => void): void => {
117
+ const node = boxRefs.current.get(shape) ?? null
118
+ if (node === null) {
119
+ done(null)
120
+ return
121
+ }
122
+ node.measureInWindow((x, _y, width) => done(width > 0 ? x : null))
123
+ }
124
+ const fmt = (x: number | null): string => (x === null ? 'unavailable' : x.toFixed(1))
125
+ const sampleWitness = (): OpacitySample | null => {
126
+ if (binding === null) return null
127
+ return {
128
+ at: Date.now() - cycleStampRef.current,
129
+ entries: binding.opacityWitnessFor(LAYOUT_ID),
130
+ }
131
+ }
132
+
133
+ const timers: ReturnType<typeof setTimeout>[] = []
134
+ const at = (ms: number, run: () => void): void => {
135
+ timers.push(setTimeout(run, ms))
136
+ }
137
+
138
+ // 1. FIRST MOUNT — the static calc seed resolves before Yoga (same claim as continuity).
139
+ at(T_MOUNT, () => {
140
+ measureShape(CALC_SHAPE, (x) => {
141
+ setLiveX(fmt(x))
142
+ const settles = settleLatenciesRef.current.length
143
+ const pass = x !== null && Math.abs(x - calcX) <= 2 && settles === 0
144
+ finalize(
145
+ ROW_MOUNT,
146
+ pass,
147
+ `measured x=${fmt(x)}, vw=${vw.toFixed(0)} (want ${calcX.toFixed(0)}±2); settles=${settles}`,
148
+ )
149
+ setStage('first mount measured — cycling under AnimatePresence')
150
+ })
151
+ })
152
+
153
+ // 2. DUAL MOUNT — ~0.7s into the flight both boxes must measure, and the identity must hold
154
+ // both holder records.
155
+ at(T_CYCLE, () => {
156
+ cycleStampRef.current = Date.now()
157
+ setCount(PX_SHAPE)
158
+ setStage('flight: calc shape → left:100 under AnimatePresence (crossfade default, 3s)')
159
+ })
160
+ at(T_DUAL, () => {
161
+ measureShape(CALC_SHAPE, (followerX) => {
162
+ measureShape(PX_SHAPE, (leadX) => {
163
+ const witness = sampleWitness()
164
+ witnessDualRef.current = witness
165
+ dualRef.current = { followerX, leadX }
166
+ setLiveX(fmt(leadX))
167
+ setLiveWitness(formatWitness(witness))
168
+ const holders = witness === null ? 0 : witness.entries.length
169
+ const pass = followerX !== null && leadX !== null && holders === 2
170
+ finalize(
171
+ ROW_DUAL,
172
+ pass,
173
+ `t+${((Date.now() - cycleStampRef.current) / 1000).toFixed(2)}s: follower shape-2 x=${fmt(followerX)}, ` +
174
+ `lead shape-1 x=${fmt(leadX)}, identity holders=${holders} (${formatWitness(witness)})` +
175
+ (pass ? '' : ' — the follower departed before the sample (presence did not hold it)'),
176
+ )
177
+ setStage('dual mount sampled — fade window next')
178
+ })
179
+ })
180
+ })
181
+
182
+ // 3. THE FOLLOWER FADES — two witness samples inside the fade half, strictly decreasing.
183
+ at(T_FADE_1, () => {
184
+ const sample = sampleWitness()
185
+ fade1Ref.current = sample
186
+ setLiveWitness(formatWitness(sample))
187
+ setStage('fade window — first opacity sample')
188
+ })
189
+ at(T_FADE_2, () => {
190
+ const sample = sampleWitness()
191
+ fade2Ref.current = sample
192
+ setLiveWitness(formatWitness(sample))
193
+ const first = fade1Ref.current
194
+ let bad: string | null = null
195
+ if (first === null || sample === null) {
196
+ bad = 'the opacity witness is unavailable (no root binding)'
197
+ } else if (first.entries.length !== 2 || sample.entries.length !== 2) {
198
+ bad =
199
+ `want 2 live holders at both samples; got ${first.entries.length} then ` +
200
+ `${sample.entries.length} (${formatWitness(first)} → ${formatWitness(sample)})`
201
+ } else {
202
+ const firstMin = Math.min(...first.entries.map((entry) => entry.opacity))
203
+ const firstMax = Math.max(...first.entries.map((entry) => entry.opacity))
204
+ const secondMin = Math.min(...sample.entries.map((entry) => entry.opacity))
205
+ const secondMax = Math.max(...sample.entries.map((entry) => entry.opacity))
206
+ if (!(secondMin < firstMin - 0.02)) {
207
+ bad = `the fading side did not decrease: ${firstMin.toFixed(3)} → ${secondMin.toFixed(3)} (a never-fading follower reads ~1 twice)`
208
+ } else if (secondMin <= 0.02) {
209
+ bad = `the follower is already gone at the second sample (${secondMin.toFixed(3)}) — an instant vanish, not a fade`
210
+ } else if (!(secondMax > firstMax + 0.02)) {
211
+ bad = `the lead did not rise: ${firstMax.toFixed(3)} → ${secondMax.toFixed(3)}`
212
+ } else if (
213
+ Math.abs(firstMin + firstMax - 1) > 0.05 ||
214
+ Math.abs(secondMin + secondMax - 1) > 0.05
215
+ ) {
216
+ bad = `the crossfade sum is not conserved: ${(firstMin + firstMax).toFixed(3)} then ${(secondMin + secondMax).toFixed(3)}`
217
+ }
218
+ }
219
+ finalize(
220
+ ROW_FADE,
221
+ bad === null,
222
+ bad ??
223
+ `follower ${formatWitness(first)} → ${formatWitness(sample)}: strictly decreasing, ` +
224
+ 'mid-fade, lead rising, sum conserved',
225
+ )
226
+ setStage('fade sampled — awaiting the settle')
227
+ })
228
+
229
+ // 4. THE LEAD SETTLES — judged on the settle callback (event-driven, see the timeline
230
+ // note): at left:100 exactly, the follower released and unmounted. The watchdog fails
231
+ // the row loud when the engine never settles.
232
+ const runSettleChecks = (): void => {
233
+ if (settleCheckedRef.current) return
234
+ settleCheckedRef.current = true
235
+ measureShape(PX_SHAPE, (leadX) => {
236
+ measureShape(CALC_SHAPE, (followerX) => {
237
+ const witness = sampleWitness()
238
+ setLiveX(fmt(leadX))
239
+ setLiveWitness(formatWitness(witness))
240
+ const latency = settleLatenciesRef.current[0]
241
+ let bad: string | null = null
242
+ if (leadX === null || Math.abs(leadX - PX_TARGET_X) > 2) {
243
+ bad = `lead settled x=${fmt(leadX)}, want ${PX_TARGET_X}±2`
244
+ } else if (followerX !== null) {
245
+ bad = `the follower still measures at settle (x=${followerX.toFixed(1)}) — the release never unmounted it`
246
+ }
247
+ finalize(
248
+ ROW_SETTLE,
249
+ bad === null,
250
+ bad ??
251
+ `lead x=${fmt(leadX)} (want ${PX_TARGET_X}±2); follower unmounted; witness ` +
252
+ `${formatWitness(witness)}` +
253
+ (latency === undefined
254
+ ? ''
255
+ : `; settle callback at +${(latency / 1000).toFixed(2)}s (3s class)`),
256
+ )
257
+ setStage('done')
258
+ })
259
+ })
260
+ }
261
+ settleChecksRef.current = runSettleChecks
262
+ at(T_WATCHDOG, () => {
263
+ if (settleCheckedRef.current) return
264
+ const latency = settleLatenciesRef.current[0]
265
+ finalize(
266
+ ROW_SETTLE,
267
+ false,
268
+ latency === undefined
269
+ ? `no settle callback within ${((T_WATCHDOG - T_CYCLE) / 1000).toFixed(1)}s of the cycle — the flight stalled`
270
+ : 'settle callback fired but the checks never ran',
271
+ )
272
+ setStage('done')
273
+ })
274
+
275
+ return () => {
276
+ for (const timer of timers) clearTimeout(timer)
277
+ }
278
+ // All captured values are refs, the stable root binding, or stable setters; runs once per mount.
279
+ // eslint-disable-next-line react-hooks/exhaustive-deps
280
+ }, [])
281
+
282
+ const done = ROW_ORDER.every((id) => rows[id] !== undefined)
283
+ const failing = ROW_ORDER.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
284
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
285
+ const passed = Object.keys(rows).length - failing.length
286
+
287
+ return (
288
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
289
+ <View style={styles.padded}>
290
+ <Text style={styles.title}>U6 shared-layout crossfade checks</Text>
291
+ <Text style={styles.subtitle}>
292
+ layoutId=&quot;u6-box&quot; keyed remount under AnimatePresence ({severity} severity)
293
+ </Text>
294
+ <View
295
+ style={[
296
+ styles.banner,
297
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
298
+ ]}
299
+ >
300
+ <Text style={styles.bannerText}>{verdict}</Text>
301
+ </View>
302
+ <Text style={styles.tally}>
303
+ {done
304
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROW_ORDER.length} rows`
305
+ : `${Object.keys(rows).length}/${ROW_ORDER.length} rows complete — RUNNING`}
306
+ </Text>
307
+ <Text style={styles.tallyDetail}>
308
+ {done
309
+ ? failing.length === 0
310
+ ? 'no failing rows'
311
+ : `FAILING: ${failing.join(' | ')}`
312
+ : `stage: ${stage}`}
313
+ </Text>
314
+ <Text style={styles.status}>
315
+ stage: {stage} · count={count} · lead x={liveX} · vw={vwRef.current.toFixed(0)}
316
+ </Text>
317
+ <Text style={styles.status}>witness: {liveWitness}</Text>
318
+ </View>
319
+
320
+ {/* The full-bleed stage: stage left = window x 0 and width = vw, so the viewport-resolved
321
+ calc seed reads directly in window coordinates. */}
322
+ <View style={styles.stage} accessibilityLabel="u6-crossfade-stage">
323
+ <AnimatePresence>
324
+ <MotionView
325
+ key={`shape-${count}`}
326
+ layoutId={LAYOUT_ID}
327
+ ref={(node) => {
328
+ if (node === null) boxRefs.current.delete(count)
329
+ else boxRefs.current.set(count, node)
330
+ }}
331
+ transition={{ duration: 3 }}
332
+ onLayoutAnimationComplete={() => {
333
+ settleLatenciesRef.current.push(Date.now() - cycleStampRef.current)
334
+ // The follower's settle-edge detach → safeToRemove → Presence unmount is a React
335
+ // render behind this callback; judge two frames out so the unmount has committed
336
+ // (the repo's double-rAF idiom for a post-commit read).
337
+ requestAnimationFrame(() => requestAnimationFrame(() => settleChecksRef.current?.()))
338
+ }}
339
+ style={[styles.box, SHAPE_STYLES[count], { backgroundColor: SHAPE_COLORS[count]! }]}
340
+ accessibilityLabel={`u6-crossfade-shape-${count}`}
341
+ />
342
+ </AnimatePresence>
343
+ </View>
344
+
345
+ <View style={styles.padded}>
346
+ {ROW_ORDER.map((id) => {
347
+ const row = rows[id]
348
+ return (
349
+ <View key={id} style={styles.row}>
350
+ <Text
351
+ style={[
352
+ styles.verdict,
353
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
354
+ ]}
355
+ >
356
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
357
+ </Text>
358
+ <View style={styles.rowBody}>
359
+ <Text style={styles.rowId}>{id}</Text>
360
+ <Text style={styles.rowDetail} numberOfLines={3}>
361
+ {row === undefined ? 'pending' : row.detail}
362
+ </Text>
363
+ </View>
364
+ </View>
365
+ )
366
+ })}
367
+ </View>
368
+ </ScrollView>
369
+ )
370
+ }
371
+
372
+ export function SharedLayoutCrossfadeChecksScreen() {
373
+ // layoutId requires a MotionRoot above it (REQ-LAYOUT-003); the root binding is also the
374
+ // card's opacity witness source (useLayoutIdentityRootBinding inside).
375
+ return (
376
+ <MotionRoot>
377
+ <CrossfadeChecks />
378
+ </MotionRoot>
379
+ )
380
+ }
381
+
382
+ const styles = StyleSheet.create({
383
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
384
+ container: { paddingVertical: 72 },
385
+ padded: { paddingHorizontal: 12 },
386
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
387
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
388
+ banner: {
389
+ alignSelf: 'center',
390
+ paddingHorizontal: 36,
391
+ paddingVertical: 10,
392
+ borderRadius: 10,
393
+ marginTop: 18,
394
+ marginBottom: 10,
395
+ },
396
+ pass: { backgroundColor: '#1d7a3e' },
397
+ fail: { backgroundColor: '#a32633' },
398
+ running: { backgroundColor: '#7a641d' },
399
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
400
+ tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
401
+ tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
402
+ status: {
403
+ color: '#c9c9d8',
404
+ fontSize: 11,
405
+ marginTop: 8,
406
+ textAlign: 'center',
407
+ fontFamily: 'Menlo',
408
+ },
409
+ stage: {
410
+ height: 230,
411
+ marginTop: 14,
412
+ backgroundColor: '#14141d',
413
+ borderRadius: 8,
414
+ overflow: 'hidden',
415
+ },
416
+ box: { position: 'absolute', borderRadius: 10 },
417
+ row: { flexDirection: 'row', marginTop: 10 },
418
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
419
+ ok: { color: '#4ade80' },
420
+ bad: { color: '#f87171' },
421
+ pending: { color: '#8a8aa0' },
422
+ rowBody: { flex: 1 },
423
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
424
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
425
+ })