@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,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
|
+
})
|