@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,1010 @@
1
+ // The choreography gallery — ENGINE-VERIFICATION, not a consumer example
2
+ // (specs/DOGFOOD-MIGRATION-BUILD-PACKET.md M3 split). This is the former native examples
3
+ // gallery (M2.6b) kept wholesale as the engine's device-proof choreography vehicle: it carries
4
+ // the L4 App Store card machine verdict (`L4PORT_APPSTORE` release-edge lines) and the
5
+ // BRIEF-ratified lifecycle-soak leg (seven-swap navigation, bounded driverStates, empty crash
6
+ // buffer). It hand-wires bindings and the substrate BY ROLE — engine verification is exempt
7
+ // from the consumer-isolation ban only by living in this package (REQ-EXHARNESS-010). The
8
+ // consumer documentation-by-example gallery lives in the app, authored on the public
9
+ // native-motion surface only; examples the public surface cannot express yet are BLOCKED there
10
+ // and tracked as packet FLAGs, never hand-rolled.
11
+ //
12
+ // Mirrors the web gallery (packages/native-motion-web/e2e/fixtures/examples.tsx)
13
+ // example-for-example so cross-engine behavior is eyeballable side by side. ONE example renders
14
+ // at a time (paged, not scrolled): iOS simulator screenshots are captured without UI
15
+ // automation, so every screenshot must self-describe — the header names the example, the
16
+ // status line carries the live evidence.
17
+ //
18
+ // AUTO-RUN drives each example's timeline programmatically then advances to the next card. iOS
19
+ // starts it hands-free after 1.5 s (no CLI touch injection — the M2.4 precedent); Android is
20
+ // driven by real adb taps/swipes, with AUTO-RUN available as a fallback.
21
+ //
22
+ // Known, ratified asymmetry surfaced here deliberately: the native MotionView spike maps exactly
23
+ // x/y/scale/rotate/opacity (M2.2 §semantics 6), while the web PASS table also animates colors,
24
+ // radii and layout props — so the native morph card uses scale + rotate and SAYS so on screen.
25
+ import { useEffect, useRef, useState } from 'react'
26
+ import { Platform, Pressable, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native'
27
+ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
28
+ import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
29
+ import {
30
+ ManualClock,
31
+ ManualScheduler,
32
+ correctRadius,
33
+ counterScale,
34
+ createMotionGraph,
35
+ type ElementHandle,
36
+ } from '@unrulysystems/native-motion-core'
37
+ import { uiDriverStateCount, useWorkletDriver } from '../../native/driver/workletDriver'
38
+ import { AnimatePresence } from '../../native/motion/AnimatePresence'
39
+ import {
40
+ createLayoutIdentityBinding,
41
+ type LayoutIdentityBinding,
42
+ type LayoutIdentityMeasureHandle,
43
+ } from '../../native/motion/layoutIdBinding'
44
+ import {
45
+ createGestureBinding,
46
+ routePanEnd,
47
+ routePanFinalize,
48
+ type GestureBinding,
49
+ } from '../../native/motion/gestureBinding'
50
+ import { createLayoutProjection, type LayoutProjection } from '../../native/motion/layoutProjection'
51
+ import { MotionView } from '../../native/motion/MotionView'
52
+ import { schedulerFrameDelta } from '../harnessMetrics'
53
+
54
+ // The house spring of the proof suite — the same constants the web gallery and the Chromium
55
+ // trajectory gate use, so cross-engine motion is directly comparable by eye.
56
+ const SPRING = { type: 'spring', stiffness: 550, damping: 45 } as const
57
+ // Softer demo springs where a ~1 s flight makes screenshots/video legible (M2.3/M2.5 precedent).
58
+ const DEMO_SPRING = { type: 'spring', stiffness: 60, damping: 14 } as const
59
+
60
+ const EXAMPLES = [
61
+ 'spring-toggle',
62
+ 'fade',
63
+ 'style-morph',
64
+ 'drag-snap',
65
+ 'presence',
66
+ 'flip-swap',
67
+ 'interrupt',
68
+ 'appstore-card',
69
+ ] as const
70
+ type ExampleId = (typeof EXAMPLES)[number]
71
+
72
+ interface ExampleProps {
73
+ // Auto-run: the example drives its own timeline once, then reports completion.
74
+ readonly auto: boolean
75
+ readonly onAutoDone: () => void
76
+ }
77
+
78
+ // Fire a timeline once when auto-run engages; the LAST step should call onAutoDone.
79
+ function useAutoTimeline(
80
+ auto: boolean,
81
+ steps: readonly { readonly at: number; readonly run: () => void }[],
82
+ ): void {
83
+ const fired = useRef(false)
84
+ useEffect(() => {
85
+ if (!auto || fired.current) return
86
+ fired.current = true
87
+ const timers = steps.map((step) => setTimeout(step.run, step.at))
88
+ return () => {
89
+ for (const timer of timers) clearTimeout(timer)
90
+ }
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [auto])
93
+ }
94
+
95
+ function requireLayoutIdentityMeasureHandle(
96
+ ref: { readonly current: LayoutIdentityMeasureHandle | null },
97
+ label: string,
98
+ ): LayoutIdentityMeasureHandle {
99
+ const handle = ref.current
100
+ if (handle === null) {
101
+ throw new Error(`layout identity '${label}': measureInWindow handle unavailable on layout.`)
102
+ }
103
+ return handle
104
+ }
105
+
106
+ // 1. Spring toggle — x 0 ↔ 240 through the worklet driver (tap TOGGLE, or auto).
107
+ function SpringToggleExample({ auto, onAutoDone }: ExampleProps) {
108
+ const [on, setOn] = useState(false)
109
+ const [settles, setSettles] = useState(0)
110
+ useAutoTimeline(auto, [
111
+ { at: 200, run: () => setOn(true) },
112
+ { at: 1200, run: () => setOn(false) },
113
+ { at: 2400, run: onAutoDone },
114
+ ])
115
+ return (
116
+ <View style={styles.exampleBody}>
117
+ <Pressable
118
+ style={styles.button}
119
+ accessibilityLabel="spring-toggle-button"
120
+ onPress={() => setOn((v) => !v)}
121
+ >
122
+ <Text style={styles.buttonText}>TOGGLE → {on ? 0 : 240}</Text>
123
+ </Pressable>
124
+ <View style={styles.stage}>
125
+ <MotionView
126
+ animate={{ x: on ? 240 : 0 }}
127
+ transition={SPRING}
128
+ onAnimationComplete={() => setSettles((n) => n + 1)}
129
+ style={[styles.box, { backgroundColor: '#7c3aed' }]}
130
+ />
131
+ </View>
132
+ <Text style={styles.status}>
133
+ target: {on ? 240 : 0} · settles: {settles}
134
+ </Text>
135
+ </View>
136
+ )
137
+ }
138
+
139
+ // 2. Fade — opacity 1 ↔ 0.25 (same target shape as the web card; native runs the JS→worklet
140
+ // value path, web runs WAAPI — the visual result must be indistinguishable).
141
+ function FadeExample({ auto, onAutoDone }: ExampleProps) {
142
+ const [on, setOn] = useState(false)
143
+ const [settles, setSettles] = useState(0)
144
+ useAutoTimeline(auto, [
145
+ { at: 200, run: () => setOn(true) },
146
+ { at: 1200, run: () => setOn(false) },
147
+ { at: 2400, run: onAutoDone },
148
+ ])
149
+ return (
150
+ <View style={styles.exampleBody}>
151
+ <Pressable style={styles.button} onPress={() => setOn((v) => !v)}>
152
+ <Text style={styles.buttonText}>FADE → {on ? 1 : 0.25}</Text>
153
+ </Pressable>
154
+ <View style={styles.stage}>
155
+ <MotionView
156
+ animate={{ opacity: on ? 0.25 : 1 }}
157
+ transition={SPRING}
158
+ onAnimationComplete={() => setSettles((n) => n + 1)}
159
+ style={[styles.box, { backgroundColor: '#0ea5e9' }]}
160
+ />
161
+ </View>
162
+ <Text style={styles.status}>
163
+ target opacity: {on ? 0.25 : 1} · settles: {settles}
164
+ </Text>
165
+ </View>
166
+ )
167
+ }
168
+
169
+ // 3. Style morph — scale + rotate + borderRadius + backgroundColor through ONE target: L1
170
+ // (specs/L1-BUILD-PACKET.md) closing the M2.6b cross-engine surface delta. THIS card is the
171
+ // rung's RED anchor — it crashed the release app on borderRadius under the M2.2 spike guard
172
+ // (bug-bash record) and now matches the web card's full morph.
173
+ function StyleMorphExample({ auto, onAutoDone }: ExampleProps) {
174
+ const [on, setOn] = useState(false)
175
+ const [settles, setSettles] = useState(0)
176
+ useAutoTimeline(auto, [
177
+ { at: 200, run: () => setOn(true) },
178
+ { at: 1400, run: () => setOn(false) },
179
+ { at: 2800, run: onAutoDone },
180
+ ])
181
+ return (
182
+ <View style={styles.exampleBody}>
183
+ <Pressable style={styles.button} onPress={() => setOn((v) => !v)}>
184
+ <Text style={styles.buttonText}>MORPH</Text>
185
+ </Pressable>
186
+ <View style={styles.stage}>
187
+ <MotionView
188
+ animate={{
189
+ scale: on ? 1.2 : 1,
190
+ rotate: on ? 45 : 0,
191
+ borderRadius: on ? 40 : 8,
192
+ backgroundColor: on ? '#f59e0b' : '#7c3aed',
193
+ }}
194
+ transition={SPRING}
195
+ onAnimationComplete={() => setSettles((n) => n + 1)}
196
+ style={[styles.box, { backgroundColor: '#7c3aed' }]}
197
+ />
198
+ </View>
199
+ <Text style={styles.status}>
200
+ scale: {on ? 1.2 : 1} · rotate: {on ? 45 : 0} · radius: {on ? 40 : 8} · settles: {settles}
201
+ </Text>
202
+ <Text style={styles.note}>
203
+ full morph incl. radius + color — the L1 rung (was the M2.6b crash)
204
+ </Text>
205
+ </View>
206
+ )
207
+ }
208
+
209
+ // 4. Drag with snap — the M2.4 binding as an EXAMPLE: constraints 0..240 elastic 0.4, snap
210
+ // [0, 240]. Android: real touch. Auto-run: the recorded stream replay (the M2.4 iOS leg).
211
+ function DragSnapExample({ auto, onAutoDone }: ExampleProps) {
212
+ const [label, setLabel] = useState({ value: 0, state: 'idle' })
213
+ const shared = useSharedValue(0)
214
+
215
+ const rigRef = useRef<{ binding: GestureBinding; scheduler: ManualScheduler } | null>(null)
216
+ if (rigRef.current === null) {
217
+ const clock = new ManualClock()
218
+ const scheduler = new ManualScheduler(clock)
219
+ const graph = createMotionGraph({ clock, scheduler })
220
+ rigRef.current = {
221
+ binding: createGestureBinding({
222
+ graph,
223
+ clock,
224
+ initial: 0,
225
+ constraints: { min: 0, max: 240, elastic: 0.4 },
226
+ snap: { points: [0, 240] },
227
+ }),
228
+ scheduler,
229
+ }
230
+ }
231
+ const { binding, scheduler } = rigRef.current
232
+
233
+ const stepperRef = useRef<{ running: boolean; raf: number; last: number; frames: number }>({
234
+ running: false,
235
+ raf: 0,
236
+ last: 0,
237
+ frames: 0,
238
+ })
239
+ const refreshLabel = (): void => {
240
+ setLabel({ value: binding.session.value(), state: binding.session.state() })
241
+ }
242
+ const startStepper = (): void => {
243
+ const stepper = stepperRef.current
244
+ if (stepper.running) return
245
+ stepper.running = true
246
+ stepper.last = 0
247
+ const loop = (now: number): void => {
248
+ const dt = schedulerFrameDelta(stepper.last, now)
249
+ stepper.last = now
250
+ if (dt > 0) scheduler.frame(dt)
251
+ shared.value = binding.session.value()
252
+ stepper.frames += 1
253
+ if (stepper.frames % 6 === 0) refreshLabel()
254
+ if (binding.session.isSettled()) {
255
+ stepper.running = false
256
+ refreshLabel()
257
+ return
258
+ }
259
+ stepper.raf = requestAnimationFrame(loop)
260
+ }
261
+ stepper.raf = requestAnimationFrame(loop)
262
+ }
263
+ useEffect(() => {
264
+ return () => cancelAnimationFrame(stepperRef.current.raf)
265
+ }, [])
266
+
267
+ const pan = Gesture.Pan()
268
+ .runOnJS(true)
269
+ .onStart((event) => {
270
+ startStepper()
271
+ binding.onStart(event)
272
+ })
273
+ .onUpdate((event) => {
274
+ binding.onUpdate(event)
275
+ shared.value = binding.session.value()
276
+ })
277
+ .onEnd((event, success) => {
278
+ routePanEnd(binding, event, success)
279
+ startStepper()
280
+ })
281
+ .onFinalize((_event, success) => {
282
+ routePanFinalize(binding, success)
283
+ startStepper()
284
+ })
285
+
286
+ // Auto-run: the M2.4 recorded stream — sweep to 200 then release flung at +900 px/s → snap 240.
287
+ useAutoTimeline(auto, [
288
+ {
289
+ at: 300,
290
+ run: () => {
291
+ startStepper()
292
+ binding.onStart({ translationX: 0, velocityX: 0 })
293
+ let step = 0
294
+ const feed = setInterval(() => {
295
+ step += 1
296
+ binding.onUpdate({ translationX: (200 * step) / 24, velocityX: 600 })
297
+ shared.value = binding.session.value()
298
+ if (step >= 24) {
299
+ clearInterval(feed)
300
+ binding.onEnd({ translationX: 200, velocityX: 900 })
301
+ startStepper()
302
+ }
303
+ }, 24)
304
+ },
305
+ },
306
+ { at: 2600, run: onAutoDone },
307
+ ])
308
+
309
+ const boxStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shared.value }] }))
310
+ return (
311
+ <View style={styles.exampleBody}>
312
+ <GestureDetector gesture={pan}>
313
+ <View style={styles.track} collapsable={false}>
314
+ <View style={[styles.snapMarker, { left: 0 }]} />
315
+ <View style={[styles.snapMarker, { left: 240 }]} />
316
+ <Animated.View style={[styles.box, { backgroundColor: '#f59e0b' }, boxStyle]} />
317
+ </View>
318
+ </GestureDetector>
319
+ <Text style={styles.status}>
320
+ value: {label.value.toFixed(1)} · state: {label.state}
321
+ </Text>
322
+ <Text style={styles.note}>
323
+ {auto ? 'auto: replayed stream (M2.4 iOS leg)' : 'drag the box — snap [0, 240]'}
324
+ </Text>
325
+ </View>
326
+ )
327
+ }
328
+
329
+ // 5. Presence — enter from initial, exit runs before unmount, removal exactly at settle.
330
+ function PresenceExample({ auto, onAutoDone }: ExampleProps) {
331
+ const [nextId, setNextId] = useState(1)
332
+ const [items, setItems] = useState<readonly number[]>([])
333
+ const [exits, setExits] = useState(0)
334
+ const add = (): void => {
335
+ setItems((list) => (list.length >= 3 ? list : [...list, nextId]))
336
+ setNextId((n) => n + 1)
337
+ }
338
+ const remove = (): void => setItems((list) => list.slice(0, -1))
339
+ useAutoTimeline(auto, [
340
+ { at: 200, run: add },
341
+ { at: 900, run: add },
342
+ { at: 1800, run: remove },
343
+ { at: 3200, run: remove },
344
+ { at: 4800, run: onAutoDone },
345
+ ])
346
+ return (
347
+ <View style={styles.exampleBody}>
348
+ <View style={styles.buttonRow}>
349
+ <Pressable style={styles.button} onPress={add}>
350
+ <Text style={styles.buttonText}>ADD</Text>
351
+ </Pressable>
352
+ <Pressable style={styles.button} onPress={remove}>
353
+ <Text style={styles.buttonText}>REMOVE</Text>
354
+ </Pressable>
355
+ </View>
356
+ <View style={styles.presenceRow}>
357
+ <AnimatePresence onExitComplete={() => setExits((n) => n + 1)}>
358
+ {items.map((id) => (
359
+ <MotionView
360
+ key={String(id)}
361
+ initial={{ opacity: 0, y: -16, x: 0 }}
362
+ animate={{ opacity: 1, y: 0, x: 0 }}
363
+ exit={{ opacity: 0, x: 80 }}
364
+ transition={DEMO_SPRING}
365
+ style={[styles.itemBox, { backgroundColor: '#14b8a6' }]}
366
+ >
367
+ <Text style={styles.itemText}>#{id}</Text>
368
+ </MotionView>
369
+ ))}
370
+ </AnimatePresence>
371
+ </View>
372
+ <Text style={styles.status}>
373
+ items: {items.length} · exits completed: {exits}
374
+ </Text>
375
+ <Text style={styles.note}>demo spring 60/14 — exits visible, removal exactly at settle</Text>
376
+ </View>
377
+ )
378
+ }
379
+
380
+ // 6. FLIP swap — the M2.5 projection as an EXAMPLE: the box's LAYOUT alternates (alignSelf +
381
+ // width) and the projection springs between layouts instead of teleporting.
382
+ function FlipSwapExample({ auto, onAutoDone }: ExampleProps) {
383
+ const [flipped, setFlipped] = useState(false)
384
+ const [label, setLabel] = useState({ state: 'idle', settles: 0 })
385
+ const tx = useSharedValue(0)
386
+ const ty = useSharedValue(0)
387
+ const sx = useSharedValue(1)
388
+ const sy = useSharedValue(1)
389
+
390
+ const rigRef = useRef<{ projection: LayoutProjection; scheduler: ManualScheduler } | null>(null)
391
+ if (rigRef.current === null) {
392
+ const clock = new ManualClock()
393
+ const scheduler = new ManualScheduler(clock)
394
+ const graph = createMotionGraph({ clock, scheduler })
395
+ rigRef.current = {
396
+ projection: createLayoutProjection({
397
+ graph,
398
+ token: 'examples-flip',
399
+ spring: { stiffness: 120, damping: 16 },
400
+ }),
401
+ scheduler,
402
+ }
403
+ }
404
+ const { projection, scheduler } = rigRef.current
405
+ const settlesRef = useRef(0)
406
+
407
+ const stepperRef = useRef<{ running: boolean; raf: number; last: number; frames: number }>({
408
+ running: false,
409
+ raf: 0,
410
+ last: 0,
411
+ frames: 0,
412
+ })
413
+ const applyTransform = (): void => {
414
+ const t = projection.session.transform()
415
+ tx.value = t.translateX
416
+ ty.value = t.translateY
417
+ sx.value = t.scaleX
418
+ sy.value = t.scaleY
419
+ }
420
+ const refreshLabel = (): void => {
421
+ setLabel({ state: projection.session.state(), settles: settlesRef.current })
422
+ }
423
+ const startStepper = (): void => {
424
+ const stepper = stepperRef.current
425
+ if (stepper.running) return
426
+ stepper.running = true
427
+ stepper.last = 0
428
+ const loop = (now: number): void => {
429
+ const dt = schedulerFrameDelta(stepper.last, now)
430
+ stepper.last = now
431
+ if (dt > 0) scheduler.frame(dt)
432
+ applyTransform()
433
+ stepper.frames += 1
434
+ if (stepper.frames % 6 === 0) refreshLabel()
435
+ if (projection.session.isSettled()) {
436
+ stepper.running = false
437
+ settlesRef.current += 1
438
+ applyTransform()
439
+ refreshLabel()
440
+ return
441
+ }
442
+ stepper.raf = requestAnimationFrame(loop)
443
+ }
444
+ stepper.raf = requestAnimationFrame(loop)
445
+ }
446
+ useEffect(() => {
447
+ return () => cancelAnimationFrame(stepperRef.current.raf)
448
+ }, [])
449
+
450
+ const onBoxLayout = (event: LayoutChangeEvent): void => {
451
+ projection.onLayoutRect(event.nativeEvent.layout)
452
+ if (!projection.session.isSettled()) startStepper()
453
+ }
454
+ useAutoTimeline(auto, [
455
+ { at: 300, run: () => setFlipped(true) },
456
+ { at: 1800, run: () => setFlipped(false) },
457
+ { at: 3400, run: onAutoDone },
458
+ ])
459
+
460
+ const boxStyle = useAnimatedStyle(() => ({
461
+ transform: [
462
+ { translateX: tx.value },
463
+ { translateY: ty.value },
464
+ { scaleX: sx.value },
465
+ { scaleY: sy.value },
466
+ ],
467
+ }))
468
+ return (
469
+ <View style={styles.exampleBody}>
470
+ <Pressable style={styles.button} onPress={() => setFlipped((f) => !f)}>
471
+ <Text style={styles.buttonText}>SWAP LAYOUT</Text>
472
+ </Pressable>
473
+ <View style={styles.arena}>
474
+ <Animated.View
475
+ onLayout={onBoxLayout}
476
+ style={[
477
+ styles.box,
478
+ { backgroundColor: '#58c47a', transformOrigin: 'top left' },
479
+ flipped ? styles.flipEnd : styles.flipHome,
480
+ boxStyle,
481
+ ]}
482
+ />
483
+ </View>
484
+ <Text style={styles.status}>
485
+ layout: {flipped ? 'end/128w' : 'start/64w'} · state: {label.state} · settles:{' '}
486
+ {label.settles}
487
+ </Text>
488
+ <Text style={styles.note}>transformOrigin top-left — core Transform anchoring</Text>
489
+ </View>
490
+ )
491
+ }
492
+
493
+ // 7. Interrupt — every tap retargets, INCLUDING mid-flight: the spring seeds from its live
494
+ // state (no jump). Auto-run taps at 140 ms — mid-flight for 550/45.
495
+ function InterruptExample({ auto, onAutoDone }: ExampleProps) {
496
+ const [on, setOn] = useState(false)
497
+ const [settles, setSettles] = useState(0)
498
+ useAutoTimeline(auto, [
499
+ { at: 300, run: () => setOn(true) },
500
+ { at: 440, run: () => setOn(false) }, // 140 ms later — mid-flight retarget
501
+ { at: 2200, run: onAutoDone },
502
+ ])
503
+ return (
504
+ <View style={styles.exampleBody}>
505
+ <Pressable style={styles.button} onPress={() => setOn((v) => !v)}>
506
+ <Text style={styles.buttonText}>RETARGET → {on ? 0 : 240}</Text>
507
+ </Pressable>
508
+ <View style={styles.stage}>
509
+ <MotionView
510
+ animate={{ x: on ? 240 : 0 }}
511
+ transition={SPRING}
512
+ onAnimationComplete={() => setSettles((n) => n + 1)}
513
+ style={[styles.box, { backgroundColor: '#8b5cf6' }]}
514
+ />
515
+ </View>
516
+ <Text style={styles.status}>
517
+ target: {on ? 240 : 0} · settles: {settles}
518
+ </Text>
519
+ <Text style={styles.note}>tap twice fast — the second flight continues from the live x</Text>
520
+ </View>
521
+ )
522
+ }
523
+
524
+ const TITLES: Record<ExampleId, string> = {
525
+ 'spring-toggle': 'Spring toggle — x: 0 ↔ 240',
526
+ fade: 'Fade — opacity 1 ↔ 0.25',
527
+ 'style-morph': 'Style morph — scale + rotate + radius + color',
528
+ 'drag-snap': 'Drag — snap [0, 240]',
529
+ presence: 'Presence — enter / exit',
530
+ 'flip-swap': 'FLIP — layout swap',
531
+ interrupt: 'Interrupt — retarget mid-flight',
532
+ 'appstore-card': 'App Store card — layoutId expand / collapse (L4)',
533
+ }
534
+
535
+ // 8. App Store card (L4, specs/SPEC-LAYOUT.md §native) — the golden-set choreography over
536
+ // L1+L2+L3: the L2 identity registry crossfades the list card into a top-anchored hero overlay
537
+ // in the SAME tree (no reparenting; the neighbor never moves); a stiffer core radius channel
538
+ // (draft {900, 60}) completes ahead of the rect; the scrim/close-X/body copy ride the flight's
539
+ // own crossfade scalar (X enters the second half, body the final 40% — draft thresholds);
540
+ // children ride UNDISTORTED via core's counterScale — its first device exercise. Symmetric,
541
+ // interruptible close through the registry's exit/resume reverse flight.
542
+ // The L4 card's machine verdict (packet Acceptance item 4): one machine-readable line per RELEASE
543
+ // edge — lifecycle-rate JS, never per-frame — cumulative counters plus the all-released predicate
544
+ // (every open eventually delivered its releasable→safeToRemove edge). RN Release drops plain
545
+ // console.log, so the verdict routes through globalThis.nativeLoggingHook (the M2 posture); the
546
+ // logcat/syslog scrape reads the LAST line after a run.
547
+ const L4PORT_LOG_PREFIX = 'L4PORT_APPSTORE '
548
+ const L4PORT_INFO_LOG_LEVEL = 1
549
+
550
+ interface L4PortLoggingGlobal {
551
+ nativeLoggingHook?: (message: string, level: number) => void
552
+ }
553
+
554
+ function logCardVerdict(counts: { readonly swaps: number; readonly releases: number }): void {
555
+ const message = `${L4PORT_LOG_PREFIX}${JSON.stringify({
556
+ swaps: counts.swaps,
557
+ releases: counts.releases,
558
+ allReleased: counts.swaps === counts.releases,
559
+ // The disposal observable (round 4 major 5): live UI-runtime driver records. The gallery
560
+ // mounts ONE example at a time and useWorkletDriver disposes at unmount, so this stays
561
+ // bounded (1 here) across page changes and auto-mode remounts — a leak grows it per remount.
562
+ driverStates: uiDriverStateCount(),
563
+ })}`
564
+ const nativeLoggingHook = (globalThis as L4PortLoggingGlobal).nativeLoggingHook
565
+ if (nativeLoggingHook !== undefined) {
566
+ nativeLoggingHook(message, L4PORT_INFO_LOG_LEVEL)
567
+ return
568
+ }
569
+ console.log(message)
570
+ }
571
+
572
+ function AppStoreCardExample({ auto, onAutoDone }: ExampleProps) {
573
+ const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed')
574
+ const [label, setLabel] = useState({ swaps: 0, releases: 0, radius: 24 })
575
+
576
+ const phaseRef = useRef<'closed' | 'open' | 'closing'>('closed')
577
+ const thumbMeasureRef = useRef<View | null>(null)
578
+ const heroMeasureRef = useRef<View | null>(null)
579
+ const thumbAttachedRef = useRef(false)
580
+ const countsRef = useRef({ swaps: 0, releases: 0 })
581
+
582
+ // The UI-thread engine (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md): the driver's frame loop
583
+ // steps the identity flight AND the radius element on the UI runtime; JS work is lifecycle-edge
584
+ // only (open/close/release + label refreshes). The house 550/45 — the golden flights run
585
+ // ≈200 ms, this profile's range (draft).
586
+ const driverBinding = useWorkletDriver({
587
+ layoutSpring: { stiffness: 550, damping: 45 },
588
+ onAllSettled: () => refreshLabel(),
589
+ })
590
+ const bindingRef = useRef<LayoutIdentityBinding | null>(null)
591
+ // The radius rides a driver ELEMENT (the M2.1 command path) instead of a JS graph value: the
592
+ // stiffer draft channel {900, 60} completes ahead of the rect (contract item 3), retargets
593
+ // carry live velocity on the interrupted close.
594
+ const radiusHandleRef = useRef<ElementHandle | null>(null)
595
+ if (bindingRef.current === null) {
596
+ bindingRef.current = createLayoutIdentityBinding(driverBinding)
597
+ radiusHandleRef.current = driverBinding.driver.register({ radius: 24 })
598
+ }
599
+ const binding = bindingRef.current
600
+ const radiusHandle = radiusHandleRef.current as ElementHandle
601
+ const radiusShared = driverBinding.sharedFor(radiusHandle, 'radius')
602
+
603
+ // Holder outputs, written by the UI frame loop; styles below derive children/scrim/body from
604
+ // them INSIDE the style worklets — zero per-frame JS.
605
+ const thumbShared = binding.sharedViewFor('appstore', 'thumb')
606
+ const heroShared = binding.sharedViewFor('appstore', 'hero')
607
+
608
+ const refreshLabel = (): void => {
609
+ setLabel({
610
+ swaps: countsRef.current.swaps,
611
+ releases: countsRef.current.releases,
612
+ radius: Math.round(radiusShared.value),
613
+ })
614
+ }
615
+
616
+ const retargetRadius = (to: number): void => {
617
+ driverBinding.driver.command(radiusHandle, {
618
+ kind: 'retarget',
619
+ targets: { radius: to },
620
+ transition: { type: 'spring', stiffness: 900, damping: 60 },
621
+ })
622
+ }
623
+
624
+ const openCard = (): void => {
625
+ if (phaseRef.current !== 'closed') return
626
+ phaseRef.current = 'open'
627
+ countsRef.current.swaps += 1
628
+ setPhase('open')
629
+ retargetRadius(0)
630
+ refreshLabel()
631
+ // The hero mounts and attaches on its first layout (the L2 pending-pairing path).
632
+ }
633
+ const closeCard = (): void => {
634
+ if (phaseRef.current !== 'open') return
635
+ phaseRef.current = 'closing'
636
+ setPhase('closing')
637
+ retargetRadius(24)
638
+ binding.detach('appstore', 'hero')
639
+ binding.coupleRelease('appstore', 'hero', () => {
640
+ binding.detach('appstore', 'hero')
641
+ phaseRef.current = 'closed'
642
+ countsRef.current.releases += 1
643
+ setPhase('closed')
644
+ refreshLabel()
645
+ logCardVerdict(countsRef.current)
646
+ })
647
+ refreshLabel()
648
+ }
649
+
650
+ useAutoTimeline(auto, [
651
+ { at: 300, run: openCard },
652
+ { at: 1500, run: closeCard },
653
+ // The interrupted close: reopen, then close mid-flight (~120 ms in — inside the ~200 ms
654
+ // open flight), the golden symmetric-interruptibility contract.
655
+ { at: 2700, run: openCard },
656
+ { at: 2820, run: closeCard },
657
+ { at: 4300, run: onAutoDone },
658
+ ])
659
+
660
+ const onThumbLayout = (_event: LayoutChangeEvent): void => {
661
+ const thumbMeasure = requireLayoutIdentityMeasureHandle(thumbMeasureRef, 'appstore/thumb')
662
+ if (!thumbAttachedRef.current) {
663
+ binding.attach('appstore', 'thumb', thumbMeasure)
664
+ thumbAttachedRef.current = true
665
+ return
666
+ }
667
+ binding.commitLayout('appstore', 'thumb', thumbMeasure)
668
+ }
669
+ const heroAttachedRef = useRef(false)
670
+ const onHeroLayout = (_event: LayoutChangeEvent): void => {
671
+ const heroMeasure = requireLayoutIdentityMeasureHandle(heroMeasureRef, 'appstore/hero')
672
+ if (!heroAttachedRef.current) {
673
+ // Mount → attach through the host handle; the binding enters unmeasured and commits the
674
+ // callback's window-space rect, so the flight departs from the thumb's comparable frame.
675
+ binding.attach('appstore', 'hero', heroMeasure)
676
+ heroAttachedRef.current = true
677
+ return
678
+ }
679
+ binding.commitLayout('appstore', 'hero', heroMeasure)
680
+ }
681
+ // The hero unmounts at release; its next mount is a fresh attach.
682
+ useEffect(() => {
683
+ if (phase === 'closed') heroAttachedRef.current = false
684
+ }, [phase])
685
+
686
+ const thumbStyle = useAnimatedStyle(() => ({
687
+ transform: [
688
+ { translateX: thumbShared.translateX.value },
689
+ { translateY: thumbShared.translateY.value },
690
+ { scaleX: thumbShared.scaleX.value },
691
+ { scaleY: thumbShared.scaleY.value },
692
+ ],
693
+ opacity: thumbShared.opacity.value,
694
+ }))
695
+ // Children ride undistorted (REQ-LAYOUT-013) — core's counterScale, computed in the style
696
+ // worklet from the flight's live scales (the dist function is worklet-tagged for exactly this).
697
+ const thumbKidStyle = useAnimatedStyle(() => {
698
+ const kid = counterScale({
699
+ translateX: thumbShared.translateX.value,
700
+ translateY: thumbShared.translateY.value,
701
+ scaleX: thumbShared.scaleX.value,
702
+ scaleY: thumbShared.scaleY.value,
703
+ })
704
+ return { transform: [{ scaleX: kid.scaleX }, { scaleY: kid.scaleY }] }
705
+ })
706
+ const heroStyle = useAnimatedStyle(() => {
707
+ // The radius channel corrected per axis so the PAINTED radius matches the driven value under
708
+ // the flight's anisotropic scale (core correctRadius).
709
+ const corrected = correctRadius(
710
+ radiusShared.value,
711
+ heroShared.scaleX.value,
712
+ heroShared.scaleY.value,
713
+ )
714
+ return {
715
+ transform: [
716
+ { translateX: heroShared.translateX.value },
717
+ { translateY: heroShared.translateY.value },
718
+ { scaleX: heroShared.scaleX.value },
719
+ { scaleY: heroShared.scaleY.value },
720
+ ],
721
+ opacity: heroShared.opacity.value,
722
+ borderRadius: (corrected.x + corrected.y) / 2,
723
+ }
724
+ })
725
+ const heroKidStyle = useAnimatedStyle(() => {
726
+ const kid = counterScale({
727
+ translateX: heroShared.translateX.value,
728
+ translateY: heroShared.translateY.value,
729
+ scaleX: heroShared.scaleX.value,
730
+ scaleY: heroShared.scaleY.value,
731
+ })
732
+ return { transform: [{ scaleX: kid.scaleX }, { scaleY: kid.scaleY }] }
733
+ })
734
+ // Scrim / close-X / body ride the flight's own crossfade scalar via the hero's opacity (draft
735
+ // thresholds: scrim linear, X second half, body final 40%) — derived in the worklet.
736
+ const scrimStyle = useAnimatedStyle(() => ({ opacity: heroShared.opacity.value * 0.92 }))
737
+ const closeXStyle = useAnimatedStyle(() => ({
738
+ opacity: Math.max(0, (heroShared.opacity.value - 0.5) / 0.5),
739
+ }))
740
+ const bodyStyle = useAnimatedStyle(() => {
741
+ const bodyProgress = Math.max(0, (heroShared.opacity.value - 0.6) / 0.4)
742
+ // The drafted body pair: opacity 0 → 1 WITH translateY 12 → 0 over the final 40%.
743
+ return { opacity: bodyProgress, transform: [{ translateY: 12 * (1 - bodyProgress) }] }
744
+ })
745
+ return (
746
+ <View style={styles.exampleBody}>
747
+ <View style={appstore.stage}>
748
+ <View style={appstore.list}>
749
+ <View style={appstore.neighbor}>
750
+ <Text style={appstore.cardTitle}>Vikings</Text>
751
+ <Text style={appstore.cardSub}>GAME OF THE DAY — static neighbor</Text>
752
+ </View>
753
+ <Pressable accessibilityLabel="appstore-thumb" onPress={openCard}>
754
+ <Animated.View
755
+ ref={thumbMeasureRef}
756
+ onLayout={onThumbLayout}
757
+ style={[appstore.thumb, { transformOrigin: 'top left' }, thumbStyle]}
758
+ >
759
+ <Animated.View style={[{ transformOrigin: 'top left' }, thumbKidStyle]}>
760
+ <Text style={appstore.cardTitle}>Dragonic</Text>
761
+ <Text style={appstore.cardSub}>Legends return — tap to expand</Text>
762
+ <View style={appstore.footerRow}>
763
+ <Text style={appstore.cardSub}>Dragonic Legends</Text>
764
+ <View style={appstore.getPill}>
765
+ <Text style={appstore.getPillText}>GET</Text>
766
+ </View>
767
+ </View>
768
+ </Animated.View>
769
+ </Animated.View>
770
+ </Pressable>
771
+ </View>
772
+ {phase !== 'closed' && (
773
+ <Animated.View pointerEvents="none" style={[appstore.scrim, scrimStyle]} />
774
+ )}
775
+ {phase !== 'closed' && (
776
+ <Pressable
777
+ accessibilityLabel="appstore-hero"
778
+ onPress={closeCard}
779
+ style={appstore.heroSlot}
780
+ >
781
+ <Animated.View
782
+ ref={heroMeasureRef}
783
+ onLayout={onHeroLayout}
784
+ style={[appstore.hero, { transformOrigin: 'top left' }, heroStyle]}
785
+ >
786
+ <Animated.View style={[{ transformOrigin: 'top left' }, heroKidStyle]}>
787
+ <Text style={appstore.cardTitle}>Dragonic</Text>
788
+ <Animated.Text style={[appstore.closeX, closeXStyle]}>×</Animated.Text>
789
+ <Animated.View style={bodyStyle}>
790
+ <Text style={appstore.cardSub}>
791
+ Body copy staggers into the final 40% — the detail overlay lives in the same
792
+ tree as the list. Tap to close.
793
+ </Text>
794
+ </Animated.View>
795
+ <View style={appstore.footerRow}>
796
+ <Text style={appstore.cardSub}>Dragonic Legends</Text>
797
+ <View style={appstore.getPill}>
798
+ <Text style={appstore.getPillText}>GET</Text>
799
+ </View>
800
+ </View>
801
+ </Animated.View>
802
+ </Animated.View>
803
+ </Pressable>
804
+ )}
805
+ </View>
806
+ <Text style={styles.status}>
807
+ {phase} · radius {label.radius} · swaps {label.swaps} · releases {label.releases}
808
+ </Text>
809
+ </View>
810
+ )
811
+ }
812
+
813
+ const appstore = StyleSheet.create({
814
+ stage: {
815
+ alignSelf: 'stretch',
816
+ height: 330,
817
+ borderRadius: 12,
818
+ backgroundColor: '#16161f',
819
+ overflow: 'hidden',
820
+ },
821
+ list: { padding: 12, gap: 12 },
822
+ neighbor: { backgroundColor: '#3a4a5e', borderRadius: 24, padding: 12, height: 72 },
823
+ thumb: { backgroundColor: '#0ea5e9', borderRadius: 24, padding: 12, height: 120 },
824
+ heroSlot: { position: 'absolute', top: 0, left: 0, right: 0 },
825
+ hero: { backgroundColor: '#0ea5e9', padding: 12, height: 200 },
826
+ scrim: {
827
+ position: 'absolute',
828
+ top: 0,
829
+ left: 0,
830
+ right: 0,
831
+ bottom: 0,
832
+ backgroundColor: '#ffffff',
833
+ },
834
+ cardTitle: { color: '#fff', fontSize: 16, fontWeight: '800' },
835
+ cardSub: { color: '#e0f2fe', fontSize: 11, marginTop: 2 },
836
+ footerRow: {
837
+ flexDirection: 'row',
838
+ justifyContent: 'space-between',
839
+ alignItems: 'center',
840
+ marginTop: 10,
841
+ },
842
+ getPill: {
843
+ backgroundColor: '#fff',
844
+ borderRadius: 12,
845
+ paddingHorizontal: 14,
846
+ paddingVertical: 4,
847
+ },
848
+ getPillText: { color: '#0ea5e9', fontSize: 11, fontWeight: '800' },
849
+ closeX: { position: 'absolute', top: 0, right: 4, color: '#fff', fontSize: 20 },
850
+ })
851
+
852
+ export function ChoreographyGalleryScreen() {
853
+ const [index, setIndex] = useState(0)
854
+ const [auto, setAuto] = useState(false)
855
+ const [autoDone, setAutoDone] = useState(false)
856
+ const id = EXAMPLES[index] as ExampleId
857
+
858
+ const advance = (): void => {
859
+ setIndex((i) => {
860
+ if (i + 1 >= EXAMPLES.length) {
861
+ setAuto(false)
862
+ setAutoDone(true)
863
+ return i
864
+ }
865
+ return i + 1
866
+ })
867
+ }
868
+ // iOS: hands-free auto-run (the simulator has no CLI touch injection — M2.4 precedent).
869
+ useEffect(() => {
870
+ if (Platform.OS !== 'ios') return
871
+ const timer = setTimeout(() => setAuto(true), 1500)
872
+ return () => clearTimeout(timer)
873
+ }, [])
874
+
875
+ const example =
876
+ id === 'spring-toggle' ? (
877
+ <SpringToggleExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
878
+ ) : id === 'fade' ? (
879
+ <FadeExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
880
+ ) : id === 'style-morph' ? (
881
+ <StyleMorphExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
882
+ ) : id === 'drag-snap' ? (
883
+ <DragSnapExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
884
+ ) : id === 'presence' ? (
885
+ <PresenceExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
886
+ ) : id === 'flip-swap' ? (
887
+ <FlipSwapExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
888
+ ) : id === 'interrupt' ? (
889
+ <InterruptExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
890
+ ) : (
891
+ <AppStoreCardExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
892
+ )
893
+
894
+ return (
895
+ <GestureHandlerRootView style={styles.root}>
896
+ <View style={styles.container}>
897
+ <Text style={styles.title}>Native Motion — examples</Text>
898
+ <Text style={styles.subtitle}>
899
+ {index + 1}/{EXAMPLES.length} · {TITLES[id]}
900
+ </Text>
901
+ <View style={styles.navRow}>
902
+ <Pressable
903
+ style={styles.navButton}
904
+ onPress={() => setIndex((i) => (i - 1 + EXAMPLES.length) % EXAMPLES.length)}
905
+ >
906
+ <Text style={styles.buttonText}>PREV</Text>
907
+ </Pressable>
908
+ <Pressable
909
+ style={styles.navButton}
910
+ onPress={() => {
911
+ setAutoDone(false)
912
+ setIndex(0)
913
+ setAuto(true)
914
+ }}
915
+ >
916
+ <Text style={styles.buttonText}>AUTO</Text>
917
+ </Pressable>
918
+ <Pressable
919
+ style={styles.navButton}
920
+ onPress={() => setIndex((i) => (i + 1) % EXAMPLES.length)}
921
+ >
922
+ <Text style={styles.buttonText}>NEXT</Text>
923
+ </Pressable>
924
+ </View>
925
+ {example}
926
+ <Text style={styles.mode}>
927
+ {autoDone
928
+ ? `AUTO-RUN COMPLETE ${EXAMPLES.length}/${EXAMPLES.length}`
929
+ : auto
930
+ ? 'mode: auto-run'
931
+ : Platform.OS === 'ios'
932
+ ? 'mode: manual (auto-run arms at boot)'
933
+ : 'mode: manual (real touch input)'}
934
+ </Text>
935
+ </View>
936
+ </GestureHandlerRootView>
937
+ )
938
+ }
939
+
940
+ const styles = StyleSheet.create({
941
+ root: { flex: 1 },
942
+ container: {
943
+ flex: 1,
944
+ backgroundColor: '#0b0b12',
945
+ alignItems: 'center',
946
+ paddingTop: 84,
947
+ paddingHorizontal: 24,
948
+ },
949
+ title: { color: '#fff', fontSize: 24, fontWeight: '700' },
950
+ subtitle: { color: '#8a8aa0', fontSize: 13, marginTop: 6, textAlign: 'center' },
951
+ navRow: { flexDirection: 'row', gap: 12, marginTop: 16 },
952
+ navButton: {
953
+ backgroundColor: '#1e1e2e',
954
+ borderRadius: 8,
955
+ paddingVertical: 10,
956
+ paddingHorizontal: 18,
957
+ borderWidth: 1,
958
+ borderColor: '#2c2c3d',
959
+ },
960
+ exampleBody: { alignSelf: 'stretch', alignItems: 'center', marginTop: 28 },
961
+ button: {
962
+ backgroundColor: '#1e1e2e',
963
+ borderRadius: 8,
964
+ paddingVertical: 10,
965
+ paddingHorizontal: 18,
966
+ borderWidth: 1,
967
+ borderColor: '#3d3d55',
968
+ },
969
+ buttonRow: { flexDirection: 'row', gap: 12 },
970
+ buttonText: { color: '#c9c9d8', fontSize: 13, fontWeight: '600' },
971
+ stage: { alignSelf: 'stretch', height: 96, justifyContent: 'center', marginTop: 20 },
972
+ track: {
973
+ width: 240 + 64,
974
+ height: 96,
975
+ justifyContent: 'center',
976
+ marginTop: 20,
977
+ },
978
+ snapMarker: {
979
+ position: 'absolute',
980
+ top: 16,
981
+ width: 64,
982
+ height: 64,
983
+ borderRadius: 16,
984
+ borderWidth: 1,
985
+ borderColor: '#2c2c3d',
986
+ },
987
+ box: { width: 64, height: 64, borderRadius: 16 },
988
+ presenceRow: {
989
+ flexDirection: 'row',
990
+ gap: 12,
991
+ minHeight: 72,
992
+ marginTop: 20,
993
+ alignSelf: 'stretch',
994
+ justifyContent: 'center',
995
+ },
996
+ itemBox: {
997
+ width: 64,
998
+ height: 64,
999
+ borderRadius: 16,
1000
+ alignItems: 'center',
1001
+ justifyContent: 'center',
1002
+ },
1003
+ itemText: { color: '#04302b', fontSize: 14, fontWeight: '700' },
1004
+ arena: { alignSelf: 'stretch', height: 160, justifyContent: 'center', marginTop: 20 },
1005
+ flipHome: { alignSelf: 'flex-start', width: 64 },
1006
+ flipEnd: { alignSelf: 'flex-end', width: 128 },
1007
+ status: { color: '#c9c9d8', fontSize: 13, marginTop: 24, fontFamily: 'Menlo' },
1008
+ note: { color: '#6f6f85', fontSize: 11, marginTop: 8, textAlign: 'center' },
1009
+ mode: { color: '#6f6f85', fontSize: 11, marginTop: 'auto', marginBottom: 24 },
1010
+ })