@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,822 @@
|
|
|
1
|
+
// Layout-binding checks. M2.5 rows (specs/M2.5-BUILD-PACKET.md §pinned semantics) drive the
|
|
2
|
+
// projection binding; L2 rows (specs/L2-BUILD-PACKET.md §semantics) drive the layoutId identity
|
|
3
|
+
// binding. All rows use FAKE rect events + a ManualClock/ManualScheduler graph — fully
|
|
4
|
+
// deterministic (the live legs are the FLIP and identity-swap smokes). A screenshot is the
|
|
5
|
+
// assertion; try/catch is evidence capture.
|
|
6
|
+
|
|
7
|
+
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
|
8
|
+
import {
|
|
9
|
+
EPSILON_POS,
|
|
10
|
+
EPSILON_SCALE,
|
|
11
|
+
ManualClock,
|
|
12
|
+
ManualScheduler,
|
|
13
|
+
createMotionGraph,
|
|
14
|
+
createProjectionSession,
|
|
15
|
+
toWindowSpaceRect,
|
|
16
|
+
type MotionGraph,
|
|
17
|
+
type Rect,
|
|
18
|
+
type Transform,
|
|
19
|
+
} from '@unrulysystems/native-motion-core'
|
|
20
|
+
import { createLayoutProjection, type LayoutProjection } from '../../native/motion/layoutProjection'
|
|
21
|
+
import {
|
|
22
|
+
createLayoutIdentityBinding,
|
|
23
|
+
type LayoutIdentityBinding,
|
|
24
|
+
} from '../../native/motion/layoutIdBinding'
|
|
25
|
+
import { createWorkletDriver, type WorkletDriverBinding } from '../../native/driver/workletDriver'
|
|
26
|
+
import {
|
|
27
|
+
buildCoreSpringChannel,
|
|
28
|
+
createNoFlashLayoutTransition,
|
|
29
|
+
} from '../../native/motion/layoutTransition'
|
|
30
|
+
import { resolveSpringGenerator } from '@unrulysystems/native-motion-core'
|
|
31
|
+
|
|
32
|
+
const DT = 16.67
|
|
33
|
+
|
|
34
|
+
interface Row {
|
|
35
|
+
readonly id: string
|
|
36
|
+
readonly pass: boolean
|
|
37
|
+
readonly detail: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Rig {
|
|
41
|
+
readonly graph: MotionGraph
|
|
42
|
+
readonly scheduler: ManualScheduler
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rig(): Rig {
|
|
46
|
+
const clock = new ManualClock()
|
|
47
|
+
const scheduler = new ManualScheduler(clock)
|
|
48
|
+
return { graph: createMotionGraph({ clock, scheduler }), scheduler }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function projection(r: Rig): LayoutProjection {
|
|
52
|
+
return createLayoutProjection({ graph: r.graph, token: 'box' })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const R = (x: number, y: number, width: number, height: number) => ({ x, y, width, height })
|
|
56
|
+
|
|
57
|
+
function identityish(p: LayoutProjection): boolean {
|
|
58
|
+
const t = p.session.transform()
|
|
59
|
+
return (
|
|
60
|
+
Math.abs(t.translateX) <= EPSILON_POS &&
|
|
61
|
+
Math.abs(t.translateY) <= EPSILON_POS &&
|
|
62
|
+
Math.abs(t.scaleX - 1) <= EPSILON_SCALE &&
|
|
63
|
+
Math.abs(t.scaleY - 1) <= EPSILON_SCALE
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkFlipInversion(): Row {
|
|
68
|
+
const id = 'FLIP inversion: old rect at t=0, identity at settle'
|
|
69
|
+
try {
|
|
70
|
+
const r = rig()
|
|
71
|
+
const p = projection(r)
|
|
72
|
+
p.onLayoutRect(R(0, 0, 64, 64)) // initial measure — no projection
|
|
73
|
+
p.onLayoutRect(R(200, 0, 64, 64)) // layout moved right 200
|
|
74
|
+
const t0 = p.session.transform()
|
|
75
|
+
if (Math.abs(t0.translateX + 200) > EPSILON_POS || Math.abs(t0.translateY) > EPSILON_POS) {
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
pass: false,
|
|
79
|
+
detail: `t0 translate (${t0.translateX.toFixed(1)}, ${t0.translateY.toFixed(1)}), want (−200, 0)`,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
r.scheduler.frames(300, DT)
|
|
83
|
+
if (!p.session.isSettled() || !identityish(p)) {
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
pass: false,
|
|
87
|
+
detail: `settle: settled=${String(p.session.isSettled())} transform=${JSON.stringify(p.session.transform())}`,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
pass: true,
|
|
93
|
+
detail: 'began at translateX −200 (the old spot), settled to identity',
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkScaleLegs(): Row {
|
|
101
|
+
const id = 'size change projects scaleX/scaleY to identity'
|
|
102
|
+
try {
|
|
103
|
+
const r = rig()
|
|
104
|
+
const p = projection(r)
|
|
105
|
+
p.onLayoutRect(R(0, 0, 64, 64))
|
|
106
|
+
p.onLayoutRect(R(0, 0, 128, 128)) // doubled — visual starts at half scale
|
|
107
|
+
const t0 = p.session.transform()
|
|
108
|
+
if (Math.abs(t0.scaleX - 0.5) > EPSILON_SCALE || Math.abs(t0.scaleY - 0.5) > EPSILON_SCALE) {
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
pass: false,
|
|
112
|
+
detail: `t0 scale (${t0.scaleX.toFixed(3)}, ${t0.scaleY.toFixed(3)}), want (0.5, 0.5)`,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
r.scheduler.frames(300, DT)
|
|
116
|
+
if (!identityish(p)) {
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
pass: false,
|
|
120
|
+
detail: `settled transform ${JSON.stringify(p.session.transform())}`,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { id, pass: true, detail: '64→128 began at scale 0.5, settled to identity' }
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function checkRetargetC1(): Row {
|
|
130
|
+
const id = 'mid-flight layout change retargets from the live rect'
|
|
131
|
+
try {
|
|
132
|
+
const r = rig()
|
|
133
|
+
const p = projection(r)
|
|
134
|
+
p.onLayoutRect(R(0, 0, 64, 64))
|
|
135
|
+
p.onLayoutRect(R(200, 0, 64, 64))
|
|
136
|
+
r.scheduler.frames(6, DT) // in flight
|
|
137
|
+
const liveBefore = p.session.visualRect()
|
|
138
|
+
if (liveBefore === null) return { id, pass: false, detail: 'no visual rect mid-flight' }
|
|
139
|
+
p.onLayoutRect(R(100, 40, 64, 64)) // second layout change mid-flight
|
|
140
|
+
const liveAfter = p.session.visualRect()
|
|
141
|
+
if (liveAfter === null) return { id, pass: false, detail: 'visual rect lost at retarget' }
|
|
142
|
+
if (
|
|
143
|
+
Math.abs(liveAfter.x - liveBefore.x) > EPSILON_POS ||
|
|
144
|
+
Math.abs(liveAfter.y - liveBefore.y) > EPSILON_POS
|
|
145
|
+
) {
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
pass: false,
|
|
149
|
+
detail: `visual rect snapped (${liveBefore.x.toFixed(1)},${liveBefore.y.toFixed(1)}) → (${liveAfter.x.toFixed(1)},${liveAfter.y.toFixed(1)})`,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
r.scheduler.frames(300, DT)
|
|
153
|
+
if (!p.session.isSettled() || !identityish(p)) {
|
|
154
|
+
return { id, pass: false, detail: 'did not settle to identity at the new layout' }
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
pass: true,
|
|
159
|
+
detail: `retargeted at live x=${liveBefore.x.toFixed(1)} with no snap, settled at the new layout`,
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function checkFailClosed(): Row {
|
|
167
|
+
const id = 'unmeasured fails closed: identity, unsettled'
|
|
168
|
+
try {
|
|
169
|
+
const r = rig()
|
|
170
|
+
const p = projection(r)
|
|
171
|
+
p.onLayoutRect(R(0, 0, 64, 64)) // ONLY the initial measure — no projection may start
|
|
172
|
+
if (!identityish(p)) {
|
|
173
|
+
return {
|
|
174
|
+
id,
|
|
175
|
+
pass: false,
|
|
176
|
+
detail: `transform not identity after a single measure: ${JSON.stringify(p.session.transform())}`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Direct session discipline: a null before/after begin is unmeasured — identity + unsettled.
|
|
180
|
+
const r2 = rig()
|
|
181
|
+
const s2 = createProjectionSession({ graph: r2.graph })
|
|
182
|
+
s2.begin('t2', null, R(10, 10, 64, 64))
|
|
183
|
+
const t2 = s2.transform()
|
|
184
|
+
const nullIdentity =
|
|
185
|
+
Math.abs(t2.translateX) <= EPSILON_POS && Math.abs(t2.scaleX - 1) <= EPSILON_SCALE
|
|
186
|
+
if (!nullIdentity || s2.measured() || s2.isSettled()) {
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
pass: false,
|
|
190
|
+
detail: `null-before begin: measured=${String(s2.measured())} settled=${String(s2.isSettled())} transform=${JSON.stringify(t2)}`,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
pass: true,
|
|
196
|
+
detail: 'single measure → no projection; null-before begin → identity, unmeasured, unsettled',
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkSettleDiscipline(): Row {
|
|
204
|
+
const id = 'settles within the ratified epsilons'
|
|
205
|
+
try {
|
|
206
|
+
const r = rig()
|
|
207
|
+
const p = projection(r)
|
|
208
|
+
p.onLayoutRect(R(0, 0, 64, 64))
|
|
209
|
+
p.onLayoutRect(R(200, 80, 64, 64))
|
|
210
|
+
let frames = 0
|
|
211
|
+
while (!p.session.isSettled() && frames < 1200) {
|
|
212
|
+
r.scheduler.frame(DT)
|
|
213
|
+
frames += 1
|
|
214
|
+
}
|
|
215
|
+
if (!p.session.isSettled()) return { id, pass: false, detail: 'never settled in 1200 frames' }
|
|
216
|
+
const rect = p.session.visualRect()
|
|
217
|
+
if (
|
|
218
|
+
rect === null ||
|
|
219
|
+
Math.abs(rect.x - 200) > EPSILON_POS ||
|
|
220
|
+
Math.abs(rect.y - 80) > EPSILON_POS
|
|
221
|
+
) {
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
pass: false,
|
|
225
|
+
detail: `settled rect ${JSON.stringify(rect)}, want (200, 80) ± ${EPSILON_POS}`,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
id,
|
|
230
|
+
pass: true,
|
|
231
|
+
detail: `settled in ${frames} frames at (200, 80) within ε=${EPSILON_POS}px, identity transform`,
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function checkMalformedRectGuard(): Row {
|
|
239
|
+
const id = 'malformed onLayout rects fail loud at the binding'
|
|
240
|
+
try {
|
|
241
|
+
const r = rig()
|
|
242
|
+
const p = projection(r)
|
|
243
|
+
let missingError: unknown = null
|
|
244
|
+
try {
|
|
245
|
+
p.onLayoutRect({ x: 0, y: 0, width: 64 }) // height missing
|
|
246
|
+
} catch (e) {
|
|
247
|
+
missingError = e
|
|
248
|
+
}
|
|
249
|
+
const missingOk = missingError instanceof Error && missingError.message.includes('height')
|
|
250
|
+
let badTypeError: unknown = null
|
|
251
|
+
try {
|
|
252
|
+
p.onLayoutRect({ x: 'zero', y: 0, width: 64, height: 64 })
|
|
253
|
+
} catch (e) {
|
|
254
|
+
badTypeError = e
|
|
255
|
+
}
|
|
256
|
+
const badTypeOk = badTypeError instanceof Error && badTypeError.message.includes("'x'")
|
|
257
|
+
if (!missingOk || !badTypeOk) {
|
|
258
|
+
return {
|
|
259
|
+
id,
|
|
260
|
+
pass: false,
|
|
261
|
+
detail: `missing→${missingError instanceof Error ? missingError.message.slice(0, 40) : 'no throw'}; badType→${badTypeError instanceof Error ? badTypeError.message.slice(0, 40) : 'no throw'}`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
id,
|
|
266
|
+
pass: true,
|
|
267
|
+
detail: 'missing height and non-numeric x both throw naming the field',
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ————— L2 rows (specs/L2-BUILD-PACKET.md §semantics): the layoutId identity binding —————
|
|
275
|
+
|
|
276
|
+
// The demo-visible geometry: A is a small card top-left, B a larger card elsewhere — the flight
|
|
277
|
+
// covers translation AND scale, so coincidence is a real two-axis assertion.
|
|
278
|
+
const RECT_A = toWindowSpaceRect({ x: 0, y: 0, width: 64, height: 64 })
|
|
279
|
+
const RECT_B = toWindowSpaceRect({ x: 200, y: 120, width: 128, height: 128 })
|
|
280
|
+
|
|
281
|
+
// L2 rows run over the DETERMINISTIC worklet driver (manual stepping — the same UI-side engine
|
|
282
|
+
// the live loop steps, REQ-DRIVER-011 discipline): the binding no longer owns a JS graph
|
|
283
|
+
// (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md §The binding after the port).
|
|
284
|
+
interface IdentityRig {
|
|
285
|
+
readonly driver: WorkletDriverBinding
|
|
286
|
+
readonly b: LayoutIdentityBinding
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Every rig created during a check run, disposed together after the rows are computed — the rows
|
|
290
|
+
// rebuild per render, and an undisposed manual driver strands its UI-runtime state per run
|
|
291
|
+
// (quiesce-before-unmount, review round 4 major 5).
|
|
292
|
+
const liveRigDrivers: WorkletDriverBinding[] = []
|
|
293
|
+
|
|
294
|
+
function identityRig(): IdentityRig {
|
|
295
|
+
const driver = createWorkletDriver({ manualStepping: true })
|
|
296
|
+
liveRigDrivers.push(driver)
|
|
297
|
+
return { driver, b: createLayoutIdentityBinding(driver) }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function disposeIdentityRigs(): void {
|
|
301
|
+
for (const driver of liveRigDrivers) driver.dispose()
|
|
302
|
+
liveRigDrivers.length = 0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function identityFrames(driver: WorkletDriverBinding, count: number): void {
|
|
306
|
+
for (let i = 0; i < count; i++) driver.driver.step(DT)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// A holder's current outputs, read back from the shared values the engine writes — the JS-read
|
|
310
|
+
// equivalent of the deleted per-frame view() (deterministic rows only; live screens bind styles).
|
|
311
|
+
interface HolderView {
|
|
312
|
+
readonly transform: Transform
|
|
313
|
+
readonly opacity: number
|
|
314
|
+
readonly zLifted: boolean
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function viewOf(b: LayoutIdentityBinding, id: string, token: string): HolderView {
|
|
318
|
+
const s = b.sharedViewFor(id, token)
|
|
319
|
+
return {
|
|
320
|
+
transform: {
|
|
321
|
+
translateX: s.translateX.value,
|
|
322
|
+
translateY: s.translateY.value,
|
|
323
|
+
scaleX: s.scaleX.value,
|
|
324
|
+
scaleY: s.scaleY.value,
|
|
325
|
+
},
|
|
326
|
+
opacity: s.opacity.value,
|
|
327
|
+
zLifted: s.zLifted.value === 1,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Compose a holder's transform over its own layout rect — the visual rect the substrate paints
|
|
332
|
+
// (top-left anchored, matching core's Transform law).
|
|
333
|
+
function applyTo(t: Transform, rect: Rect): Rect {
|
|
334
|
+
return {
|
|
335
|
+
x: rect.x + t.translateX,
|
|
336
|
+
y: rect.y + t.translateY,
|
|
337
|
+
width: rect.width * t.scaleX,
|
|
338
|
+
height: rect.height * t.scaleY,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function rectsCoincide(a: Rect, b: Rect): boolean {
|
|
343
|
+
return (
|
|
344
|
+
Math.abs(a.x - b.x) <= EPSILON_POS &&
|
|
345
|
+
Math.abs(a.y - b.y) <= EPSILON_POS &&
|
|
346
|
+
Math.abs(a.width - b.width) <= EPSILON_POS &&
|
|
347
|
+
Math.abs(a.height - b.height) <= EPSILON_POS
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A paired binding mid-flight: A sole → B lead, six frames in. The shared setup for L2 rows.
|
|
352
|
+
function pairedMidFlight(): IdentityRig {
|
|
353
|
+
const rig = identityRig()
|
|
354
|
+
rig.b.attach('card', 'A', RECT_A)
|
|
355
|
+
rig.b.attach('card', 'B', RECT_B)
|
|
356
|
+
identityFrames(rig.driver, 6)
|
|
357
|
+
return rig
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function checkIdentityRolesAndCoincidence(): Row {
|
|
361
|
+
const id = 'L2 roles: sole → lead flight; both holders coincide mid-flight'
|
|
362
|
+
try {
|
|
363
|
+
const { driver, b } = identityRig()
|
|
364
|
+
const roleA = b.attach('card', 'A', RECT_A)
|
|
365
|
+
if (roleA !== 'sole')
|
|
366
|
+
return { id, pass: false, detail: `first attach role '${roleA}', want 'sole'` }
|
|
367
|
+
const soleView = viewOf(b, 'card', 'A')
|
|
368
|
+
const soleIdentity =
|
|
369
|
+
Math.abs(soleView.transform.translateX) <= EPSILON_POS &&
|
|
370
|
+
Math.abs(soleView.transform.scaleX - 1) <= EPSILON_SCALE &&
|
|
371
|
+
soleView.opacity === 1
|
|
372
|
+
if (!soleIdentity) {
|
|
373
|
+
return {
|
|
374
|
+
id,
|
|
375
|
+
pass: false,
|
|
376
|
+
detail: `sole holder not at identity/opacity 1: ${JSON.stringify(soleView)}`,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const roleB = b.attach('card', 'B', RECT_B)
|
|
380
|
+
if (roleB !== 'lead')
|
|
381
|
+
return { id, pass: false, detail: `second attach role '${roleB}', want 'lead'` }
|
|
382
|
+
identityFrames(driver, 6)
|
|
383
|
+
if (b.isSettled('card'))
|
|
384
|
+
return { id, pass: false, detail: 'flight settled in 6 frames — no flight began' }
|
|
385
|
+
const visualA = applyTo(viewOf(b, 'card', 'A').transform, RECT_A)
|
|
386
|
+
const visualB = applyTo(viewOf(b, 'card', 'B').transform, RECT_B)
|
|
387
|
+
if (!rectsCoincide(visualA, visualB)) {
|
|
388
|
+
return {
|
|
389
|
+
id,
|
|
390
|
+
pass: false,
|
|
391
|
+
detail: `mid-flight visuals diverge: A ${JSON.stringify(visualA)} vs B ${JSON.stringify(visualB)}`,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
id,
|
|
396
|
+
pass: true,
|
|
397
|
+
detail: `sole/lead roles assigned; mid-flight both project onto one visual rect (x≈${visualA.x.toFixed(1)})`,
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {
|
|
400
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function checkIdentityCrossfade(): Row {
|
|
405
|
+
const id = 'L2 crossfade: conserved mid-flight, exact endpoints at settle'
|
|
406
|
+
try {
|
|
407
|
+
const { driver, b } = pairedMidFlight()
|
|
408
|
+
const midA = viewOf(b, 'card', 'A').opacity
|
|
409
|
+
const midB = viewOf(b, 'card', 'B').opacity
|
|
410
|
+
if (midB <= 0 || midB >= 1 || Math.abs(midA + midB - 1) > 0.001) {
|
|
411
|
+
return {
|
|
412
|
+
id,
|
|
413
|
+
pass: false,
|
|
414
|
+
detail: `mid-flight opacities A=${midA.toFixed(3)} B=${midB.toFixed(3)}, want conserved sum 1 with B in (0,1)`,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
identityFrames(driver, 600)
|
|
418
|
+
const endA = viewOf(b, 'card', 'A').opacity
|
|
419
|
+
const endB = viewOf(b, 'card', 'B').opacity
|
|
420
|
+
if (!b.isSettled('card') || endB !== 1 || endA !== 0) {
|
|
421
|
+
return {
|
|
422
|
+
id,
|
|
423
|
+
pass: false,
|
|
424
|
+
detail: `settle: settled=${String(b.isSettled('card'))} A=${endA} B=${endB}, want exact 0/1`,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
id,
|
|
429
|
+
pass: true,
|
|
430
|
+
detail: `mid-flight A=${midA.toFixed(2)}+B=${midB.toFixed(2)}=1; settled to exact A=0, B=1`,
|
|
431
|
+
}
|
|
432
|
+
} catch (e) {
|
|
433
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function checkIdentityZLift(): Row {
|
|
438
|
+
const id = 'L2 z-lift: lead lifted during flight only'
|
|
439
|
+
try {
|
|
440
|
+
const { driver, b } = pairedMidFlight()
|
|
441
|
+
const midA = viewOf(b, 'card', 'A').zLifted
|
|
442
|
+
const midB = viewOf(b, 'card', 'B').zLifted
|
|
443
|
+
if (!midB || midA) {
|
|
444
|
+
return {
|
|
445
|
+
id,
|
|
446
|
+
pass: false,
|
|
447
|
+
detail: `mid-flight zLifted A=${String(midA)} B=${String(midB)}, want lead-only lift`,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
identityFrames(driver, 600)
|
|
451
|
+
if (viewOf(b, 'card', 'A').zLifted || viewOf(b, 'card', 'B').zLifted) {
|
|
452
|
+
return { id, pass: false, detail: 'still z-lifted after settle — natural order must return' }
|
|
453
|
+
}
|
|
454
|
+
return { id, pass: true, detail: 'lead z-lifted mid-flight, everyone natural at settle' }
|
|
455
|
+
} catch (e) {
|
|
456
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function checkIdentityExitResume(): Row {
|
|
461
|
+
const id = 'L2 exit/resume: lead detach reverses the flight; releasable at settle'
|
|
462
|
+
try {
|
|
463
|
+
const { driver, b } = pairedMidFlight()
|
|
464
|
+
identityFrames(driver, 600) // settle the A→B flight first
|
|
465
|
+
b.detach('card', 'B') // the lead leaves — reverse flight to A begins; B rides exiting
|
|
466
|
+
identityFrames(driver, 6)
|
|
467
|
+
if (b.releasable('card', 'B')) {
|
|
468
|
+
return {
|
|
469
|
+
id,
|
|
470
|
+
pass: false,
|
|
471
|
+
detail: 'exiting holder releasable mid-flight — must wait for settle',
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const midB = viewOf(b, 'card', 'B').opacity
|
|
475
|
+
if (midB <= 0 || midB >= 1) {
|
|
476
|
+
return {
|
|
477
|
+
id,
|
|
478
|
+
pass: false,
|
|
479
|
+
detail: `exiting holder opacity ${midB.toFixed(3)} mid-flight, want fading in (0,1)`,
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (!viewOf(b, 'card', 'A').zLifted) {
|
|
483
|
+
return { id, pass: false, detail: 'resumed lead not z-lifted during the reverse flight' }
|
|
484
|
+
}
|
|
485
|
+
identityFrames(driver, 600)
|
|
486
|
+
const endA = viewOf(b, 'card', 'A')
|
|
487
|
+
const resumedIdentity =
|
|
488
|
+
Math.abs(endA.transform.translateX) <= EPSILON_POS &&
|
|
489
|
+
Math.abs(endA.transform.scaleX - 1) <= EPSILON_SCALE
|
|
490
|
+
if (!b.releasable('card', 'B') || endA.opacity !== 1 || !resumedIdentity) {
|
|
491
|
+
return {
|
|
492
|
+
id,
|
|
493
|
+
pass: false,
|
|
494
|
+
detail: `settle: releasable=${String(b.releasable('card', 'B'))} A=${JSON.stringify(endA)}, want releasable + A identity/opacity 1`,
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
b.detach('card', 'B') // final removal of the exiting holder — must be accepted
|
|
498
|
+
return {
|
|
499
|
+
id,
|
|
500
|
+
pass: true,
|
|
501
|
+
detail:
|
|
502
|
+
'reverse flight ran (B fading, A lifted), B releasable at settle, final detach accepted',
|
|
503
|
+
}
|
|
504
|
+
} catch (e) {
|
|
505
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function checkIdentityFailLoud(): Row {
|
|
510
|
+
const id = 'L2 fail-loud: malformed rects and non-holder ops throw typed errors'
|
|
511
|
+
try {
|
|
512
|
+
const { b } = identityRig()
|
|
513
|
+
b.attach('card', 'A', RECT_A)
|
|
514
|
+
let missing: unknown = null
|
|
515
|
+
try {
|
|
516
|
+
toWindowSpaceRect({ x: 0, y: 0, width: 64 } as Rect) // height missing
|
|
517
|
+
} catch (e) {
|
|
518
|
+
missing = e
|
|
519
|
+
}
|
|
520
|
+
const missingOk = missing instanceof Error && missing.message.includes('height')
|
|
521
|
+
// A non-holder lifecycle op must throw naming id + token — the registry error crosses back
|
|
522
|
+
// synchronously through the one-lane lifecycle crossing.
|
|
523
|
+
let nonHolder: unknown = null
|
|
524
|
+
try {
|
|
525
|
+
b.commitLayout('card', 'GHOST', RECT_B)
|
|
526
|
+
} catch (e) {
|
|
527
|
+
nonHolder = e
|
|
528
|
+
}
|
|
529
|
+
const nonHolderOk =
|
|
530
|
+
nonHolder instanceof Error &&
|
|
531
|
+
nonHolder.message.includes('card') &&
|
|
532
|
+
nonHolder.message.includes('GHOST')
|
|
533
|
+
let doubleAttach: unknown = null
|
|
534
|
+
try {
|
|
535
|
+
b.attach('card', 'A', RECT_A)
|
|
536
|
+
} catch (e) {
|
|
537
|
+
doubleAttach = e
|
|
538
|
+
}
|
|
539
|
+
const doubleOk = doubleAttach instanceof Error && doubleAttach.message.includes('already holds')
|
|
540
|
+
if (!missingOk || !nonHolderOk || !doubleOk) {
|
|
541
|
+
return {
|
|
542
|
+
id,
|
|
543
|
+
pass: false,
|
|
544
|
+
detail: `missing→${missing instanceof Error ? 'named' : 'no throw'}; nonHolder→${nonHolderOk ? 'named' : 'weak'}; double→${doubleOk ? 'named' : 'weak'}`,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
id,
|
|
549
|
+
pass: true,
|
|
550
|
+
detail:
|
|
551
|
+
'missing height, non-holder commit, and double attach all throw naming id + operation',
|
|
552
|
+
}
|
|
553
|
+
} catch (e) {
|
|
554
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function checkIdentityReleaseCoupling(): Row {
|
|
559
|
+
const id =
|
|
560
|
+
'L2 presence coupling: releasable gates the exit; already-releasable coupling fires once at registration'
|
|
561
|
+
try {
|
|
562
|
+
// The frame-carried safeToRemove delivery is asynchronous by design (the UI frame loop posts
|
|
563
|
+
// it via scheduleOnRN), so this synchronous row asserts the gate it rides — releasable stays
|
|
564
|
+
// false mid-flight and flips at settle — plus the two SYNCHRONOUS coupling legs below. The
|
|
565
|
+
// exactly-once delivery across trailing frames is pinned off-device by
|
|
566
|
+
// uiLayoutEngine.test.ts and live on-device by the identity-swap smoke's release counter.
|
|
567
|
+
const { driver, b } = pairedMidFlight()
|
|
568
|
+
identityFrames(driver, 600)
|
|
569
|
+
b.detach('card', 'B') // exit flight begins
|
|
570
|
+
const firings: true[] = []
|
|
571
|
+
const firedCount = (): number => firings.length
|
|
572
|
+
const uncouple = b.coupleRelease('card', 'B', () => {
|
|
573
|
+
firings.push(true)
|
|
574
|
+
})
|
|
575
|
+
identityFrames(driver, 6)
|
|
576
|
+
if (firedCount() !== 0 || b.releasable('card', 'B')) {
|
|
577
|
+
return {
|
|
578
|
+
id,
|
|
579
|
+
pass: false,
|
|
580
|
+
detail: `mid-flight: fired=${firedCount()} releasable=${String(b.releasable('card', 'B'))} — must wait for settle`,
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
uncouple() // unmount-before-settle path: an uncoupled callback must never fire
|
|
584
|
+
identityFrames(driver, 600)
|
|
585
|
+
if (!b.releasable('card', 'B')) {
|
|
586
|
+
return { id, pass: false, detail: 'exit flight settled but releasable stayed false' }
|
|
587
|
+
}
|
|
588
|
+
if (firedCount() !== 0) {
|
|
589
|
+
return {
|
|
590
|
+
id,
|
|
591
|
+
pass: false,
|
|
592
|
+
detail: `uncoupled callback fired ${firedCount()}× — uncouple must remove it`,
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Immediate-release frontier (review cycle 3 major 7): a coupling registered on an
|
|
596
|
+
// ALREADY-releasable holder must fire synchronously at registration — a stored coupling
|
|
597
|
+
// strands the callback when nothing schedules another frame.
|
|
598
|
+
const rig2 = identityRig()
|
|
599
|
+
rig2.b.attach('card', 'A', RECT_A)
|
|
600
|
+
rig2.b.attach('card', 'B', null) // mounting path — B never measures
|
|
601
|
+
rig2.b.detach('card', 'B') // reverted pairing: B is immediately releasable
|
|
602
|
+
const immediate: true[] = []
|
|
603
|
+
rig2.b.coupleRelease('card', 'B', () => {
|
|
604
|
+
immediate.push(true)
|
|
605
|
+
})
|
|
606
|
+
if (immediate.length !== 1) {
|
|
607
|
+
return {
|
|
608
|
+
id,
|
|
609
|
+
pass: false,
|
|
610
|
+
detail: `coupling on an already-releasable holder fired ${immediate.length}× at registration, want exactly 1`,
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
id,
|
|
615
|
+
pass: true,
|
|
616
|
+
detail:
|
|
617
|
+
'releasable false mid-flight, true at settle; uncoupled callback silent; already-releasable coupling fires once at registration',
|
|
618
|
+
}
|
|
619
|
+
} catch (e) {
|
|
620
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ————— L3 rows (specs/L3-BUILD-PACKET.md §impl): the no-flash layout transition —————
|
|
625
|
+
|
|
626
|
+
// The demo geometry for the transition rows (Reanimated LayoutAnimationValues shape).
|
|
627
|
+
const L3_VALUES = {
|
|
628
|
+
currentOriginX: 8,
|
|
629
|
+
currentOriginY: 8,
|
|
630
|
+
currentWidth: 64,
|
|
631
|
+
currentHeight: 64,
|
|
632
|
+
targetOriginX: 208,
|
|
633
|
+
targetOriginY: 128,
|
|
634
|
+
targetWidth: 128,
|
|
635
|
+
targetHeight: 128,
|
|
636
|
+
windowWidth: 390,
|
|
637
|
+
windowHeight: 844,
|
|
638
|
+
currentGlobalOriginX: 8,
|
|
639
|
+
currentGlobalOriginY: 8,
|
|
640
|
+
targetGlobalOriginX: 208,
|
|
641
|
+
targetGlobalOriginY: 128,
|
|
642
|
+
currentBorderRadius: 0,
|
|
643
|
+
targetBorderRadius: 0,
|
|
644
|
+
boundsToWindow: 0,
|
|
645
|
+
} as never
|
|
646
|
+
|
|
647
|
+
function checkNoFlashInitialValues(): Row {
|
|
648
|
+
const id = 'L3 initialValues: first post-commit frame paints the OLD rect'
|
|
649
|
+
try {
|
|
650
|
+
const transition = createNoFlashLayoutTransition()
|
|
651
|
+
const result = transition!(L3_VALUES)
|
|
652
|
+
const iv = result.initialValues as Record<string, number>
|
|
653
|
+
const ok = iv.originX === 8 && iv.originY === 8 && iv.width === 64 && iv.height === 64
|
|
654
|
+
if (!ok) {
|
|
655
|
+
return {
|
|
656
|
+
id,
|
|
657
|
+
pass: false,
|
|
658
|
+
detail: `initialValues ${JSON.stringify(result.initialValues)}, want the CURRENT rect (8,8,64,64)`,
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const anims = result.animations as Record<string, unknown>
|
|
662
|
+
const keys = ['originX', 'originY', 'width', 'height'].filter((k) => anims[k] !== undefined)
|
|
663
|
+
if (keys.length !== 4) {
|
|
664
|
+
return { id, pass: false, detail: `animations cover ${keys.length}/4 layout channels` }
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
id,
|
|
668
|
+
pass: true,
|
|
669
|
+
detail: 'initialValues = current rect exactly; all 4 channels animated',
|
|
670
|
+
}
|
|
671
|
+
} catch (e) {
|
|
672
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function checkCoreTrajectoryParity(): Row {
|
|
677
|
+
const id = 'L3 trajectory: channel animation reproduces the core spring exactly'
|
|
678
|
+
try {
|
|
679
|
+
const spring = { stiffness: 550, damping: 45 }
|
|
680
|
+
const channel = buildCoreSpringChannel(208, spring)
|
|
681
|
+
// The host passes UNDEFINED (not null) when no previous animation exists — the first commit
|
|
682
|
+
// on-device crashed on a null-only guard (packet Findings); this row pins the real shape.
|
|
683
|
+
channel.onStart(channel, 8, 0, undefined)
|
|
684
|
+
// The reference: core's generator sampled at the same accumulated elapsed times.
|
|
685
|
+
const reference = resolveSpringGenerator(208, spring)({ from: 8, velocity: 0 })
|
|
686
|
+
let now = 0
|
|
687
|
+
for (let i = 1; i <= 60; i += 1) {
|
|
688
|
+
now += 16.67
|
|
689
|
+
channel.onFrame(channel, now)
|
|
690
|
+
const expected = reference.sample(now).value
|
|
691
|
+
if (channel.current !== expected) {
|
|
692
|
+
return {
|
|
693
|
+
id,
|
|
694
|
+
pass: false,
|
|
695
|
+
detail: `step ${i}: channel ${channel.current} vs core ${expected} — trajectories must be byte-equal (M2.1 parity law)`,
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const finished = channel.onFrame(channel, (now += 16.67))
|
|
700
|
+
if (!finished && !reference.sample(now).done) {
|
|
701
|
+
return {
|
|
702
|
+
id,
|
|
703
|
+
pass: true,
|
|
704
|
+
detail: 'byte-equal through 60 frames (both still settling at this profile)',
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return { id, pass: true, detail: 'byte-equal trajectory through 60 frames; done flags agree' }
|
|
708
|
+
} catch (e) {
|
|
709
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function checkRetargetCarry(): Row {
|
|
714
|
+
const id = 'L3 re-layout mid-flight: C1 velocity carries through retargetSpring'
|
|
715
|
+
try {
|
|
716
|
+
const spring = { stiffness: 550, damping: 45 }
|
|
717
|
+
const first = buildCoreSpringChannel(208, spring)
|
|
718
|
+
first.onStart(first, 8, 0, null)
|
|
719
|
+
let now = 0
|
|
720
|
+
for (let i = 1; i <= 6; i += 1) {
|
|
721
|
+
now += 16.67
|
|
722
|
+
first.onFrame(first, now)
|
|
723
|
+
}
|
|
724
|
+
const liveValue = first.current
|
|
725
|
+
// A new layout commit mid-flight: the next channel receives the live previous animation.
|
|
726
|
+
const second = buildCoreSpringChannel(120, spring)
|
|
727
|
+
second.onStart(second, liveValue, now, first)
|
|
728
|
+
now += 16.67
|
|
729
|
+
second.onFrame(second, now)
|
|
730
|
+
// C1: the first post-retarget step continues the motion (a rest-seeded spring from the same
|
|
731
|
+
// value would move a DIFFERENT amount in one frame than the velocity-carrying one).
|
|
732
|
+
const restSeeded = resolveSpringGenerator(120, spring)({ from: liveValue, velocity: 0 })
|
|
733
|
+
const restStep = restSeeded.sample(16.67).value
|
|
734
|
+
if (second.current === restStep) {
|
|
735
|
+
return {
|
|
736
|
+
id,
|
|
737
|
+
pass: false,
|
|
738
|
+
detail: `post-retarget step equals the rest-seeded spring (${restStep.toFixed(4)}) — velocity was zeroed, C1 broken`,
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
id,
|
|
743
|
+
pass: true,
|
|
744
|
+
detail: `velocity carried: post-retarget ${second.current.toFixed(2)} ≠ rest-seeded ${restStep.toFixed(2)}`,
|
|
745
|
+
}
|
|
746
|
+
} catch (e) {
|
|
747
|
+
return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function LayoutChecksScreen() {
|
|
752
|
+
const rows: Row[] = [
|
|
753
|
+
checkFlipInversion(),
|
|
754
|
+
checkScaleLegs(),
|
|
755
|
+
checkRetargetC1(),
|
|
756
|
+
checkFailClosed(),
|
|
757
|
+
checkSettleDiscipline(),
|
|
758
|
+
checkMalformedRectGuard(),
|
|
759
|
+
checkIdentityRolesAndCoincidence(),
|
|
760
|
+
checkIdentityCrossfade(),
|
|
761
|
+
checkIdentityZLift(),
|
|
762
|
+
checkIdentityExitResume(),
|
|
763
|
+
checkIdentityFailLoud(),
|
|
764
|
+
checkIdentityReleaseCoupling(),
|
|
765
|
+
checkNoFlashInitialValues(),
|
|
766
|
+
checkCoreTrajectoryParity(),
|
|
767
|
+
checkRetargetCarry(),
|
|
768
|
+
]
|
|
769
|
+
// The L2 rows' rigs are done reading shared values — free their UI-runtime records.
|
|
770
|
+
disposeIdentityRigs()
|
|
771
|
+
const allPass = rows.every((row) => row.pass)
|
|
772
|
+
|
|
773
|
+
return (
|
|
774
|
+
<ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
|
|
775
|
+
<Text style={styles.title}>Layout checks — M2.5 + L2 + L3</Text>
|
|
776
|
+
<Text style={styles.subtitle}>
|
|
777
|
+
projection + layoutId identity + no-flash transition vs fake rects + a manual graph
|
|
778
|
+
</Text>
|
|
779
|
+
<View style={[styles.banner, allPass ? styles.pass : styles.fail]}>
|
|
780
|
+
<Text style={styles.bannerText}>{allPass ? 'PASS' : 'FAIL'}</Text>
|
|
781
|
+
</View>
|
|
782
|
+
{rows.map((row) => (
|
|
783
|
+
<View key={row.id} style={styles.row}>
|
|
784
|
+
<Text style={[styles.verdict, row.pass ? styles.ok : styles.bad]}>
|
|
785
|
+
{row.pass ? 'PASS' : 'FAIL'}
|
|
786
|
+
</Text>
|
|
787
|
+
<View style={styles.rowBody}>
|
|
788
|
+
<Text style={styles.rowId}>{row.id}</Text>
|
|
789
|
+
<Text style={styles.rowDetail} numberOfLines={3}>
|
|
790
|
+
{row.detail}
|
|
791
|
+
</Text>
|
|
792
|
+
</View>
|
|
793
|
+
</View>
|
|
794
|
+
))}
|
|
795
|
+
</ScrollView>
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const styles = StyleSheet.create({
|
|
800
|
+
scroll: { flex: 1, backgroundColor: '#0b0b12' },
|
|
801
|
+
container: { paddingVertical: 72, paddingHorizontal: 20 },
|
|
802
|
+
title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
|
|
803
|
+
subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
|
|
804
|
+
banner: {
|
|
805
|
+
alignSelf: 'center',
|
|
806
|
+
paddingHorizontal: 36,
|
|
807
|
+
paddingVertical: 10,
|
|
808
|
+
borderRadius: 10,
|
|
809
|
+
marginTop: 18,
|
|
810
|
+
marginBottom: 10,
|
|
811
|
+
},
|
|
812
|
+
pass: { backgroundColor: '#1d7a3e' },
|
|
813
|
+
fail: { backgroundColor: '#a32633' },
|
|
814
|
+
bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
|
|
815
|
+
row: { flexDirection: 'row', marginTop: 10 },
|
|
816
|
+
verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
|
|
817
|
+
ok: { color: '#4ade80' },
|
|
818
|
+
bad: { color: '#f87171' },
|
|
819
|
+
rowBody: { flex: 1 },
|
|
820
|
+
rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
|
|
821
|
+
rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
|
|
822
|
+
})
|