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