@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,930 @@
|
|
|
1
|
+
// M2.2 Motion.View behavioral checks (specs/M2.2-BUILD-PACKET.md §pinned semantics). Every check
|
|
2
|
+
// drives the controller + the manual-stepping driver (synchronous crossings — deterministic, no
|
|
3
|
+
// React timing) and renders PASS/FAIL on screen: the screenshot is the assertion (M2.1 pattern).
|
|
4
|
+
// try/catch here is evidence capture — a failed check renders its exact error, never disappears.
|
|
5
|
+
|
|
6
|
+
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
|
7
|
+
import {
|
|
8
|
+
EPS_COLOR_CHANNEL,
|
|
9
|
+
InvalidTargetError,
|
|
10
|
+
parseColor,
|
|
11
|
+
transformColor,
|
|
12
|
+
type Driver,
|
|
13
|
+
type DriverCommand,
|
|
14
|
+
type ElementHandle,
|
|
15
|
+
type Target,
|
|
16
|
+
} from '@unrulysystems/native-motion-core'
|
|
17
|
+
import { createWorkletDriver, type WorkletDriverBinding } from '../../native/driver/workletDriver'
|
|
18
|
+
import { projectColor } from '../../native/motion/colorProjection'
|
|
19
|
+
import { prepareMotionViewMount, prepareMotionViewUpdate } from '../../native/motion/MotionView'
|
|
20
|
+
import { createMotionViewController } from '../../native/motion/motionViewController'
|
|
21
|
+
|
|
22
|
+
const SPRING = { type: 'spring', stiffness: 100, damping: 10 } as const
|
|
23
|
+
const DT_MS = 16.67
|
|
24
|
+
|
|
25
|
+
interface Row {
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly pass: boolean
|
|
28
|
+
readonly detail: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Every manual driver created during a check run, disposed together after the rows are computed —
|
|
32
|
+
// the rows rebuild per render, and an undisposed driver strands its UI-runtime record per run
|
|
33
|
+
// (quiesce-before-unmount, review round 4 major 5).
|
|
34
|
+
const liveCheckDrivers: WorkletDriverBinding[] = []
|
|
35
|
+
|
|
36
|
+
function checkDriver(): WorkletDriverBinding {
|
|
37
|
+
const binding = createWorkletDriver({ manualStepping: true })
|
|
38
|
+
liveCheckDrivers.push(binding)
|
|
39
|
+
return binding
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function disposeCheckDrivers(): void {
|
|
43
|
+
for (const binding of liveCheckDrivers) binding.dispose()
|
|
44
|
+
liveCheckDrivers.length = 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Wrap a binding so checks can count commands and observe setActive calls without touching the
|
|
48
|
+
// driver itself (verification-side instrumentation, not product code).
|
|
49
|
+
interface InstrumentedBinding {
|
|
50
|
+
readonly binding: WorkletDriverBinding
|
|
51
|
+
commandCount(): number
|
|
52
|
+
lastSetActive(): { handle: number; active: boolean } | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function instrument(inner: WorkletDriverBinding): InstrumentedBinding {
|
|
56
|
+
let commands = 0
|
|
57
|
+
let lastSetActive: { handle: number; active: boolean } | null = null
|
|
58
|
+
const driver: Driver = {
|
|
59
|
+
register: (initial) => inner.driver.register(initial),
|
|
60
|
+
command: (handle: ElementHandle, command: DriverCommand) => {
|
|
61
|
+
commands += 1
|
|
62
|
+
inner.driver.command(handle, command)
|
|
63
|
+
},
|
|
64
|
+
step: (elapsedMs) => inner.driver.step(elapsedMs),
|
|
65
|
+
committed: (handle) => inner.driver.committed(handle),
|
|
66
|
+
setActive: (handle, active) => {
|
|
67
|
+
lastSetActive = { handle: handle as number, active }
|
|
68
|
+
inner.driver.setActive(handle, active)
|
|
69
|
+
},
|
|
70
|
+
isActive: (handle) => inner.driver.isActive(handle),
|
|
71
|
+
// FLAG 5a gesture lane: pass-through forwarding (no check instruments these yet). Forward the
|
|
72
|
+
// gesture-phase velocity so the C1 seam holds through the wrapper (r4 c14e15).
|
|
73
|
+
write: (handle, values, velocities) => inner.driver.write(handle, values, velocities),
|
|
74
|
+
liveFor: (handle, key) => inner.driver.liveFor(handle, key),
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
binding: {
|
|
78
|
+
driver,
|
|
79
|
+
sharedFor: (handle, key) => inner.sharedFor(handle, key),
|
|
80
|
+
writeBoundValue: (handle, values) => inner.writeBoundValue(handle, values),
|
|
81
|
+
onBoundValueWriteRefusal: (listener) => inner.onBoundValueWriteRefusal(listener),
|
|
82
|
+
setFrameValuesListener: (handle, listener) => inner.setFrameValuesListener(handle, listener),
|
|
83
|
+
layout: inner.layout,
|
|
84
|
+
dispose: () => inner.dispose(),
|
|
85
|
+
isDisposed: () => inner.isDisposed(),
|
|
86
|
+
},
|
|
87
|
+
commandCount: () => commands,
|
|
88
|
+
lastSetActive: () => lastSetActive,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkMountAnimates(): Row {
|
|
93
|
+
const id = 'mount animates initial→animate'
|
|
94
|
+
try {
|
|
95
|
+
const binding = checkDriver()
|
|
96
|
+
const controller = createMotionViewController(binding, {
|
|
97
|
+
animate: { x: 220 },
|
|
98
|
+
transition: SPRING,
|
|
99
|
+
})
|
|
100
|
+
const handle = controller.mount()
|
|
101
|
+
const start = binding.driver.committed(handle)['x']
|
|
102
|
+
for (let i = 0; i < 30; i++) binding.driver.step(DT_MS)
|
|
103
|
+
const mid = binding.driver.committed(handle)['x']
|
|
104
|
+
for (let i = 0; i < 600; i++) binding.driver.step(DT_MS)
|
|
105
|
+
const end = binding.driver.committed(handle)['x']
|
|
106
|
+
const quiesced = !binding.driver.isActive(handle)
|
|
107
|
+
if (start !== 0) return { id, pass: false, detail: `start ${String(start)}, want host base 0` }
|
|
108
|
+
if (!(typeof mid === 'number' && mid > 0)) {
|
|
109
|
+
return { id, pass: false, detail: `no motion by step 30 (mid=${String(mid)})` }
|
|
110
|
+
}
|
|
111
|
+
if (end !== 220 || !quiesced) {
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
pass: false,
|
|
115
|
+
detail: `end=${String(end)} quiesced=${String(quiesced)}, want 220/true`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { id, pass: true, detail: `0 → ${mid.toFixed(1)} (step 30) → 220 settled, quiesced` }
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// H2 (REQ-API-049 / REQ-TIMING-003) — a FRACTIONAL repeat executes on the UI runtime and commits
|
|
125
|
+
// the pin's final keyframe. Discriminating in both directions: before H2 the fractional transition
|
|
126
|
+
// was REFUSED at validation, so this check could not even construct; and the snap lives in
|
|
127
|
+
// `repeatGenerator`'s `finalValue`, applied by `stepProp`, both inside workletized core — so a
|
|
128
|
+
// device PASS is evidence the new frame-path code crossed to the UI runtime intact, not merely that
|
|
129
|
+
// the host-side unit tests pass.
|
|
130
|
+
function checkFractionalRepeatSnaps(): Row {
|
|
131
|
+
const id = 'fractional repeat commits the final keyframe'
|
|
132
|
+
try {
|
|
133
|
+
const binding = checkDriver()
|
|
134
|
+
const controller = createMotionViewController(binding, {
|
|
135
|
+
animate: { x: 220 },
|
|
136
|
+
// 0.5 additional plays, REVERSE: the last leg is DESCENDING toward the origin when the clock
|
|
137
|
+
// runs out, and the pin still commits the TARGET. Mid-play would be ~110.
|
|
138
|
+
transition: {
|
|
139
|
+
type: 'tween',
|
|
140
|
+
duration: 0.1,
|
|
141
|
+
ease: 'linear',
|
|
142
|
+
repeat: 0.5,
|
|
143
|
+
repeatType: 'reverse',
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
const handle = controller.mount()
|
|
147
|
+
for (let i = 0; i < 600; i++) binding.driver.step(DT_MS)
|
|
148
|
+
const end = binding.driver.committed(handle)['x']
|
|
149
|
+
const quiesced = !binding.driver.isActive(handle)
|
|
150
|
+
if (typeof end !== 'number')
|
|
151
|
+
return { id, pass: false, detail: `no committed x (${String(end)})` }
|
|
152
|
+
if (Math.abs(end - 220) > 0.5 || !quiesced) {
|
|
153
|
+
return {
|
|
154
|
+
id,
|
|
155
|
+
pass: false,
|
|
156
|
+
detail: `end=${end.toFixed(2)} quiesced=${String(quiesced)}, want 220/true (mid-play ≈110)`,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
pass: true,
|
|
162
|
+
detail: `descending reverse leg → committed ${end.toFixed(1)}, quiesced`,
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// H2 round-1 review MAJOR 1 — the COMPOSED case. `delayGenerator` rebuilds the generator object, so
|
|
170
|
+
// it silently dropped the fold's finish commit and a delayed fractional repeat landed mid-play on
|
|
171
|
+
// both drivers. `delay.ts` is worklet-tagged frame-path core, so this row is what proves the forward
|
|
172
|
+
// crossed to the UI runtime rather than only passing on the host. A dropped forward reads 110.0.
|
|
173
|
+
function checkDelayedFractionalRepeatSnaps(): Row {
|
|
174
|
+
const id = 'delay + fractional repeat still commits the final keyframe'
|
|
175
|
+
try {
|
|
176
|
+
const binding = checkDriver()
|
|
177
|
+
const controller = createMotionViewController(binding, {
|
|
178
|
+
animate: { x: 220 },
|
|
179
|
+
transition: {
|
|
180
|
+
type: 'tween',
|
|
181
|
+
duration: 0.1,
|
|
182
|
+
ease: 'linear',
|
|
183
|
+
delay: 0.2,
|
|
184
|
+
repeat: 0.5,
|
|
185
|
+
repeatType: 'reverse',
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
const handle = controller.mount()
|
|
189
|
+
for (let i = 0; i < 600; i++) binding.driver.step(DT_MS)
|
|
190
|
+
const end = binding.driver.committed(handle)['x']
|
|
191
|
+
const quiesced = !binding.driver.isActive(handle)
|
|
192
|
+
if (typeof end !== 'number')
|
|
193
|
+
return { id, pass: false, detail: `no committed x (${String(end)})` }
|
|
194
|
+
if (Math.abs(end - 220) > 0.5 || !quiesced) {
|
|
195
|
+
return {
|
|
196
|
+
id,
|
|
197
|
+
pass: false,
|
|
198
|
+
detail: `end=${end.toFixed(2)} quiesced=${String(quiesced)}, want 220/true (dropped forward reads 110)`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
id,
|
|
203
|
+
pass: true,
|
|
204
|
+
detail: `delayed 200ms, descending leg → committed ${end.toFixed(1)}, quiesced`,
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// H3 (REQ-VALUETYPE-015) — a BOTH-VISIBLE discrete pair publishes the pin's TARGET constant, and it
|
|
212
|
+
// does so at progress ZERO. `projectDiscreteStep` is a worklet on the frame path, so a device PASS
|
|
213
|
+
// is evidence the new arm crossed to the UI runtime; before H3 this pair was refused at the
|
|
214
|
+
// controller boundary and the check could not even mount.
|
|
215
|
+
//
|
|
216
|
+
// The reading at p=0 is the whole discriminator. A step law reads the ORIGIN there, the pin's
|
|
217
|
+
// immediate arm reads the ORIGIN there, and native's pre-H3 worklet fallback read the ORIGIN there.
|
|
218
|
+
// Only the pin's zero-slot constant reads the TARGET.
|
|
219
|
+
function checkVisibleDiscretePairPublishesTarget(): Row {
|
|
220
|
+
const id = 'both-visible discrete pair publishes the target at p=0'
|
|
221
|
+
try {
|
|
222
|
+
const binding = checkDriver()
|
|
223
|
+
const controller = createMotionViewController(binding, {
|
|
224
|
+
initial: { display: 'flex' },
|
|
225
|
+
animate: { display: 'contents' },
|
|
226
|
+
transition: { type: 'tween', duration: 0.2, ease: 'linear' },
|
|
227
|
+
})
|
|
228
|
+
controller.mount()
|
|
229
|
+
// Read BEFORE stepping: the lane is registered at progress 0.
|
|
230
|
+
const atZero = controller.currentDiscrete('display')
|
|
231
|
+
for (let i = 0; i < 600; i++) binding.driver.step(DT_MS)
|
|
232
|
+
const atEnd = controller.currentDiscrete('display')
|
|
233
|
+
if (atZero !== 'contents' || atEnd !== 'contents') {
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
pass: false,
|
|
237
|
+
detail: `p=0 → ${atZero}, settled → ${atEnd}; want contents/contents ('flex' at p=0 is the old step)`,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { id, pass: true, detail: `flex → contents: p=0 → ${atZero}, settled → ${atEnd}` }
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The control H3 owes: the invisible-endpoint STEP law must survive intact. An implementation that
|
|
247
|
+
// simply returned the target everywhere would pass the row above and fail this one — `flex → none`
|
|
248
|
+
// holds the ORIGIN until the very end, so p=0 must still read 'flex'.
|
|
249
|
+
function checkInvisibleTargetStepUnmoved(): Row {
|
|
250
|
+
const id = 'invisible-target discrete pair still steps at the end'
|
|
251
|
+
try {
|
|
252
|
+
const binding = checkDriver()
|
|
253
|
+
const controller = createMotionViewController(binding, {
|
|
254
|
+
initial: { display: 'flex' },
|
|
255
|
+
animate: { display: 'none' },
|
|
256
|
+
transition: { type: 'tween', duration: 0.2, ease: 'linear' },
|
|
257
|
+
})
|
|
258
|
+
controller.mount()
|
|
259
|
+
const atZero = controller.currentDiscrete('display')
|
|
260
|
+
for (let i = 0; i < 600; i++) binding.driver.step(DT_MS)
|
|
261
|
+
const atEnd = controller.currentDiscrete('display')
|
|
262
|
+
if (atZero !== 'flex' || atEnd !== 'none') {
|
|
263
|
+
return {
|
|
264
|
+
id,
|
|
265
|
+
pass: false,
|
|
266
|
+
detail: `p=0 → ${atZero}, settled → ${atEnd}; want flex/none (the pin's mixVisibility step)`,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { id, pass: true, detail: `flex → none: p=0 → ${atZero}, settled → ${atEnd}` }
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The control: an INTEGER repeat already ends on the pin's keyframe by construction, and H2 must not
|
|
276
|
+
// have moved it. `repeat: 1` + `reverse` commits the FIRST keyframe — the origin, not the target.
|
|
277
|
+
function checkIntegerRepeatUnmoved(): Row {
|
|
278
|
+
const id = 'integer repeat still lands on the origin'
|
|
279
|
+
try {
|
|
280
|
+
const binding = checkDriver()
|
|
281
|
+
const controller = createMotionViewController(binding, {
|
|
282
|
+
animate: { x: 220 },
|
|
283
|
+
transition: {
|
|
284
|
+
type: 'tween',
|
|
285
|
+
duration: 0.1,
|
|
286
|
+
ease: 'linear',
|
|
287
|
+
repeat: 1,
|
|
288
|
+
repeatType: 'reverse',
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
const handle = controller.mount()
|
|
292
|
+
for (let i = 0; i < 600; i++) binding.driver.step(DT_MS)
|
|
293
|
+
const end = binding.driver.committed(handle)['x']
|
|
294
|
+
const quiesced = !binding.driver.isActive(handle)
|
|
295
|
+
if (typeof end !== 'number')
|
|
296
|
+
return { id, pass: false, detail: `no committed x (${String(end)})` }
|
|
297
|
+
if (Math.abs(end) > 0.5 || !quiesced) {
|
|
298
|
+
return {
|
|
299
|
+
id,
|
|
300
|
+
pass: false,
|
|
301
|
+
detail: `end=${end.toFixed(2)} quiesced=${String(quiesced)}, want 0/true`,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
id,
|
|
306
|
+
pass: true,
|
|
307
|
+
detail: `odd reverse leg → committed ${end.toFixed(1)} (origin), quiesced`,
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function checkInitialFalse(): Row {
|
|
315
|
+
const id = 'initial={false} mounts settled at animate'
|
|
316
|
+
try {
|
|
317
|
+
const binding = checkDriver()
|
|
318
|
+
const controller = createMotionViewController(binding, {
|
|
319
|
+
initial: false,
|
|
320
|
+
animate: { x: 220, opacity: 0.5 },
|
|
321
|
+
transition: SPRING,
|
|
322
|
+
})
|
|
323
|
+
const handle = controller.mount()
|
|
324
|
+
const committed = binding.driver.committed(handle)
|
|
325
|
+
const active = binding.driver.isActive(handle)
|
|
326
|
+
binding.driver.step(DT_MS)
|
|
327
|
+
const after = binding.driver.committed(handle)
|
|
328
|
+
if (committed['x'] !== 220 || committed['opacity'] !== 0.5) {
|
|
329
|
+
return {
|
|
330
|
+
id,
|
|
331
|
+
pass: false,
|
|
332
|
+
detail: `mounted at ${JSON.stringify(committed)}, want animate values`,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (active) return { id, pass: false, detail: 'element is animating; want no entrance motion' }
|
|
336
|
+
if (after['x'] !== 220 || after['opacity'] !== 0.5) {
|
|
337
|
+
return { id, pass: false, detail: 'values moved after a step; want settled hold' }
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
id,
|
|
341
|
+
pass: true,
|
|
342
|
+
detail: 'first frame at animate values, no motion, holds under stepping',
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function checkRetargetContinuity(): Row {
|
|
350
|
+
const id = 'animate change → one continuous retarget'
|
|
351
|
+
try {
|
|
352
|
+
const instrumented = instrument(checkDriver())
|
|
353
|
+
const controller = createMotionViewController(instrumented.binding, {
|
|
354
|
+
animate: { x: 220 },
|
|
355
|
+
transition: SPRING,
|
|
356
|
+
})
|
|
357
|
+
const handle = controller.mount()
|
|
358
|
+
for (let i = 0; i < 30; i++) instrumented.binding.driver.step(DT_MS)
|
|
359
|
+
const before = instrumented.binding.driver.committed(handle)['x']!
|
|
360
|
+
const commandsBefore = instrumented.commandCount()
|
|
361
|
+
controller.update({ animate: { x: 0 }, transition: SPRING })
|
|
362
|
+
const seeded = instrumented.binding.driver.committed(handle)['x']!
|
|
363
|
+
const issued = instrumented.commandCount() - commandsBefore
|
|
364
|
+
for (let i = 0; i < 900; i++) instrumented.binding.driver.step(DT_MS)
|
|
365
|
+
const end = instrumented.binding.driver.committed(handle)['x']
|
|
366
|
+
if (issued !== 1)
|
|
367
|
+
return { id, pass: false, detail: `${issued} commands issued, want exactly 1` }
|
|
368
|
+
if (seeded !== before) {
|
|
369
|
+
return { id, pass: false, detail: `seed jumped ${before} → ${seeded}; want C0 continuity` }
|
|
370
|
+
}
|
|
371
|
+
if (end !== 0) return { id, pass: false, detail: `settled at ${String(end)}, want 0` }
|
|
372
|
+
return {
|
|
373
|
+
id,
|
|
374
|
+
pass: true,
|
|
375
|
+
detail: `seeded from live ${before.toFixed(1)}, one retarget, settled at 0`,
|
|
376
|
+
}
|
|
377
|
+
} catch (e) {
|
|
378
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function checkUnlistedHoldAndNoOpUpdate(): Row {
|
|
383
|
+
const id = 'unlisted props hold; unchanged update issues no command'
|
|
384
|
+
try {
|
|
385
|
+
const instrumented = instrument(checkDriver())
|
|
386
|
+
const controller = createMotionViewController(instrumented.binding, {
|
|
387
|
+
animate: { x: 220, opacity: 0.25 },
|
|
388
|
+
transition: SPRING,
|
|
389
|
+
})
|
|
390
|
+
const handle = controller.mount()
|
|
391
|
+
for (let i = 0; i < 900; i++) instrumented.binding.driver.step(DT_MS)
|
|
392
|
+
const settledOpacity = instrumented.binding.driver.committed(handle)['opacity']
|
|
393
|
+
// Unrelated re-render: same resolved values → NO command (REQ discipline from the packet).
|
|
394
|
+
const commandsBefore = instrumented.commandCount()
|
|
395
|
+
controller.update({ animate: { x: 220, opacity: 0.25 }, transition: SPRING })
|
|
396
|
+
const noOpIssued = instrumented.commandCount() - commandsBefore
|
|
397
|
+
// Retarget mentioning only x: opacity must hold at 0.25, never reset (REQ-API-002/012).
|
|
398
|
+
controller.update({ animate: { x: 50, opacity: 0.25 }, transition: SPRING })
|
|
399
|
+
const rightAfter = instrumented.binding.driver.committed(handle)['opacity']
|
|
400
|
+
for (let i = 0; i < 900; i++) instrumented.binding.driver.step(DT_MS)
|
|
401
|
+
const finalCommitted = instrumented.binding.driver.committed(handle)
|
|
402
|
+
if (settledOpacity !== 0.25) {
|
|
403
|
+
return { id, pass: false, detail: `opacity settled at ${String(settledOpacity)}, want 0.25` }
|
|
404
|
+
}
|
|
405
|
+
if (noOpIssued !== 0) {
|
|
406
|
+
return { id, pass: false, detail: `unchanged update issued ${noOpIssued} command(s), want 0` }
|
|
407
|
+
}
|
|
408
|
+
if (rightAfter !== 0.25 || finalCommitted['opacity'] !== 0.25) {
|
|
409
|
+
return {
|
|
410
|
+
id,
|
|
411
|
+
pass: false,
|
|
412
|
+
detail: `opacity ${String(rightAfter)} → ${String(finalCommitted['opacity'])}; a retarget reset an unlisted prop`,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (finalCommitted['x'] !== 50) {
|
|
416
|
+
return { id, pass: false, detail: `x ${String(finalCommitted['x'])}, want 50` }
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
id,
|
|
420
|
+
pass: true,
|
|
421
|
+
detail: 'no-op update silent; opacity held 0.25 across the retarget; x → 50',
|
|
422
|
+
}
|
|
423
|
+
} catch (e) {
|
|
424
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function checkValidateFailLoud(): Row {
|
|
429
|
+
const id = 'invalid target fails loud at mount'
|
|
430
|
+
try {
|
|
431
|
+
const binding = checkDriver()
|
|
432
|
+
const controller = createMotionViewController(binding, {
|
|
433
|
+
animate: { definitelyNotAProperty: 1 } as unknown as Target,
|
|
434
|
+
transition: SPRING,
|
|
435
|
+
})
|
|
436
|
+
try {
|
|
437
|
+
controller.mount()
|
|
438
|
+
return { id, pass: false, detail: 'mount did not throw on an unknown target key' }
|
|
439
|
+
} catch (thrown) {
|
|
440
|
+
if (!(thrown instanceof InvalidTargetError)) {
|
|
441
|
+
return {
|
|
442
|
+
id,
|
|
443
|
+
pass: false,
|
|
444
|
+
detail: `threw ${thrown instanceof Error ? thrown.name : typeof thrown}, want InvalidTargetError`,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!thrown.message.includes('definitelyNotAProperty')) {
|
|
448
|
+
return {
|
|
449
|
+
id,
|
|
450
|
+
pass: false,
|
|
451
|
+
detail: `error does not name the offending key: ${thrown.message}`,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
id,
|
|
456
|
+
pass: true,
|
|
457
|
+
detail: `InvalidTargetError names the key: ${thrown.message.slice(0, 80)}…`,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function checkStyleSeedsStart(): Row {
|
|
466
|
+
// Review-cycle-1 major 1 (specs/M2.2-BUILD-PACKET.md Findings): a style-declared value must seed
|
|
467
|
+
// the entrance start (REQ-API-011 — style wins over host base). style opacity 0.3, animate to 1:
|
|
468
|
+
// the first committed frame is 0.3 (NOT the host base 1), then the spring settles at 1.
|
|
469
|
+
const id = 'style value seeds the entrance start'
|
|
470
|
+
try {
|
|
471
|
+
const binding = checkDriver()
|
|
472
|
+
const controller = createMotionViewController(binding, {
|
|
473
|
+
animate: { opacity: 1 },
|
|
474
|
+
transition: SPRING,
|
|
475
|
+
style: { opacity: 0.3 },
|
|
476
|
+
})
|
|
477
|
+
const handle = controller.mount()
|
|
478
|
+
const start = binding.driver.committed(handle)['opacity']
|
|
479
|
+
for (let i = 0; i < 900; i++) binding.driver.step(DT_MS)
|
|
480
|
+
const end = binding.driver.committed(handle)['opacity']
|
|
481
|
+
if (start !== 0.3) {
|
|
482
|
+
return { id, pass: false, detail: `seeded from ${String(start)}, want the style value 0.3` }
|
|
483
|
+
}
|
|
484
|
+
if (end !== 1) return { id, pass: false, detail: `settled at ${String(end)}, want 1` }
|
|
485
|
+
return {
|
|
486
|
+
id,
|
|
487
|
+
pass: true,
|
|
488
|
+
detail: 'entrance seeded from style 0.3 (not host base 1), settled at 1',
|
|
489
|
+
}
|
|
490
|
+
} catch (e) {
|
|
491
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function checkWrapperValidationOrder(): Row {
|
|
496
|
+
// Review-cycle-2 major 3: the WRAPPER's mount prep must run core validation FIRST — an unknown key
|
|
497
|
+
// throws InvalidTargetError from the pipeline (never the spike's scope Error), while a VALID key
|
|
498
|
+
// this spike cannot render (e.g. width) hits the scope guard AFTER validation passes. This drives
|
|
499
|
+
// prepareMotionViewMount — the exact function the component calls at mount.
|
|
500
|
+
const id = 'wrapper validates before its scope guard'
|
|
501
|
+
try {
|
|
502
|
+
let unknownKeyError: unknown = null
|
|
503
|
+
try {
|
|
504
|
+
prepareMotionViewMount({ animate: { definitelyNotAProperty: 1 } as unknown as Target })
|
|
505
|
+
} catch (e) {
|
|
506
|
+
unknownKeyError = e
|
|
507
|
+
}
|
|
508
|
+
if (!(unknownKeyError instanceof InvalidTargetError)) {
|
|
509
|
+
return {
|
|
510
|
+
id,
|
|
511
|
+
pass: false,
|
|
512
|
+
detail: `unknown key threw ${unknownKeyError instanceof Error ? unknownKeyError.name : typeof unknownKeyError}, want InvalidTargetError (validation must run first)`,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// R4 zero-delta (REQ-DRIVER-027): the mapped set equals the registry's full shipped key
|
|
516
|
+
// set, so the 'valid-but-unmapped' class is RETIRED — width now mounts clean (r12 major
|
|
517
|
+
// 7b8e1f34c2ad; this row asserted the pre-R4 scope error and reported false failures).
|
|
518
|
+
try {
|
|
519
|
+
prepareMotionViewMount({ animate: { width: 100 } })
|
|
520
|
+
} catch (e) {
|
|
521
|
+
return {
|
|
522
|
+
id,
|
|
523
|
+
pass: false,
|
|
524
|
+
detail: `width (R4-mapped) threw ${e instanceof Error ? e.message.slice(0, 80) : String(e)}, want a clean mount`,
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
id,
|
|
529
|
+
pass: true,
|
|
530
|
+
detail: 'unknown key → InvalidTargetError first; width (R4-mapped) mounts clean',
|
|
531
|
+
}
|
|
532
|
+
} catch (e) {
|
|
533
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function checkWrapperUpdateGuard(): Row {
|
|
538
|
+
// Review-cycle-3 major 4: UPDATE-time keys must hit the same wrapper boundary as mount — an
|
|
539
|
+
// unknown key throws the pipeline's InvalidTargetError; a valid key that was NOT mounted throws
|
|
540
|
+
// the fixed-at-mount scope error (it has no shared value and no registered driver prop — letting
|
|
541
|
+
// it through dies late inside the driver, the wrong altitude).
|
|
542
|
+
const id = 'wrapper guards update-time keys'
|
|
543
|
+
try {
|
|
544
|
+
let newKeyError: unknown = null
|
|
545
|
+
try {
|
|
546
|
+
prepareMotionViewUpdate(['x'], { x: 0, width: 100 })
|
|
547
|
+
} catch (e) {
|
|
548
|
+
newKeyError = e
|
|
549
|
+
}
|
|
550
|
+
const newKeyOk =
|
|
551
|
+
newKeyError instanceof Error &&
|
|
552
|
+
!(newKeyError instanceof InvalidTargetError) &&
|
|
553
|
+
newKeyError.message.includes('fixed at mount') &&
|
|
554
|
+
newKeyError.message.includes('width')
|
|
555
|
+
if (!newKeyOk) {
|
|
556
|
+
return {
|
|
557
|
+
id,
|
|
558
|
+
pass: false,
|
|
559
|
+
detail: `un-mounted valid key: ${newKeyError instanceof Error ? newKeyError.message.slice(0, 80) : String(newKeyError)}, want the fixed-at-mount scope error`,
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
let unknownKeyError: unknown = null
|
|
563
|
+
try {
|
|
564
|
+
prepareMotionViewUpdate(['x'], { definitelyNotAProperty: 1 } as unknown as Target)
|
|
565
|
+
} catch (e) {
|
|
566
|
+
unknownKeyError = e
|
|
567
|
+
}
|
|
568
|
+
if (!(unknownKeyError instanceof InvalidTargetError)) {
|
|
569
|
+
return {
|
|
570
|
+
id,
|
|
571
|
+
pass: false,
|
|
572
|
+
detail: `unknown key threw ${unknownKeyError instanceof Error ? unknownKeyError.name : typeof unknownKeyError}, want InvalidTargetError (validation still first)`,
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
id,
|
|
577
|
+
pass: true,
|
|
578
|
+
detail:
|
|
579
|
+
"update with un-mounted 'width' → fixed-at-mount scope error; unknown key → InvalidTargetError",
|
|
580
|
+
}
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function checkTeardownDeactivates(): Row {
|
|
587
|
+
const id = 'unmount deactivates before release'
|
|
588
|
+
try {
|
|
589
|
+
const instrumented = instrument(checkDriver())
|
|
590
|
+
const controller = createMotionViewController(instrumented.binding, {
|
|
591
|
+
animate: { x: 220 },
|
|
592
|
+
transition: SPRING,
|
|
593
|
+
})
|
|
594
|
+
const handle = controller.mount()
|
|
595
|
+
for (let i = 0; i < 10; i++) instrumented.binding.driver.step(DT_MS)
|
|
596
|
+
controller.unmount()
|
|
597
|
+
const last = instrumented.lastSetActive()
|
|
598
|
+
if (last === null || last.handle !== (handle as number) || last.active !== false) {
|
|
599
|
+
return {
|
|
600
|
+
id,
|
|
601
|
+
pass: false,
|
|
602
|
+
detail: `no setActive(handle,false) observed at unmount (${JSON.stringify(last)})`,
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { id, pass: true, detail: 'setActive(false) observed before release' }
|
|
606
|
+
} catch (e) {
|
|
607
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// --- L1 rows (specs/L1-BUILD-PACKET.md) --------------------------------------------------------
|
|
612
|
+
// Authored RED-FIRST: each drives the L1 contract through the REAL wrapper/controller path and
|
|
613
|
+
// fails against the pre-L1 tree (the M2.2 spike guards / the currentColor stub). The packet cites
|
|
614
|
+
// the observed red run before the implementation lands (TDD gate).
|
|
615
|
+
|
|
616
|
+
function checkL1RadiusKeysMapped(): Row {
|
|
617
|
+
const id = 'L1: wrapper maps radius keys'
|
|
618
|
+
try {
|
|
619
|
+
const { keys } = prepareMotionViewMount({
|
|
620
|
+
animate: { borderRadius: 0, borderTopLeftRadius: 12 },
|
|
621
|
+
})
|
|
622
|
+
if (!keys.includes('borderRadius') || !keys.includes('borderTopLeftRadius')) {
|
|
623
|
+
return { id, pass: false, detail: `mount prep returned keys [${keys.join(', ')}]` }
|
|
624
|
+
}
|
|
625
|
+
return { id, pass: true, detail: 'borderRadius + borderTopLeftRadius accepted by mount prep' }
|
|
626
|
+
} catch (e) {
|
|
627
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function checkL1RadiusAnimates(): Row {
|
|
632
|
+
// Style seeds 24 (REQ-API-011), animate to 0, settle EXACT at 0 — the demo's radius morph in
|
|
633
|
+
// deterministic form. Independence: the corner key animates to its own target alongside.
|
|
634
|
+
const id = 'L1: borderRadius animates 24→0, corner independent'
|
|
635
|
+
try {
|
|
636
|
+
prepareMotionViewMount({ animate: { borderRadius: 0 } }) // the wrapper gate (RED pre-L1)
|
|
637
|
+
const binding = checkDriver()
|
|
638
|
+
const controller = createMotionViewController(binding, {
|
|
639
|
+
animate: { borderRadius: 0, borderTopLeftRadius: 24 },
|
|
640
|
+
transition: SPRING,
|
|
641
|
+
style: { borderRadius: 24, borderTopLeftRadius: 4 },
|
|
642
|
+
})
|
|
643
|
+
const handle = controller.mount()
|
|
644
|
+
const start = binding.driver.committed(handle)
|
|
645
|
+
for (let i = 0; i < 900; i++) binding.driver.step(DT_MS)
|
|
646
|
+
const end = binding.driver.committed(handle)
|
|
647
|
+
if (start['borderRadius'] !== 24 || start['borderTopLeftRadius'] !== 4) {
|
|
648
|
+
return { id, pass: false, detail: `seeded ${JSON.stringify(start)}, want style values 24/4` }
|
|
649
|
+
}
|
|
650
|
+
if (end['borderRadius'] !== 0 || end['borderTopLeftRadius'] !== 24) {
|
|
651
|
+
return { id, pass: false, detail: `settled ${JSON.stringify(end)}, want 0/24` }
|
|
652
|
+
}
|
|
653
|
+
return { id, pass: true, detail: 'borderRadius 24→0 exact; corner 4→24 independently' }
|
|
654
|
+
} catch (e) {
|
|
655
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function checkL1ColorProjects(): Row {
|
|
660
|
+
// The pinned seam (packet §semantics 3): ONE driver progress prop on [0,100]; the projected
|
|
661
|
+
// rgba() string is projectColor(from, to, progress) — start byte-exact at the initial color,
|
|
662
|
+
// mid-flight self-consistent with the driver's live progress, settled byte-exact at the target.
|
|
663
|
+
const id = 'L1: backgroundColor projects by progress seam'
|
|
664
|
+
try {
|
|
665
|
+
const binding = checkDriver()
|
|
666
|
+
const controller = createMotionViewController(binding, {
|
|
667
|
+
initial: { backgroundColor: '#ff0000' },
|
|
668
|
+
animate: { backgroundColor: '#0000ff' },
|
|
669
|
+
transition: SPRING,
|
|
670
|
+
})
|
|
671
|
+
const handle = controller.mount()
|
|
672
|
+
const red = parseColor('#ff0000')
|
|
673
|
+
const blue = parseColor('#0000ff')
|
|
674
|
+
const atMount = controller.currentColor('backgroundColor')
|
|
675
|
+
if (atMount !== transformColor(red)) {
|
|
676
|
+
return { id, pass: false, detail: `mounted at ${atMount}, want ${transformColor(red)}` }
|
|
677
|
+
}
|
|
678
|
+
// Sample EARLY (step 5): the underdamped spring overshoots past 100 later in the flight
|
|
679
|
+
// (observed 107.45 at step 30 on device — packet Findings), which is legitimate seam behavior
|
|
680
|
+
// (mix-space extrapolation, output clamped); the mid-flight range assertion belongs BEFORE the
|
|
681
|
+
// first overshoot.
|
|
682
|
+
for (let i = 0; i < 5; i++) binding.driver.step(DT_MS)
|
|
683
|
+
const progress = binding.driver.committed(handle)['backgroundColor']
|
|
684
|
+
const mid = controller.currentColor('backgroundColor')
|
|
685
|
+
if (typeof progress !== 'number' || !(progress > 0 && progress < 100)) {
|
|
686
|
+
return { id, pass: false, detail: `progress ${String(progress)} at step 5, want (0,100)` }
|
|
687
|
+
}
|
|
688
|
+
if (mid !== projectColor(red, blue, progress)) {
|
|
689
|
+
return {
|
|
690
|
+
id,
|
|
691
|
+
pass: false,
|
|
692
|
+
detail: `mid ${mid} != projectColor at progress ${progress.toFixed(2)}`,
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (mid === transformColor(red) || mid === transformColor(blue)) {
|
|
696
|
+
return { id, pass: false, detail: `mid-flight color ${mid} sits at an endpoint` }
|
|
697
|
+
}
|
|
698
|
+
for (let i = 0; i < 900; i++) binding.driver.step(DT_MS)
|
|
699
|
+
const settled = controller.currentColor('backgroundColor')
|
|
700
|
+
if (settled !== transformColor(blue)) {
|
|
701
|
+
return { id, pass: false, detail: `settled at ${settled}, want ${transformColor(blue)}` }
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
id,
|
|
705
|
+
pass: true,
|
|
706
|
+
detail: `red → ${mid} (progress ${progress.toFixed(1)}) → blue, seam-consistent`,
|
|
707
|
+
}
|
|
708
|
+
} catch (e) {
|
|
709
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function checkL1ColorRetargetContinuity(): Row {
|
|
714
|
+
// Packet §semantics 5: a mid-flight retarget re-seeds from the CURRENT projected color (C0 — no
|
|
715
|
+
// snap at the handoff frame, per-channel within EPS_COLOR_CHANNEL) and converges to the new target.
|
|
716
|
+
const id = 'L1: color retarget continuity (C0)'
|
|
717
|
+
try {
|
|
718
|
+
const binding = checkDriver()
|
|
719
|
+
const controller = createMotionViewController(binding, {
|
|
720
|
+
initial: { backgroundColor: '#ff0000' },
|
|
721
|
+
animate: { backgroundColor: '#0000ff' },
|
|
722
|
+
transition: SPRING,
|
|
723
|
+
})
|
|
724
|
+
controller.mount()
|
|
725
|
+
for (let i = 0; i < 30; i++) binding.driver.step(DT_MS)
|
|
726
|
+
const before = parseColor(controller.currentColor('backgroundColor'))
|
|
727
|
+
controller.update({ animate: { backgroundColor: '#00ff00' } })
|
|
728
|
+
const after = parseColor(controller.currentColor('backgroundColor'))
|
|
729
|
+
const maxDelta = Math.max(
|
|
730
|
+
Math.abs(after.red - before.red),
|
|
731
|
+
Math.abs(after.green - before.green),
|
|
732
|
+
Math.abs(after.blue - before.blue),
|
|
733
|
+
Math.abs(after.alpha - before.alpha) * 255,
|
|
734
|
+
)
|
|
735
|
+
if (maxDelta > EPS_COLOR_CHANNEL) {
|
|
736
|
+
return {
|
|
737
|
+
id,
|
|
738
|
+
pass: false,
|
|
739
|
+
detail: `handoff snapped: max channel delta ${maxDelta.toFixed(3)}`,
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
for (let i = 0; i < 900; i++) binding.driver.step(DT_MS)
|
|
743
|
+
const settled = controller.currentColor('backgroundColor')
|
|
744
|
+
if (settled !== transformColor(parseColor('#00ff00'))) {
|
|
745
|
+
return { id, pass: false, detail: `settled at ${settled}, want rgba(0, 255, 0, 1)` }
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
id,
|
|
749
|
+
pass: true,
|
|
750
|
+
detail: `handoff delta ${maxDelta.toFixed(3)} ≤ ${EPS_COLOR_CHANNEL}, settled at green`,
|
|
751
|
+
}
|
|
752
|
+
} catch (e) {
|
|
753
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function checkL1TypedFailures(): Row {
|
|
758
|
+
// Packet §semantics 6: malformed AND incompatible-but-valid inputs hit TYPED failures at the
|
|
759
|
+
// component boundary — never raw TypeErrors, never a stale M2.2 scope message.
|
|
760
|
+
const id = 'L1: typed failures at the boundary'
|
|
761
|
+
const probes: string[] = []
|
|
762
|
+
function probe(name: string, run: () => void, want: (e: Error) => boolean): void {
|
|
763
|
+
try {
|
|
764
|
+
run()
|
|
765
|
+
probes.push(`${name}: did not throw`)
|
|
766
|
+
} catch (e) {
|
|
767
|
+
if (!(e instanceof Error)) probes.push(`${name}: threw non-Error ${typeof e}`)
|
|
768
|
+
else if (!want(e)) probes.push(`${name}: ${e.message.slice(0, 90)}`)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
const binding = checkDriver()
|
|
773
|
+
// A number for an rgba-typed key → CORE validation's typed rejection (observed in the RED run:
|
|
774
|
+
// the wrong-primitive gate already lives at InvalidTargetError altitude — packet Findings).
|
|
775
|
+
probe(
|
|
776
|
+
'backgroundColor:42',
|
|
777
|
+
() =>
|
|
778
|
+
createMotionViewController(binding, {
|
|
779
|
+
animate: { backgroundColor: 42 } as unknown as Target,
|
|
780
|
+
transition: SPRING,
|
|
781
|
+
}).mount(),
|
|
782
|
+
(e) => e instanceof InvalidTargetError && e.message.includes('backgroundColor'),
|
|
783
|
+
)
|
|
784
|
+
// An unparseable color string → typed parse error naming the key, from the L1 path.
|
|
785
|
+
probe(
|
|
786
|
+
"backgroundColor:'#zzz'",
|
|
787
|
+
() =>
|
|
788
|
+
createMotionViewController(binding, {
|
|
789
|
+
animate: { backgroundColor: '#zzz' },
|
|
790
|
+
transition: SPRING,
|
|
791
|
+
}).mount(),
|
|
792
|
+
(e) => e.message.includes('backgroundColor') && !e.message.includes('M2.2'),
|
|
793
|
+
)
|
|
794
|
+
// A string for a length key → the numeric gate's typed error, now owned by L1.
|
|
795
|
+
probe(
|
|
796
|
+
"borderRadius:'24px'",
|
|
797
|
+
() =>
|
|
798
|
+
createMotionViewController(binding, {
|
|
799
|
+
animate: { borderRadius: '24px' },
|
|
800
|
+
transition: SPRING,
|
|
801
|
+
}).mount(),
|
|
802
|
+
(e) => e.message.includes('borderRadius') && e.message.includes('L1'),
|
|
803
|
+
)
|
|
804
|
+
// R4 zero-delta retired the valid-but-unmapped class: width is registry-mapped and MUST
|
|
805
|
+
// mount clean (r12 major 7b8e1f34c2ad).
|
|
806
|
+
try {
|
|
807
|
+
prepareMotionViewMount({ animate: { width: 100 } })
|
|
808
|
+
} catch (e) {
|
|
809
|
+
probes.push(
|
|
810
|
+
`width:100 (R4-mapped): threw ${e instanceof Error ? e.message.slice(0, 80) : String(e)}`,
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
// Regression pin: validation ordering unchanged — unknown key still InvalidTargetError first.
|
|
814
|
+
probe(
|
|
815
|
+
'unknown key ordering',
|
|
816
|
+
() => prepareMotionViewMount({ animate: { definitelyNotAProperty: 1 } as unknown as Target }),
|
|
817
|
+
(e) => e instanceof InvalidTargetError,
|
|
818
|
+
)
|
|
819
|
+
if (probes.length > 0) return { id, pass: false, detail: probes.join(' | ') }
|
|
820
|
+
return {
|
|
821
|
+
id,
|
|
822
|
+
pass: true,
|
|
823
|
+
detail: 'four boundary probes hit typed failures; R4-mapped width mounts clean',
|
|
824
|
+
}
|
|
825
|
+
} catch (e) {
|
|
826
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function MotionViewChecksScreen() {
|
|
831
|
+
const rows: Row[] = [
|
|
832
|
+
// H2 first, deliberately: the iOS simulator has no CLI touch injection (PROOF.md), so a claim
|
|
833
|
+
// that needs scrolling to reach can never appear in a captured frame. The newest claim under
|
|
834
|
+
// proof leads; the ordering carries no semantics.
|
|
835
|
+
checkVisibleDiscretePairPublishesTarget(),
|
|
836
|
+
checkInvisibleTargetStepUnmoved(),
|
|
837
|
+
checkFractionalRepeatSnaps(),
|
|
838
|
+
checkDelayedFractionalRepeatSnaps(),
|
|
839
|
+
checkIntegerRepeatUnmoved(),
|
|
840
|
+
checkMountAnimates(),
|
|
841
|
+
checkInitialFalse(),
|
|
842
|
+
checkStyleSeedsStart(),
|
|
843
|
+
checkRetargetContinuity(),
|
|
844
|
+
checkUnlistedHoldAndNoOpUpdate(),
|
|
845
|
+
checkValidateFailLoud(),
|
|
846
|
+
checkWrapperValidationOrder(),
|
|
847
|
+
checkWrapperUpdateGuard(),
|
|
848
|
+
checkTeardownDeactivates(),
|
|
849
|
+
checkL1RadiusKeysMapped(),
|
|
850
|
+
checkL1RadiusAnimates(),
|
|
851
|
+
checkL1ColorProjects(),
|
|
852
|
+
checkL1ColorRetargetContinuity(),
|
|
853
|
+
checkL1TypedFailures(),
|
|
854
|
+
]
|
|
855
|
+
// The checks are done reading committed values — free their UI-runtime records.
|
|
856
|
+
disposeCheckDrivers()
|
|
857
|
+
const failing = rows.filter((row) => !row.pass)
|
|
858
|
+
const allPass = failing.length === 0
|
|
859
|
+
|
|
860
|
+
return (
|
|
861
|
+
<ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
|
|
862
|
+
<Text style={styles.title}>Motion.View checks — M2.2 + L1 + H2 + H3</Text>
|
|
863
|
+
<Text style={styles.subtitle}>
|
|
864
|
+
controller semantics vs the manual-stepping worklet driver
|
|
865
|
+
</Text>
|
|
866
|
+
<View style={[styles.banner, allPass ? styles.pass : styles.fail]}>
|
|
867
|
+
<Text style={styles.bannerText}>{allPass ? 'PASS' : 'FAIL'}</Text>
|
|
868
|
+
</View>
|
|
869
|
+
{/*
|
|
870
|
+
The tally and the failing SET, stated by the screen itself. The banner is binary and the
|
|
871
|
+
row list runs past the fold, so an aggregate claim ("N pass, and the failures are X and Y")
|
|
872
|
+
used to be readable only from a SCROLLED capture — which the iOS simulator can never produce,
|
|
873
|
+
having no CLI touch injection (PROOF.md). A capture that cannot evidence the claim made about
|
|
874
|
+
it is the defect the H2 round-3 review found; the screen answers it in the first frame
|
|
875
|
+
instead, on both platforms.
|
|
876
|
+
*/}
|
|
877
|
+
<Text style={styles.tally}>
|
|
878
|
+
{rows.length - failing.length} PASS / {failing.length} FAIL of {rows.length} rows
|
|
879
|
+
</Text>
|
|
880
|
+
{/*
|
|
881
|
+
Deliberately UNCLIPPED — no `numberOfLines`. A cap here would silently truncate the failing
|
|
882
|
+
list once the failure count grew, which is the same under-reporting the tally exists to
|
|
883
|
+
prevent (H2 round-4 review MINOR 3). The list is allowed to push the rows down instead.
|
|
884
|
+
*/}
|
|
885
|
+
<Text style={styles.tallyDetail}>
|
|
886
|
+
{allPass ? 'no failing rows' : `FAILING: ${failing.map((row) => row.id).join(' | ')}`}
|
|
887
|
+
</Text>
|
|
888
|
+
{rows.map((row) => (
|
|
889
|
+
<View key={row.id} style={styles.row}>
|
|
890
|
+
<Text style={[styles.verdict, row.pass ? styles.ok : styles.bad]}>
|
|
891
|
+
{row.pass ? 'PASS' : 'FAIL'}
|
|
892
|
+
</Text>
|
|
893
|
+
<View style={styles.rowBody}>
|
|
894
|
+
<Text style={styles.rowId}>{row.id}</Text>
|
|
895
|
+
<Text style={styles.rowDetail} numberOfLines={3}>
|
|
896
|
+
{row.detail}
|
|
897
|
+
</Text>
|
|
898
|
+
</View>
|
|
899
|
+
</View>
|
|
900
|
+
))}
|
|
901
|
+
</ScrollView>
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const styles = StyleSheet.create({
|
|
906
|
+
scroll: { flex: 1, backgroundColor: '#0b0b12' },
|
|
907
|
+
container: { paddingVertical: 72, paddingHorizontal: 20 },
|
|
908
|
+
title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
|
|
909
|
+
subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
|
|
910
|
+
banner: {
|
|
911
|
+
alignSelf: 'center',
|
|
912
|
+
paddingHorizontal: 36,
|
|
913
|
+
paddingVertical: 10,
|
|
914
|
+
borderRadius: 10,
|
|
915
|
+
marginTop: 18,
|
|
916
|
+
marginBottom: 10,
|
|
917
|
+
},
|
|
918
|
+
pass: { backgroundColor: '#1d7a3e' },
|
|
919
|
+
fail: { backgroundColor: '#a32633' },
|
|
920
|
+
bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
|
|
921
|
+
tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
|
|
922
|
+
tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
|
|
923
|
+
row: { flexDirection: 'row', marginTop: 10 },
|
|
924
|
+
verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
|
|
925
|
+
ok: { color: '#4ade80' },
|
|
926
|
+
bad: { color: '#f87171' },
|
|
927
|
+
rowBody: { flex: 1 },
|
|
928
|
+
rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
|
|
929
|
+
rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
|
|
930
|
+
})
|