@unrulysystems/native-motion 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- package/src/web.ts +98 -0
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
|
+
})
|