@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
package/src/native.ts ADDED
@@ -0,0 +1,159 @@
1
+ // Native route of the single public import (REQ-UNIVERSAL-001): Metro resolves here via the
2
+ // `react-native` exports condition (newer Metro) or the top-level `react-native` field (older
3
+ // Metro) — the skew check guarantees both point at this file.
4
+ //
5
+ // The native runtime lives in this package (./native/{driver,motion}, extracted at the dogfood
6
+ // rung — specs/DOGFOOD-MIGRATION-BUILD-PACKET.md) but public runtime exports land example-by-
7
+ // example as the M3 migration pulls them (the dogfood-driven surface). Until a capability is
8
+ // pulled, it is absent here and consumer use is a build error — fail-loud by absence, never an
9
+ // interim shape (BRIEF Decisions). Engine verification mounts via the './verification' subpath,
10
+ // which is NOT the product surface.
11
+
12
+ export type {
13
+ MotionTargetProps,
14
+ Target,
15
+ Transition,
16
+ TransitionType,
17
+ } from '@unrulysystems/native-motion-core'
18
+
19
+ // The public native runtime surface, pulled example-by-example (dogfood law), under the
20
+ // RATIFIED universal names the web entry already ships (`View`/`ViewProps`, `Text`/`TextProps`,
21
+ // `Image`/`ImageProps`, and the presence primitives — SPEC-WEB-SHIM/REQ-UNIVERSAL-001): one
22
+ // import, three host components, two engines. `MotionView`/`MotionText`/`MotionImage` stay
23
+ // engine-internal; only universal names are public.
24
+ // Everything else in ./native is engine-internal — hand-wiring the driver or bindings from
25
+ // consumer code is the escape hatch the ban forbids. Cross-entry identity is asserted in
26
+ // type-identity.assert.ts (universal-subset prop assignability, fail-closed at typecheck).
27
+ export {
28
+ MotionImage as Image,
29
+ MotionText as Text,
30
+ MotionView as View,
31
+ type MotionImageComponentProps as ImageProps,
32
+ type MotionTextComponentProps as TextProps,
33
+ type MotionViewComponentProps as ViewProps,
34
+ } from './native/motion/MotionView'
35
+ // AnimatePresenceProps is deliberately not a standalone native export. The shared component prop
36
+ // contract is checked through ComponentProps in type-identity.assert.ts.
37
+ export {
38
+ AnimatePresence,
39
+ useIsPresent,
40
+ usePresence,
41
+ usePresenceData,
42
+ } from './native/motion/AnimatePresence'
43
+ // T21 (REQ-API-053): the public stagger() helper — ONE pin-golden implementation (core's,
44
+ // floor-verified against motion-utils) shipped by BOTH entries; on web the produced
45
+ // (index, total) => seconds functions feed motion/react's own delayChildren execution.
46
+ export { stagger } from '@unrulysystems/native-motion-core'
47
+ // T23 A: the public mix() utility — ONE core implementation on BOTH entries (the stagger
48
+ // precedent): pin-polymorphic through the value seam, refuse-loud on the arms core does not
49
+ // execute (arrays, objects, CSS vars) so the entries never skew on refusals.
50
+ export { motionMix as mix } from '@unrulysystems/native-motion-core'
51
+
52
+ // T24 W: the pin's `wrap` page-domain modulo (motion-utils/wrap via the dom barrel), one core
53
+ // implementation on both entries; degenerate domains refuse loud (G-INV-8 posture).
54
+ export { wrap } from '@unrulysystems/native-motion-core'
55
+ // FLAG 5a (REQ-API-022): the public root primitive — on native it owns the platform gesture
56
+ // root; the web entry ships the name with pass-through children (entry key parity). A gesture-
57
+ // carrying View outside it fails loud, never a silently dead gesture.
58
+ export { MotionRoot, type MotionRootProps } from './native/motion/MotionRoot'
59
+ // R10 M1 (REQ-API-035): tree-wide configuration provider — native context so transition
60
+ // defaults and reducedMotion reach every Motion element in the subtree.
61
+ export {
62
+ MotionConfig,
63
+ type MotionConfigProps,
64
+ type MotionConfigTransition,
65
+ type ReducedMotionConfig,
66
+ } from './native/motion/MotionConfig'
67
+ // R10 M2 (REQ-API-036): layout-identity namespace — native context prefixes descendant
68
+ // layoutIds (id-namespacing half this rung; pin nodeGroup dirty fan-out deferred).
69
+ export {
70
+ LayoutGroup,
71
+ type LayoutGroupInherit,
72
+ type LayoutGroupProps,
73
+ } from './native/motion/LayoutGroup'
74
+ // The Motion (event, info) drag-callback payload shape (REQ-API-020) — the shared core type,
75
+ // re-exported so both entries expose the same name (namespace parity).
76
+ export type { DragInfo, TapCallback, TapInfo } from '@unrulysystems/native-motion-core'
77
+ // T17 (REQ-API-048): the transformTemplate prop's public types — core's shared declarations,
78
+ // both entries (the pin exports TransformTemplate from motion-dom's public root).
79
+ export type { TransformProperties, TransformTemplate } from '@unrulysystems/native-motion-core'
80
+ // Product dual-authority release helpers (REQ-GESTURE-022 absolute origin + project-then-snap).
81
+ // Gallery/examples retarget controlled animate from the same pure selector the engine uses.
82
+ export { absoluteDragOrigin, selectReleaseTarget } from '@unrulysystems/native-motion-core'
83
+ // R4 — the public value channel (REQ-API-025..027): pinned-oracle hook names over CORE's graph;
84
+ // the web entry re-exports motion/react's own hooks under the same names (namespace parity).
85
+ // The MotionValue TYPE is engine-specific by design (core's here, motion's on web). Explicit
86
+ // native contract probes cover its portable shape; the namespace gate covers value names only.
87
+ export {
88
+ useMotionTemplate,
89
+ useMotionValue,
90
+ useMotionValueEvent,
91
+ useSpring,
92
+ useTransform,
93
+ useVelocity,
94
+ type SpringValueOptions,
95
+ type TransformOptions,
96
+ } from './native/motion/valueHooks'
97
+ // R11 M1 (REQ-API-037): scroll-fed motion values — UI-thread binding into the R4 value channel.
98
+ export {
99
+ useScroll,
100
+ type ScrollMotionValues,
101
+ type UseScrollOptions,
102
+ } from './native/motion/useScroll'
103
+ // U7i (REQ-API-037(e)): deprecated zero-arg alias of useScroll(); inherits the
104
+ // container-required refusal. Web re-exports the raw pin.
105
+ export { useViewportScroll } from './native/motion/useViewportScroll'
106
+ // R12 M1 (REQ-API-039): imperative surface — standalone animate + useAnimate through the
107
+ // prepared seam (no parallel engine). Public callables are the SHALLOW AnimateFn pair
108
+ // (web-identical shape) so dual-entry type-identity never expands pin overloads.
109
+ export {
110
+ animate,
111
+ type AnimateFn,
112
+ type AnimationPlaybackControls,
113
+ type AnimationScope,
114
+ } from './native/motion/imperativeAnimate'
115
+ export { useAnimate } from './native/motion/useAnimate'
116
+ // R13 M1 (REQ-API-040): useCycle — pin-faithful item list cycle; web re-exports motion/react.
117
+ export { useCycle, type Cycle, type CycleState } from './native/motion/useCycle'
118
+ // U7a (the REQ-API-027-adjacent hook row): useReducedMotion — the pin's mount-time SNAPSHOT of
119
+ // the OS reduced-motion preference (frozen for the hook's lifetime; no subscription); web
120
+ // re-exports the raw pin.
121
+ export { useReducedMotion } from './native/motion/useReducedMotion'
122
+ // U7b (the instant-transition lane): useInstantTransition opens the GLOBAL instant window — the
123
+ // MotionGlobalConfig.instantAnimations analogue (every starting transition skips its generator
124
+ // and commits its final keyframe on the next update tick, delay zeroed), relocked two
125
+ // post-render frames later under the token guard; disableInstantTransitions is the pin's
126
+ // sibling flag-clear. Web re-exports the raw pin.
127
+ export {
128
+ disableInstantTransitions,
129
+ useInstantTransition,
130
+ } from './native/motion/useInstantTransition'
131
+ // R15 D3 (REQ-API-043 / REQ-GESTURE-026): imperative drag hub — pin-faithful start/cancel/stop
132
+ // fan-out; web re-exports motion/react's useDragControls under the same name.
133
+ // DragControlOptions is the shared core type (namespace key parity with the web entry).
134
+ export { DragControls, useDragControls } from './native/motion/dragControls'
135
+ // U7g (REQ-LAYOUT-024): user scale-corrector channel. Web re-exports the raw pin; native
136
+ // requires workletized `correct` functions (F1) and bindable native style keys.
137
+ export { addScaleCorrector } from './native/motion/addScaleCorrector'
138
+ // U7j (REQ-API-058): read-only JS-frame clock. Web re-exports the raw pin; native is fed
139
+ // by the value-channel rAF stepper. The `frame` scheduler is not this unit.
140
+ export { frameData, type FrameData } from './native/motion/frameData'
141
+ // U8 (REQ-API-059): parametric arc path factory. Web re-exports the raw pin; native is core.
142
+ export { arc, type ArcOptions } from '@unrulysystems/native-motion-core'
143
+ export type { DragControlOptions, DragControlsHandle } from '@unrulysystems/native-motion-core'
144
+ // R13 value-breadth (REQ-VALUETYPE-013): parent/viewport geometry for measure-resolved %/calc.
145
+ // Web entry ships a no-op provider (pin measures itself); namespace key parity.
146
+ export {
147
+ MotionLengthLayoutProvider,
148
+ type MotionLengthLayoutProviderProps,
149
+ } from './native/motion/lengthLayoutContext'
150
+ // R13 V2 (REQ-LAYOUT-022(c) live-offset): composition feed for container scroll without a new host.
151
+ export {
152
+ LayoutScrollOffsetProvider,
153
+ createLayoutScrollOffsetRef,
154
+ recordLayoutScrollOffsetFromEvent,
155
+ type LayoutScrollOffset,
156
+ type LayoutScrollOffsetProviderProps,
157
+ type LayoutScrollOffsetRef,
158
+ } from './native/motion/layoutScroll'
159
+ export type { MotionValue } from '@unrulysystems/native-motion-core'
@@ -0,0 +1,78 @@
1
+ declare const process:
2
+ | {
3
+ readonly env?: {
4
+ readonly HARNESS?: string
5
+ readonly EXPO_PUBLIC_HARNESS?: string
6
+ }
7
+ }
8
+ | undefined
9
+
10
+ export const HARNESS_FRAME_DT_MS = 16.667
11
+
12
+ export interface FlipMetricTransform {
13
+ readonly translateX: number
14
+ readonly translateY: number
15
+ readonly scaleX: number
16
+ readonly scaleY: number
17
+ }
18
+
19
+ export function isHarnessMode(): boolean {
20
+ if (typeof process === 'undefined') return false
21
+ return process.env?.HARNESS === '1' || process.env?.EXPO_PUBLIC_HARNESS === '1'
22
+ }
23
+
24
+ export function schedulerFrameDelta(previousNow: number, now: number): number {
25
+ if (isHarnessMode()) return HARNESS_FRAME_DT_MS
26
+ return previousNow === 0 ? 0 : now - previousNow
27
+ }
28
+
29
+ export interface FlipMetricsTracker {
30
+ readonly startFlight: () => void
31
+ readonly sampleTransform: (transform: FlipMetricTransform) => void
32
+ readonly emitSettled: () => void
33
+ }
34
+
35
+ export function createFlipMetricsTracker(id: string): FlipMetricsTracker {
36
+ let active = false
37
+ let emittedFlights = 0
38
+ let peakTx = 0
39
+ let peakTy = 0
40
+ let minScaleX = 1
41
+ let minScaleY = 1
42
+
43
+ function reset(): void {
44
+ peakTx = 0
45
+ peakTy = 0
46
+ minScaleX = 1
47
+ minScaleY = 1
48
+ }
49
+
50
+ return {
51
+ startFlight() {
52
+ active = true
53
+ reset()
54
+ },
55
+ sampleTransform(transform) {
56
+ if (!active) return
57
+ peakTx = Math.max(peakTx, Math.abs(transform.translateX))
58
+ peakTy = Math.max(peakTy, Math.abs(transform.translateY))
59
+ minScaleX = Math.min(minScaleX, transform.scaleX)
60
+ minScaleY = Math.min(minScaleY, transform.scaleY)
61
+ },
62
+ emitSettled() {
63
+ if (!active) return
64
+ active = false
65
+ emittedFlights += 1
66
+ console.log(
67
+ `FLIP_METRICS ${JSON.stringify({
68
+ id,
69
+ flight: emittedFlights,
70
+ peakTx,
71
+ peakTy,
72
+ minScaleX,
73
+ minScaleY,
74
+ })}`,
75
+ )
76
+ },
77
+ }
78
+ }
@@ -0,0 +1,251 @@
1
+ // Layout-identity UI-thread crossing probe. The acceptance floor is a real iOS/Android app process
2
+ // that would have crashed before the dist layout worklet tags existed. The screen self-drives two legs:
3
+ // 1. executeOnUIRuntimeSync parity: the same registry/session trajectory runs on JS and UI and is
4
+ // compared exactly over a fixed frame grid.
5
+ // 2. useFrameCallback liveness: one live UI frame loop steps a registry/session until settle and
6
+ // reports the single settle edge back to JS.
7
+ // The RN-free probe engine (graph, trajectory, live-leg state machine) lives in
8
+ // layoutIdentityProbeEngine.ts so its reporting semantics are unit-tested off-device.
9
+
10
+ import { useEffect, useMemo, useRef, useState } from 'react'
11
+ import { StyleSheet, Text, View } from 'react-native'
12
+ import { useFrameCallback } from 'react-native-reanimated'
13
+ import { executeOnUIRuntimeSync, scheduleOnRN } from 'react-native-worklets'
14
+ import {
15
+ DT_MS,
16
+ STEPS_PER_LEG,
17
+ resetLiveProbe,
18
+ runLayoutIdentityTrajectory,
19
+ stepLiveProbe,
20
+ } from './layoutIdentityProbeEngine'
21
+
22
+ const PROBE_LOG_PREFIX = 'LAYOUT_UITHREAD_PROBE '
23
+ const REACT_NATIVE_INFO_LOG_LEVEL = 1
24
+
25
+ interface ReactNativeLoggingGlobal {
26
+ nativeLoggingHook?: (message: string, level: number) => void
27
+ }
28
+
29
+ function logProbeVerdict(report: {
30
+ readonly pass: boolean
31
+ readonly parityPass: boolean
32
+ readonly parityDetail: string
33
+ readonly livePass: boolean | null
34
+ readonly liveDetail: string
35
+ }): void {
36
+ const message = `${PROBE_LOG_PREFIX}${JSON.stringify(report)}`
37
+ const nativeLoggingHook = (globalThis as ReactNativeLoggingGlobal).nativeLoggingHook
38
+ if (nativeLoggingHook !== undefined) {
39
+ nativeLoggingHook(message, REACT_NATIVE_INFO_LOG_LEVEL)
40
+ return
41
+ }
42
+ console.log(message)
43
+ }
44
+
45
+ type ProbeReport =
46
+ | {
47
+ readonly pass: true
48
+ readonly detail: string
49
+ readonly sampleRows: readonly {
50
+ readonly i: number
51
+ readonly js: number
52
+ readonly ui: number
53
+ }[]
54
+ }
55
+ | {
56
+ readonly pass: false
57
+ readonly detail: string
58
+ readonly sampleRows: readonly {
59
+ readonly i: number
60
+ readonly js: number
61
+ readonly ui: number | null
62
+ }[]
63
+ }
64
+
65
+ function runParityProbe(): ProbeReport {
66
+ const js = runLayoutIdentityTrajectory()
67
+ let ui: ReturnType<typeof runLayoutIdentityTrajectory>
68
+ try {
69
+ ui = executeOnUIRuntimeSync(() => {
70
+ 'worklet'
71
+ return runLayoutIdentityTrajectory()
72
+ })()
73
+ } catch (e) {
74
+ return {
75
+ pass: false,
76
+ detail: `UI runtime threw before settle: ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`,
77
+ sampleRows: [0, 240, 1200, js.values.length - 1].map((i) => ({
78
+ i,
79
+ js: js.values[i] as number,
80
+ ui: null,
81
+ })),
82
+ }
83
+ }
84
+ if (!ui.enterSettled || !ui.exitReleased) {
85
+ const last = Math.max(0, Math.min(js.values.length, ui.values.length) - 1)
86
+ return {
87
+ pass: false,
88
+ detail:
89
+ `UI runtime completed without crash but missed lifecycle floor ` +
90
+ `(enterSettled=${String(ui.enterSettled)}, exitReleased=${String(ui.exitReleased)}, ` +
91
+ `active=${ui.activeAnimations}, jsActive=${js.activeAnimations})`,
92
+ sampleRows: [0, 240, 1200, last].map((i) => ({
93
+ i,
94
+ js: js.values[i] as number,
95
+ ui: ui.values[i] as number,
96
+ })),
97
+ }
98
+ }
99
+ if (!js.enterSettled || !js.exitReleased) {
100
+ return {
101
+ pass: false,
102
+ detail: `JS reference missed lifecycle floor (enterSettled=${String(js.enterSettled)}, exitReleased=${String(js.exitReleased)})`,
103
+ sampleRows: [],
104
+ }
105
+ }
106
+ if (ui.values.length !== js.values.length) {
107
+ return {
108
+ pass: false,
109
+ detail: `length mismatch: js=${js.values.length} ui=${ui.values.length}`,
110
+ sampleRows: [],
111
+ }
112
+ }
113
+ for (let i = 0; i < js.values.length; i++) {
114
+ if (js.values[i] !== ui.values[i]) {
115
+ return {
116
+ pass: false,
117
+ detail: `first divergence at sample ${i}: js=${String(js.values[i])} ui=${String(ui.values[i])}`,
118
+ sampleRows: [{ i, js: js.values[i] as number, ui: ui.values[i] as number }],
119
+ }
120
+ }
121
+ }
122
+ return {
123
+ pass: true,
124
+ detail: `${STEPS_PER_LEG * 2} layout-identity frames bit-identical; enter settled and exit releasable`,
125
+ sampleRows: [0, 240, 1200, js.values.length - 1].map((i) => ({
126
+ i,
127
+ js: js.values[i] as number,
128
+ ui: ui.values[i] as number,
129
+ })),
130
+ }
131
+ }
132
+
133
+ export function LayoutIdentityWorkletProbe() {
134
+ const parity = useMemo(runParityProbe, [])
135
+ const loggedParity = useRef(false)
136
+ const loggedVerdict = useRef(false)
137
+ const [liveDetail, setLiveDetail] = useState('useFrameCallback live leg pending')
138
+ const [livePass, setLivePass] = useState<boolean | null>(null)
139
+
140
+ const frameCallback = useFrameCallback((frame) => {
141
+ 'worklet'
142
+ const result = stepLiveProbe(frame.timeSincePreviousFrame ?? DT_MS)
143
+ if (result.report) scheduleOnRN(reportLiveResult, result.pass, result.detail)
144
+ }, false)
145
+
146
+ const liveReported = useRef(false)
147
+
148
+ function reportLiveResult(pass: boolean, detail: string): void {
149
+ // First verdict wins. scheduleOnRN calls queue while the frame callback is being deactivated;
150
+ // without this latch a later report would overwrite the recorded verdict.
151
+ if (liveReported.current) return
152
+ liveReported.current = true
153
+ setLivePass(pass)
154
+ setLiveDetail(detail)
155
+ frameCallback.setActive(false)
156
+ }
157
+
158
+ useEffect(() => {
159
+ try {
160
+ executeOnUIRuntimeSync(() => {
161
+ 'worklet'
162
+ resetLiveProbe()
163
+ })()
164
+ frameCallback.setActive(true)
165
+ } catch (e) {
166
+ setLivePass(false)
167
+ setLiveDetail(
168
+ `live setup threw: ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`,
169
+ )
170
+ }
171
+ return () => frameCallback.setActive(false)
172
+ }, [frameCallback])
173
+
174
+ const liveResolved = livePass !== null
175
+ const pass = parity.pass && livePass === true
176
+
177
+ useEffect(() => {
178
+ if (loggedParity.current) return
179
+ loggedParity.current = true
180
+ logProbeVerdict({
181
+ pass,
182
+ parityPass: parity.pass,
183
+ parityDetail: parity.detail,
184
+ livePass,
185
+ liveDetail,
186
+ })
187
+ }, [liveDetail, livePass, parity.detail, parity.pass, pass])
188
+
189
+ useEffect(() => {
190
+ if (!liveResolved || loggedVerdict.current) return
191
+ loggedVerdict.current = true
192
+ logProbeVerdict({
193
+ pass,
194
+ parityPass: parity.pass,
195
+ parityDetail: parity.detail,
196
+ livePass,
197
+ liveDetail,
198
+ })
199
+ }, [liveDetail, livePass, liveResolved, parity.detail, parity.pass, pass])
200
+
201
+ return (
202
+ <View style={styles.container}>
203
+ <Text style={styles.title}>Layout identity worklet probe</Text>
204
+ <Text style={styles.subtitle}>core registry/session on the UI runtime</Text>
205
+ <View
206
+ style={[
207
+ styles.banner,
208
+ pass ? styles.pass : parity.pass && !liveResolved ? styles.pending : styles.fail,
209
+ ]}
210
+ >
211
+ <Text style={styles.bannerText}>
212
+ {pass ? 'PASS' : parity.pass && !liveResolved ? 'RUNNING' : 'FAIL'}
213
+ </Text>
214
+ </View>
215
+ <Text style={styles.detail}>sync parity: {parity.detail}</Text>
216
+ <Text style={styles.detail}>live frame: {liveDetail}</Text>
217
+ <View style={styles.table}>
218
+ {parity.sampleRows.map((row) => (
219
+ <Text key={row.i} style={styles.row}>
220
+ sample {row.i}: js={row.js.toFixed(6)} ui={row.ui === null ? '-' : row.ui.toFixed(6)}
221
+ </Text>
222
+ ))}
223
+ </View>
224
+ <Text style={styles.footer}>
225
+ Acceptance requires a real device/simulator screenshot showing PASS and the captured
226
+ verdict.
227
+ </Text>
228
+ </View>
229
+ )
230
+ }
231
+
232
+ const styles = StyleSheet.create({
233
+ container: {
234
+ flex: 1,
235
+ backgroundColor: '#0b0b12',
236
+ alignItems: 'center',
237
+ justifyContent: 'center',
238
+ paddingHorizontal: 24,
239
+ },
240
+ title: { color: '#fff', fontSize: 23, fontWeight: '700', textAlign: 'center' },
241
+ subtitle: { color: '#8a8aa0', fontSize: 13, marginTop: 6, marginBottom: 24, textAlign: 'center' },
242
+ banner: { paddingHorizontal: 40, paddingVertical: 14, borderRadius: 12 },
243
+ pass: { backgroundColor: '#1d7a3e' },
244
+ pending: { backgroundColor: '#6b5f1d' },
245
+ fail: { backgroundColor: '#a32633' },
246
+ bannerText: { color: '#fff', fontSize: 30, fontWeight: '800', letterSpacing: 2 },
247
+ detail: { color: '#c9c9d8', fontSize: 13, marginTop: 18, textAlign: 'center' },
248
+ table: { marginTop: 20 },
249
+ row: { color: '#6f6f85', fontSize: 12, fontFamily: 'Menlo', marginTop: 4 },
250
+ footer: { color: '#77778a', fontSize: 11, marginTop: 26, textAlign: 'center' },
251
+ })
@@ -0,0 +1,162 @@
1
+ // M2.1 worklet-parity probe (specs/M2.1-BUILD-PACKET.md, Milestone 2). The same spring trajectory is
2
+ // computed twice over an identical manual sample grid — once on the JS thread, once inside a UI-thread
3
+ // worklet — and compared EXACTLY. Both legs run the same Hermes engine, so any divergence (or a failed
4
+ // crossing) is a finding, not noise. The probe renders its evidence on screen so a device screenshot IS
5
+ // the proof artifact; a crossing error is caught and DISPLAYED (fail-loud on screen — the probe's whole
6
+ // purpose is to capture that exact text), never swallowed.
7
+
8
+ import { StyleSheet, Text, View } from 'react-native'
9
+ import { executeOnUIRuntimeSync } from 'react-native-worklets'
10
+ import {
11
+ resolveSpringGenerator,
12
+ stepProp,
13
+ type PropStep,
14
+ type SpringConfig,
15
+ } from '@unrulysystems/native-motion-core'
16
+
17
+ // The shared grid + spring. Underdamped (motion's default feel) so the trajectory oscillates rather
18
+ // than settling monotonically — the richest of the regime code paths. 120 frames ≈ 2s at 60Hz.
19
+ const FROM = 0
20
+ const TARGET = 220
21
+ const STEPS = 120
22
+ const DT_MS = 16.67
23
+ const SPRING: SpringConfig = { stiffness: 100, damping: 10, mass: 1 }
24
+
25
+ // One leg of the probe: seed a spring generator and step it across the grid — core's real per-frame
26
+ // path (resolveSpringGenerator → factory → stepProp), no shortcuts. This exact function body must be
27
+ // executable on BOTH runtimes; the UI leg re-states it inside a worklet because the generator closure
28
+ // cannot cross runtimes (it must be constructed where it runs).
29
+ function jsLegTrajectory(): number[] {
30
+ const factory = resolveSpringGenerator(TARGET, SPRING)
31
+ const generator = factory({ from: FROM, velocity: 0 })
32
+ const prop: PropStep = { generator, elapsed: 0, value: FROM, done: false }
33
+ const values: number[] = []
34
+ for (let i = 0; i < STEPS; i++) {
35
+ stepProp(prop, DT_MS)
36
+ values.push(prop.value)
37
+ }
38
+ return values
39
+ }
40
+
41
+ type UiLegResult =
42
+ | { readonly ok: true; readonly values: readonly number[] }
43
+ | { readonly ok: false; readonly error: string }
44
+
45
+ // The UI-thread leg — packet §The crossing, mechanism 2 (direct import). RED was observed with a
46
+ // sentinel body first (apps/mobile/docs/proof/m21-probe-red-ios.png). The generator is CONSTRUCTED
47
+ // inside the worklet — a JS-built closure cannot cross runtimes — so the crossing under test is the
48
+ // imported core functions themselves (resolveSpringGenerator, stepProp). The try/catch is the probe's
49
+ // evidence capture, not suppression: a failed crossing must render its exact error on screen.
50
+ function uiLegTrajectory(): UiLegResult {
51
+ try {
52
+ const values = executeOnUIRuntimeSync((): number[] => {
53
+ 'worklet'
54
+ const factory = resolveSpringGenerator(TARGET, SPRING)
55
+ const generator = factory({ from: FROM, velocity: 0 })
56
+ const prop: PropStep = { generator, elapsed: 0, value: FROM, done: false }
57
+ const out: number[] = []
58
+ for (let i = 0; i < STEPS; i++) {
59
+ stepProp(prop, DT_MS)
60
+ out.push(prop.value)
61
+ }
62
+ return out
63
+ })()
64
+ return { ok: true, values }
65
+ } catch (e) {
66
+ return { ok: false, error: e instanceof Error ? `${e.name}: ${e.message}` : String(e) }
67
+ }
68
+ }
69
+
70
+ interface ProbeReport {
71
+ readonly pass: boolean
72
+ readonly detail: string
73
+ readonly sampleRows: readonly {
74
+ readonly i: number
75
+ readonly js: number
76
+ readonly ui: number | null
77
+ }[]
78
+ }
79
+
80
+ // Compare the two legs exactly (===, epsilon 0): same engine, same doubles, same code path — bitwise
81
+ // agreement is the claim under test. First divergence index + both values become the on-screen detail.
82
+ function runProbe(): ProbeReport {
83
+ const js = jsLegTrajectory()
84
+ const ui = uiLegTrajectory()
85
+ if (!ui.ok) {
86
+ return {
87
+ pass: false,
88
+ detail: `UI leg failed to run: ${ui.error}`,
89
+ sampleRows: [0, 30, 60, 119].map((i) => ({ i, js: js[i] as number, ui: null })),
90
+ }
91
+ }
92
+ if (ui.values.length !== js.length) {
93
+ return {
94
+ pass: false,
95
+ detail: `length mismatch: js=${js.length} ui=${ui.values.length}`,
96
+ sampleRows: [],
97
+ }
98
+ }
99
+ for (let i = 0; i < js.length; i++) {
100
+ if (js[i] !== ui.values[i]) {
101
+ return {
102
+ pass: false,
103
+ detail: `first divergence at step ${i}: js=${js[i]} ui=${ui.values[i]}`,
104
+ sampleRows: [i].map((k) => ({ i: k, js: js[k] as number, ui: ui.values[k] as number })),
105
+ }
106
+ }
107
+ }
108
+ return {
109
+ pass: true,
110
+ detail: `${STEPS} steps bit-identical (dt=${DT_MS}ms, spring k=${SPRING.stiffness} c=${SPRING.damping})`,
111
+ sampleRows: [0, 30, 60, 119].map((i) => ({
112
+ i,
113
+ js: js[i] as number,
114
+ ui: ui.values[i] as number,
115
+ })),
116
+ }
117
+ }
118
+
119
+ export function WorkletParityProbe() {
120
+ // Computed synchronously at render: the probe is deterministic and cheap (240 analytic samples), and
121
+ // a synchronous result guarantees any screenshot shows the final verdict, never a loading state.
122
+ const report = runProbe()
123
+
124
+ return (
125
+ <View style={styles.container}>
126
+ <Text style={styles.title}>M2.1 worklet parity probe</Text>
127
+ <Text style={styles.subtitle}>
128
+ core stepProp: JS thread vs UI-thread worklet, {STEPS} steps @ {DT_MS}ms
129
+ </Text>
130
+ <View style={[styles.banner, report.pass ? styles.pass : styles.fail]}>
131
+ <Text style={styles.bannerText}>{report.pass ? 'PASS' : 'FAIL'}</Text>
132
+ </View>
133
+ <Text style={styles.detail}>{report.detail}</Text>
134
+ <View style={styles.table}>
135
+ {report.sampleRows.map((row) => (
136
+ <Text key={row.i} style={styles.row}>
137
+ step {row.i}: js={row.js.toFixed(6)} ui={row.ui === null ? '—' : row.ui.toFixed(6)}
138
+ </Text>
139
+ ))}
140
+ </View>
141
+ </View>
142
+ )
143
+ }
144
+
145
+ const styles = StyleSheet.create({
146
+ container: {
147
+ flex: 1,
148
+ backgroundColor: '#0b0b12',
149
+ alignItems: 'center',
150
+ justifyContent: 'center',
151
+ paddingHorizontal: 24,
152
+ },
153
+ title: { color: '#fff', fontSize: 24, fontWeight: '700' },
154
+ subtitle: { color: '#8a8aa0', fontSize: 13, marginTop: 6, marginBottom: 28, textAlign: 'center' },
155
+ banner: { paddingHorizontal: 48, paddingVertical: 14, borderRadius: 12 },
156
+ pass: { backgroundColor: '#1d7a3e' },
157
+ fail: { backgroundColor: '#a32633' },
158
+ bannerText: { color: '#fff', fontSize: 32, fontWeight: '800', letterSpacing: 2 },
159
+ detail: { color: '#c9c9d8', fontSize: 13, marginTop: 20, textAlign: 'center' },
160
+ table: { marginTop: 24 },
161
+ row: { color: '#6f6f85', fontSize: 12, fontFamily: 'Menlo', marginTop: 4 },
162
+ })