@unrulysystems/native-motion 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,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
+ })