@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,568 @@
1
+ // U5 color-binding device checks (REQ-API-025/-026, REQ-DRIVER-029): the PUBLIC value-hook surface
2
+ // proven on device exactly as a consumer writes it (the pinned catalog Drag-external-handlers
3
+ // shape) — useMotionValue, a useTransform color ramp bound to backgroundColor via style, and a
4
+ // useMotionTemplate translate3d string bound to style.transform. Three claims, self-driven:
5
+ //
6
+ // 1. ATTACH-PUBLISH — x carries 150 BEFORE mount: the first frame already shows the mid-ramp
7
+ // color and the follower translated 150px (no entrance, no animation).
8
+ // 2. set() SWEEP — a timer DIRECTLY set()s x in discrete 25px steps (never animate() a bound
9
+ // value — that refusal is intentional, REQ-DRIVER-029). The subject's color must follow the
10
+ // ramp through the binding and the follower must mirror position through the template
11
+ // binding. The sweep is ALSO the discriminator that the BINDING (not an animation) delivered
12
+ // the values: every step is a direct set(), and any animation-driven traffic on a bound
13
+ // value would fire the lane's loud refusal, failing the row.
14
+ // 3. INVALID set() REFUSAL — Release runs production severity (the proof posture): set()ing an
15
+ // unparseable color string is REPORTED through the error channel and REFUSED, keeping the
16
+ // last good color; a later valid set() publishes again.
17
+ //
18
+ // Readback honesty: the follower's committed position is read back from the HOST
19
+ // (measureInWindow against an untransformed marker — the value the host actually received). A
20
+ // bound color has no programmatic host readback on this surface, so the color rows assert the
21
+ // stamped values the binding lane ACCEPTED (the derived-value change events) plus the absence of
22
+ // refusal reports, and the rendered pixels in the capture carry the host-received color evidence.
23
+ // Refusal reports are observed by tapping console.error — the severity law's production error
24
+ // channel (consoleReporter) — verification-side instrumentation, never product code. A
25
+ // screenshot is the assertion; try/catch is evidence capture, never suppression.
26
+
27
+ import { useEffect, useRef, useState, type ComponentRef } from 'react'
28
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
29
+ import { MotionView } from '../../native/motion/MotionView'
30
+ import { ambientNativeSeverity } from '../../native/motion/severity'
31
+ import {
32
+ useMotionTemplate,
33
+ useMotionValue,
34
+ useMotionValueEvent,
35
+ useTransform,
36
+ } from '../../native/motion/valueHooks'
37
+
38
+ // The ramp the charter pins: x=0 → '#f00', x=150 → mid '#ff0'→'#0f0' (rgb ≈ 180,255,0 — the pin
39
+ // mixes in linear-light space, NOT naive linear, so mid-segment red is 180 not 128), x=300 →
40
+ // '#00f'. The sweep rides 25px steps so every stop AND both mid-segment points are hit.
41
+ const INPUT_RANGE = [0, 100, 200, 300] as const
42
+ const OUTPUT_RANGE = ['#f00', '#ff0', '#0f0', '#00f'] as const
43
+ const STOPS: readonly (readonly [number, number, number])[] = [
44
+ [255, 0, 0],
45
+ [255, 255, 0],
46
+ [0, 255, 0],
47
+ [0, 0, 255],
48
+ ]
49
+
50
+ const SWEEP_UP: readonly number[] = [175, 200, 225, 250, 275, 300]
51
+ const SWEEP_DOWN: readonly number[] = [275, 250, 225, 200, 175, 150, 125, 100, 75, 50, 25, 0]
52
+ const SWEEP_VALUES: readonly number[] = [...SWEEP_UP, ...SWEEP_DOWN]
53
+ const STEP_MS = 150
54
+
55
+ const PROBE_INITIAL = '#22cc88'
56
+ const PROBE_RECOVERY = '#3366ff'
57
+ const PROBE_INVALID = 'not-a-color'
58
+
59
+ const ROW_ATTACH = 'attach-publish: first frame carries the pre-mount x=150'
60
+ const ROW_SWEEP_COLOR = 'set() sweep: subject color follows the ramp THROUGH THE BINDING'
61
+ const ROW_SWEEP_TEMPLATE = 'set() sweep: follower mirrors position through the template binding'
62
+ const ROW_INVALID = 'invalid set() reported + refused (production posture)'
63
+ const ROW_RECOVERY = 'binding lane survives the refusal (recovery set publishes)'
64
+ const ROW_ORDER = [
65
+ ROW_ATTACH,
66
+ ROW_SWEEP_COLOR,
67
+ ROW_SWEEP_TEMPLATE,
68
+ ROW_INVALID,
69
+ ROW_RECOVERY,
70
+ ] as const
71
+
72
+ // Timeline (ms). Dwells make every staged state capturable: the boot frame holds x=150 mid-ramp,
73
+ // the 300-dwell holds blue + follower at 300, the invalid-dwell holds the probe's last good color.
74
+ const T_ATTACH = 900
75
+ const T_SWEEP_UP = 1100
76
+ const T_MEASURE_300 = 2500
77
+ const T_SWEEP_DOWN = 2900
78
+ const T_SWEEP_FINALIZE = 5100
79
+ const T_INVALID = 5600
80
+ const T_INVALID_FINALIZE = 6300
81
+ const T_RECOVER = 6500
82
+ const T_DONE = 7100
83
+
84
+ /**
85
+ * The ramp expectation, per channel. NOT naive linear: the pin mixes colors in LINEAR-LIGHT space
86
+ * — mixLinearColor(from, to, progress) = sqrt(from² + (to² − from²)·progress) (motion-dom
87
+ * utils/mix/color.mjs:12-17), dispatched by getMixer for color outputs. Core's mixColor matches;
88
+ * the same model drives the web leg's rampChannels.
89
+ */
90
+ function mixLinearChannel(from: number, to: number, progress: number): number {
91
+ const fromExpo = from * from
92
+ const expo = progress * (to * to - fromExpo) + fromExpo
93
+ return expo < 0 ? 0 : Math.sqrt(expo)
94
+ }
95
+
96
+ function expectedRamp(x: number): readonly [number, number, number] {
97
+ const clamped = Math.min(INPUT_RANGE[3], Math.max(INPUT_RANGE[0], x))
98
+ let segment = 0
99
+ while (segment < 2 && clamped >= INPUT_RANGE[segment + 1]!) segment++
100
+ const from = INPUT_RANGE[segment]!
101
+ const to = INPUT_RANGE[segment + 1]!
102
+ const progress = to === from ? 1 : (clamped - from) / (to - from)
103
+ const a = STOPS[segment]!
104
+ const b = STOPS[segment + 1]!
105
+ return [
106
+ mixLinearChannel(a[0], b[0], progress),
107
+ mixLinearChannel(a[1], b[1], progress),
108
+ mixLinearChannel(a[2], b[2], progress),
109
+ ]
110
+ }
111
+
112
+ function parseRgba(raw: string): { r: number; g: number; b: number; a: number } | null {
113
+ const match =
114
+ /^rgba\(\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)\s*\)$/.exec(
115
+ raw.trim(),
116
+ )
117
+ if (match === null) return null
118
+ return {
119
+ r: Number(match[1]),
120
+ g: Number(match[2]),
121
+ b: Number(match[3]),
122
+ a: Number(match[4]),
123
+ }
124
+ }
125
+
126
+ // Channels mix in linear-light then round (Math.round(180.3…) = 180 at x=150) — a ±1 window is
127
+ // exactness, not leniency. Alpha must be exactly 1.
128
+ function channelsClose(
129
+ parsed: { r: number; g: number; b: number; a: number },
130
+ expected: readonly [number, number, number],
131
+ ): boolean {
132
+ return (
133
+ Math.abs(parsed.r - expected[0]) <= 1 &&
134
+ Math.abs(parsed.g - expected[1]) <= 1 &&
135
+ Math.abs(parsed.b - expected[2]) <= 1 &&
136
+ parsed.a === 1
137
+ )
138
+ }
139
+
140
+ function formatRgb(expected: readonly [number, number, number]): string {
141
+ return `rgb(${expected.map((c) => c.toFixed(0)).join(', ')})`
142
+ }
143
+
144
+ interface RowResult {
145
+ readonly pass: boolean
146
+ readonly detail: string
147
+ }
148
+
149
+ export function ColorBindingChecksScreen() {
150
+ const severity = ambientNativeSeverity()
151
+ // The public value hooks, exactly the catalog shape: x carries 150 BEFORE mount.
152
+ const x = useMotionValue(150)
153
+ const y = useMotionValue(0)
154
+ const color = useTransform(x, INPUT_RANGE, OUTPUT_RANGE)
155
+ const template = useMotionTemplate`translate3d(${x}px, ${y}px, 0)`
156
+ const probe = useMotionValue(PROBE_INITIAL)
157
+
158
+ const followerRef = useRef<ComponentRef<typeof MotionView> | null>(null)
159
+ const markerRef = useRef<ComponentRef<typeof View> | null>(null)
160
+
161
+ // First-frame stamps: the derived values computed at creation, read during the FIRST render —
162
+ // what the binding's attach publishes before any frame runs.
163
+ const firstFrameRef = useRef<{ color: string; template: string } | null>(null)
164
+ if (firstFrameRef.current === null) {
165
+ firstFrameRef.current = { color: color.get(), template: template.get() }
166
+ }
167
+
168
+ const colorStampsRef = useRef<{ x: number; color: string }[]>([])
169
+ const templateStampsRef = useRef<string[]>([])
170
+ const setCountRef = useRef(0)
171
+ const sawAnimatingRef = useRef(false)
172
+ const reportsRef = useRef<string[]>([])
173
+ const measured150Ref = useRef<number | null>(null)
174
+ const measured300Ref = useRef<number | null>(null)
175
+ const invalidThrewRef = useRef<string | null>(null)
176
+ const recoveryThrewRef = useRef<string | null>(null)
177
+
178
+ const [stage, setStage] = useState('boot: first frame at x=150 (pre-mount value)')
179
+ const [liveColor, setLiveColor] = useState<string>(() => color.get())
180
+ const [liveDelta, setLiveDelta] = useState('—')
181
+ const [probeNow, setProbeNow] = useState(PROBE_INITIAL)
182
+ const [rows, setRows] = useState<Partial<Record<(typeof ROW_ORDER)[number], RowResult>>>({})
183
+
184
+ useMotionValueEvent(color, 'change', (latest: string) => {
185
+ colorStampsRef.current.push({ x: x.get(), color: latest })
186
+ setLiveColor(latest)
187
+ })
188
+ useMotionValueEvent(template, 'change', (latest: string) => {
189
+ templateStampsRef.current.push(latest)
190
+ })
191
+
192
+ const finalize = (id: (typeof ROW_ORDER)[number], pass: boolean, detail: string): void => {
193
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
194
+ }
195
+
196
+ useEffect(() => {
197
+ // The production error channel tap: refusal reports ride consoleReporter (console.error) —
198
+ // recording them is the screen's only window into a production refusal, which by law never
199
+ // throws. Installed for this screen's lifetime only; everything passes through.
200
+ const originalError = console.error
201
+ console.error = (...args: unknown[]) => {
202
+ for (const arg of args) {
203
+ const message = arg instanceof Error ? arg.message : typeof arg === 'string' ? arg : null
204
+ if (message !== null && message.includes('style-bound MotionValue')) {
205
+ reportsRef.current.push(message)
206
+ }
207
+ }
208
+ originalError(...args)
209
+ }
210
+
211
+ const measureFollowerDelta = (done: (delta: number | null) => void): void => {
212
+ const follower = followerRef.current
213
+ const marker = markerRef.current
214
+ if (follower === null || marker === null) {
215
+ done(null)
216
+ return
217
+ }
218
+ follower.measureInWindow((followerX, _fy, width) => {
219
+ marker.measureInWindow((markerX) => {
220
+ done(width > 0 ? followerX - markerX : null)
221
+ })
222
+ })
223
+ }
224
+ const animationRefusals = (): number =>
225
+ reportsRef.current.filter((m) => m.includes('driven by a JS-side animation')).length
226
+ const invalidRefusals = (): readonly string[] =>
227
+ reportsRef.current.filter((m) => m.includes("color key 'backgroundColor'"))
228
+
229
+ const timers: ReturnType<typeof setTimeout>[] = []
230
+ const at = (ms: number, run: () => void): void => {
231
+ timers.push(setTimeout(run, ms))
232
+ }
233
+
234
+ // 1. ATTACH-PUBLISH — verify the first-frame stamps and the host-measured follower position.
235
+ at(T_ATTACH, () => {
236
+ measureFollowerDelta((delta) => {
237
+ measured150Ref.current = delta
238
+ setLiveDelta(delta === null ? 'n/a' : delta.toFixed(1))
239
+ const first = firstFrameRef.current!
240
+ const parsed = parseRgba(first.color)
241
+ const colorOk = parsed !== null && channelsClose(parsed, expectedRamp(150))
242
+ const templateOk = first.template === 'translate3d(150px, 0px, 0)'
243
+ const measureOk = delta !== null && Math.abs(delta - 150) <= 2
244
+ finalize(
245
+ ROW_ATTACH,
246
+ colorOk && templateOk && measureOk,
247
+ `first frame ${first.color} + ${first.template}; host-measured followerΔ ` +
248
+ `${delta === null ? 'unavailable' : delta.toFixed(1)} (want 150±2)`,
249
+ )
250
+ setStage('attach published — sweep starts')
251
+ })
252
+ })
253
+
254
+ // 2. set() SWEEP — direct set()s only. Clearing the stamp logs at the first step scopes the
255
+ // assertions to the sweep itself.
256
+ SWEEP_UP.forEach((value, index) => {
257
+ at(T_SWEEP_UP + index * STEP_MS, () => {
258
+ if (index === 0) {
259
+ colorStampsRef.current = []
260
+ templateStampsRef.current = []
261
+ setStage('sweep up: direct set() +25 → x=300')
262
+ }
263
+ setCountRef.current += 1
264
+ if (x.isAnimating()) sawAnimatingRef.current = true
265
+ x.set(value)
266
+ })
267
+ })
268
+ at(T_MEASURE_300, () => {
269
+ measureFollowerDelta((delta) => {
270
+ measured300Ref.current = delta
271
+ setLiveDelta(delta === null ? 'n/a' : delta.toFixed(1))
272
+ setStage('dwell at x=300 (blue end of the ramp)')
273
+ })
274
+ })
275
+ SWEEP_DOWN.forEach((value, index) => {
276
+ at(T_SWEEP_DOWN + index * STEP_MS, () => {
277
+ if (index === 0) setStage('sweep down: direct set() −25 → x=0')
278
+ setCountRef.current += 1
279
+ if (x.isAnimating()) sawAnimatingRef.current = true
280
+ x.set(value)
281
+ })
282
+ })
283
+ at(T_SWEEP_FINALIZE, () => {
284
+ measureFollowerDelta((delta0) => {
285
+ setLiveDelta(delta0 === null ? 'n/a' : delta0.toFixed(1))
286
+ // —— color row ——
287
+ const stamps = colorStampsRef.current
288
+ let colorBad: string | null = null
289
+ if (stamps.length !== SWEEP_VALUES.length) {
290
+ colorBad = `${stamps.length} color stamps for ${SWEEP_VALUES.length} direct set()s`
291
+ } else {
292
+ for (let i = 0; i < stamps.length; i++) {
293
+ const stamp = stamps[i]!
294
+ if (stamp.x !== SWEEP_VALUES[i]) {
295
+ colorBad = `stamp ${i} at x=${stamp.x}, want ${SWEEP_VALUES[i]}`
296
+ break
297
+ }
298
+ const parsed = parseRgba(stamp.color)
299
+ if (parsed === null || !channelsClose(parsed, expectedRamp(stamp.x))) {
300
+ colorBad = `stamp ${i} x=${stamp.x}: ${stamp.color}, want ≈${formatRgb(expectedRamp(stamp.x))}`
301
+ break
302
+ }
303
+ }
304
+ }
305
+ if (colorBad === null && sawAnimatingRef.current) {
306
+ colorBad = 'x animated during the sweep — every step must be a DIRECT set()'
307
+ }
308
+ if (colorBad === null && animationRefusals() > 0) {
309
+ colorBad = `${animationRefusals()} animation-refusal report(s) fired — the lane saw animated traffic`
310
+ }
311
+ const mid = stamps.find((stamp) => stamp.x === 150)
312
+ finalize(
313
+ ROW_SWEEP_COLOR,
314
+ colorBad === null,
315
+ colorBad ??
316
+ `${SWEEP_VALUES.length} direct set() steps, every stamped color on-ramp ` +
317
+ `(x=150 → ${mid?.color ?? '?'}), zero animation refusals`,
318
+ )
319
+ // —— template row ——
320
+ const templates = templateStampsRef.current
321
+ let templateBad: string | null = null
322
+ if (templates.length !== SWEEP_VALUES.length) {
323
+ templateBad = `${templates.length} template stamps for ${SWEEP_VALUES.length} direct set()s`
324
+ } else {
325
+ for (let i = 0; i < templates.length; i++) {
326
+ const want = `translate3d(${SWEEP_VALUES[i]}px, 0px, 0)`
327
+ if (templates[i] !== want) {
328
+ templateBad = `stamp ${i}: ${templates[i]}, want ${want}`
329
+ break
330
+ }
331
+ }
332
+ }
333
+ const at300 = measured300Ref.current
334
+ if (templateBad === null && (at300 === null || Math.abs(at300 - 300) > 2)) {
335
+ templateBad = `host-measured followerΔ at x=300: ${at300 === null ? 'unavailable' : at300.toFixed(1)}`
336
+ }
337
+ if (templateBad === null && (delta0 === null || Math.abs(delta0) > 2)) {
338
+ templateBad = `host-measured followerΔ at x=0: ${delta0 === null ? 'unavailable' : delta0.toFixed(1)}`
339
+ }
340
+ finalize(
341
+ ROW_SWEEP_TEMPLATE,
342
+ templateBad === null,
343
+ templateBad ??
344
+ `every set() re-emitted translate3d(xpx, 0px, 0); host-measured Δ ${at300?.toFixed(1) ?? '?'} ` +
345
+ `at x=300, ${delta0?.toFixed(1) ?? '?'} back at x=0`,
346
+ )
347
+ setStage('sweep complete — invalid set() next')
348
+ })
349
+ })
350
+
351
+ // 3. INVALID set() — production reports + refuses; development throws (the severity law).
352
+ at(T_INVALID, () => {
353
+ setStage(`invalid set(): ${PROBE_INVALID} (${severity} severity)`)
354
+ try {
355
+ probe.set(PROBE_INVALID)
356
+ setProbeNow(PROBE_INVALID)
357
+ } catch (thrown) {
358
+ invalidThrewRef.current = thrown instanceof Error ? thrown.message : String(thrown)
359
+ }
360
+ })
361
+ at(T_INVALID_FINALIZE, () => {
362
+ const refusals = invalidRefusals()
363
+ const threw = invalidThrewRef.current
364
+ if (severity === 'production') {
365
+ const pass = refusals.length === 1 && threw === null
366
+ finalize(
367
+ ROW_INVALID,
368
+ pass,
369
+ pass
370
+ ? `reported + refused: "${refusals[0]!.slice(0, 90)}…" — the host keeps the last good ` +
371
+ `color (probe box holds ${PROBE_INITIAL} on screen)`
372
+ : `want exactly 1 refusal report + no throw; got ${refusals.length} report(s), ` +
373
+ `threw=${threw === null ? 'no' : threw.slice(0, 60)}`,
374
+ )
375
+ } else {
376
+ const pass = threw !== null && threw.includes("color key 'backgroundColor'")
377
+ finalize(
378
+ ROW_INVALID,
379
+ pass,
380
+ pass
381
+ ? `development severity threw loud: "${threw.slice(0, 90)}…"`
382
+ : `development severity must throw naming the key; threw=${threw === null ? 'nothing' : threw.slice(0, 60)}`,
383
+ )
384
+ }
385
+ })
386
+ at(T_RECOVER, () => {
387
+ setStage('recovery set(): valid color publishes again')
388
+ try {
389
+ probe.set(PROBE_RECOVERY)
390
+ setProbeNow(PROBE_RECOVERY)
391
+ } catch (thrown) {
392
+ recoveryThrewRef.current = thrown instanceof Error ? thrown.message : String(thrown)
393
+ }
394
+ })
395
+ at(T_DONE, () => {
396
+ const refusals = invalidRefusals()
397
+ const threw = recoveryThrewRef.current
398
+ const pass = refusals.length === 1 && threw === null && probe.get() === PROBE_RECOVERY
399
+ finalize(
400
+ ROW_RECOVERY,
401
+ pass,
402
+ pass
403
+ ? `no new report; probe now ${probe.get()} (probe box turned blue — the lane never ` +
404
+ 'wedged on the refused write)'
405
+ : `reports=${refusals.length} (want still 1), threw=${threw === null ? 'no' : threw.slice(0, 60)}, ` +
406
+ `probe=${probe.get()}`,
407
+ )
408
+ setStage('done')
409
+ })
410
+
411
+ return () => {
412
+ for (const timer of timers) clearTimeout(timer)
413
+ console.error = originalError
414
+ }
415
+ // All captured values are stable hook identities or refs; the timeline runs once per mount.
416
+ // eslint-disable-next-line react-hooks/exhaustive-deps
417
+ }, [])
418
+
419
+ const done = ROW_ORDER.every((id) => rows[id] !== undefined)
420
+ const failing = ROW_ORDER.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
421
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
422
+ const passed = Object.keys(rows).length - failing.length
423
+
424
+ return (
425
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
426
+ <Text style={styles.title}>U5 color binding checks</Text>
427
+ <Text style={styles.subtitle}>
428
+ useTransform color ramp + useMotionTemplate transform, style-bound ({severity} severity)
429
+ </Text>
430
+ <View
431
+ style={[
432
+ styles.banner,
433
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
434
+ ]}
435
+ >
436
+ <Text style={styles.bannerText}>{verdict}</Text>
437
+ </View>
438
+ <Text style={styles.tally}>
439
+ {done
440
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROW_ORDER.length} rows`
441
+ : `${Object.keys(rows).length}/${ROW_ORDER.length} rows complete — RUNNING`}
442
+ </Text>
443
+ <Text style={styles.tallyDetail}>
444
+ {done
445
+ ? failing.length === 0
446
+ ? 'no failing rows'
447
+ : `FAILING: ${failing.join(' | ')}`
448
+ : `stage: ${stage}`}
449
+ </Text>
450
+ <Text style={styles.status}>
451
+ stage: {stage} · x={x.get()} · color: {liveColor} · followerΔ: {liveDelta} · probe:{' '}
452
+ {probeNow}
453
+ </Text>
454
+
455
+ {/* The subjects. The screenshot carries the host-received color evidence; the follower's
456
+ committed position is additionally read back from the host against the marker. The
457
+ track gets its OWN full-width row — beside the 64px subject it would shrink below the
458
+ 300px travel and push the follower off-screen at the x=300 dwell. */}
459
+ <View style={styles.stageRow}>
460
+ <MotionView
461
+ style={[styles.subject, { backgroundColor: color }]}
462
+ accessibilityLabel="u5-color-subject"
463
+ />
464
+ <MotionView
465
+ style={[styles.probe, { backgroundColor: probe }]}
466
+ accessibilityLabel="u5-invalid-probe"
467
+ />
468
+ </View>
469
+ <View style={styles.track} accessibilityLabel="u5-follower-track">
470
+ <View ref={markerRef} style={styles.marker} accessibilityLabel="u5-follower-marker" />
471
+ <MotionView
472
+ ref={followerRef}
473
+ style={[styles.follower, { transform: template }]}
474
+ accessibilityLabel="u5-follower"
475
+ />
476
+ </View>
477
+
478
+ {ROW_ORDER.map((id) => {
479
+ const row = rows[id]
480
+ return (
481
+ <View key={id} style={styles.row}>
482
+ <Text
483
+ style={[
484
+ styles.verdict,
485
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
486
+ ]}
487
+ >
488
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
489
+ </Text>
490
+ <View style={styles.rowBody}>
491
+ <Text style={styles.rowId}>{id}</Text>
492
+ <Text style={styles.rowDetail} numberOfLines={3}>
493
+ {row === undefined ? 'pending' : row.detail}
494
+ </Text>
495
+ </View>
496
+ </View>
497
+ )
498
+ })}
499
+ </ScrollView>
500
+ )
501
+ }
502
+
503
+ const styles = StyleSheet.create({
504
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
505
+ container: { paddingVertical: 72, paddingHorizontal: 8 },
506
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
507
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
508
+ banner: {
509
+ alignSelf: 'center',
510
+ paddingHorizontal: 36,
511
+ paddingVertical: 10,
512
+ borderRadius: 10,
513
+ marginTop: 18,
514
+ marginBottom: 10,
515
+ },
516
+ pass: { backgroundColor: '#1d7a3e' },
517
+ fail: { backgroundColor: '#a32633' },
518
+ running: { backgroundColor: '#7a641d' },
519
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
520
+ tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
521
+ tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
522
+ status: {
523
+ color: '#c9c9d8',
524
+ fontSize: 11,
525
+ marginTop: 8,
526
+ textAlign: 'center',
527
+ fontFamily: 'Menlo',
528
+ },
529
+ stageRow: { flexDirection: 'row', marginTop: 18, alignItems: 'center' },
530
+ subject: { width: 64, height: 64, borderRadius: 12 },
531
+ track: {
532
+ width: 336,
533
+ maxWidth: '100%',
534
+ height: 48,
535
+ marginTop: 14,
536
+ backgroundColor: '#14141d',
537
+ borderRadius: 8,
538
+ justifyContent: 'center',
539
+ },
540
+ marker: {
541
+ position: 'absolute',
542
+ left: 0,
543
+ top: 6,
544
+ width: 36,
545
+ height: 36,
546
+ borderWidth: 1,
547
+ borderColor: '#33333f',
548
+ borderRadius: 8,
549
+ },
550
+ follower: {
551
+ position: 'absolute',
552
+ left: 0,
553
+ top: 6,
554
+ width: 36,
555
+ height: 36,
556
+ borderRadius: 8,
557
+ backgroundColor: '#8a8aa0',
558
+ },
559
+ probe: { width: 64, height: 24, borderRadius: 6, marginLeft: 16 },
560
+ row: { flexDirection: 'row', marginTop: 10 },
561
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
562
+ ok: { color: '#4ade80' },
563
+ bad: { color: '#f87171' },
564
+ pending: { color: '#8a8aa0' },
565
+ rowBody: { flex: 1 },
566
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
567
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
568
+ })