@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,7 @@
1
+ // The keyframe timing cross-field law is owned by CORE's raw Driver.command preparation boundary.
2
+ // Native supplying lanes import it only through the deliberately gated internal-driver subpath, so the
3
+ // controller, gesture, variant, presence, reference driver, and worklet driver cannot drift apart.
4
+ export {
5
+ keyframeTimingRefusal,
6
+ keyframeTransitionRefusal,
7
+ } from '@unrulysystems/native-motion-core/internal-driver'
@@ -0,0 +1,261 @@
1
+ // The layoutId binding's brain (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md §The binding after the
2
+ // port): RN-side wiring for core's layout-identity registry, whose per-frame engine now lives in
3
+ // the DRIVER's UI-runtime state (`WorkletDriverBinding.layout`). This layer owns only (a) fail-loud
4
+ // onLayout rect validation (the M2.5 posture, reused not forked), (b) attach/commit/detach routing
5
+ // so a mounting RN view enters unmeasured and its first commitLayout supplies the rect, and (c) the
6
+ // holder-output handoff: screens bind animated styles to `sharedViewFor` shared values the UI
7
+ // frame loop writes — there is no per-frame JS read and no JS stepper anymore. The
8
+ // releasable→safeToRemove coupling AnimatePresence consumes rides the driver's exactly-once
9
+ // release edge (PRESENCE owns the mounted lifetime; the registry only reports when the crossfade
10
+ // is done with the element).
11
+
12
+ import { toWindowSpaceRect, type WindowSpaceRect } from '@unrulysystems/native-motion-core'
13
+ import type {
14
+ LayoutIdentityRole,
15
+ LayoutIdToken,
16
+ } from '@unrulysystems/native-motion-core/worklet-layout'
17
+ import type {
18
+ LayoutIdentityHolderOpacityWitness,
19
+ LayoutIdentityHolderShared,
20
+ WorkletDriverBinding,
21
+ } from '../driver/workletDriver'
22
+ import { toValidatedLayoutRect, type LayoutRectShape } from './layoutProjection'
23
+
24
+ export interface LayoutIdentityMeasureHandle {
25
+ readonly measureInWindow: (
26
+ callback: (x: number, y: number, width: number, height: number) => void,
27
+ ) => void
28
+ }
29
+
30
+ export type LayoutIdentityMeasureInput = LayoutIdentityMeasureHandle | WindowSpaceRect
31
+
32
+ /** Per-edge options for layout-identity lifecycle (REQ-API-035 MotionConfig → layoutId lane). */
33
+ export interface LayoutIdentityBindingOptions {
34
+ readonly instant?: boolean
35
+ /** Stable component-tree address of the host whose live layoutId transform is being painted. */
36
+ readonly projectionComponentId?: string
37
+ /**
38
+ * Family-3 layoutId transition: duration/ease tween for the shared projection flight
39
+ * (TimingConfig is ms). Host maps authored seconds via toTimingConfig before this edge.
40
+ */
41
+ readonly timing?: import('@unrulysystems/native-motion-core').TimingConfig
42
+ /**
43
+ * T22 layoutId transition: per-flight spring (engine SpringConfig ms — physics or
44
+ * time-defined). Mutually exclusive with `timing` (single-outcome resolver, L1).
45
+ */
46
+ readonly spring?: import('@unrulysystems/native-motion-core').SpringConfig
47
+ }
48
+
49
+ export interface LayoutIdentityBinding {
50
+ /** Root driver that owns this identity registry and its shared projection tree. */
51
+ readonly projectionBinding?: WorkletDriverBinding
52
+ // A view begins holding `id`. Real RN holders pass a host handle; deterministic check screens may
53
+ // pass an already-branded synthetic common-space rect. Raw onLayout rects are not accepted.
54
+ attach(
55
+ id: string,
56
+ token: LayoutIdToken,
57
+ measure?: LayoutIdentityMeasureInput | null,
58
+ options?: LayoutIdentityBindingOptions,
59
+ ): LayoutIdentityRole
60
+ // Feed one host handle or branded synthetic rect that can provide a common-space measurement.
61
+ // Malformed measured fields fail loud naming the field — never coerced (M2.5 §semantics 6 posture).
62
+ commitLayout(
63
+ id: string,
64
+ token: LayoutIdToken,
65
+ measure: LayoutIdentityMeasureInput,
66
+ options?: LayoutIdentityBindingOptions,
67
+ onMeasuredCommit?: (startedFlight: boolean) => void,
68
+ ): void
69
+ // One holder's animated-style outputs, written by the UI frame loop (transform, opacity, z-lift
70
+ // as 0/1). Replaces the deleted per-frame JS `view()` read.
71
+ sharedViewFor(id: string, token: LayoutIdToken): LayoutIdentityHolderShared
72
+ // U6 W4 verification witness (the device proof cards' crossfade row): the live painted opacity
73
+ // of every holder record the identity still holds (mounted lead + exiting retained follower).
74
+ opacityWitnessFor(id: string): readonly LayoutIdentityHolderOpacityWitness[]
75
+ // Stop holding. A lead detaching begins the reverse flight and rides as exiting until
76
+ // releasable; detaching again (or after settle) completes the removal — registry `leave`
77
+ // semantics verbatim.
78
+ // Returns whether this detach retained the holder as an exiting record. A trailing or sole
79
+ // holder drops immediately; a leading reverse flight remains held until its terminal detach.
80
+ detach(id: string, token: LayoutIdToken, options?: LayoutIdentityBindingOptions): boolean
81
+ releasable(id: string, token: LayoutIdToken): boolean
82
+ isSettled(id: string): boolean
83
+ // Registry membership (holders ∪ exiting). Presence beginExit peeks before detach.
84
+ holds(id: string, token: LayoutIdToken): boolean
85
+ // True while this token is the current lead. Trailing followers of a live flight are false.
86
+ isLead(id: string, token: LayoutIdToken): boolean
87
+ subscribeSettled(id: string, onSettled: () => void): () => void
88
+ // Couple an exiting holder's release to a presence callback: fires EXACTLY ONCE when the
89
+ // registry reports the crossfade is done with the holder (driver-owned first-report-wins latch;
90
+ // an already-releasable holder fires at registration). Returns an uncouple for
91
+ // unmount-before-settle.
92
+ coupleRelease(id: string, token: LayoutIdToken, safeToRemove: () => void): () => void
93
+ }
94
+
95
+ function isMeasureHandle(input: LayoutIdentityMeasureInput): input is LayoutIdentityMeasureHandle {
96
+ return (
97
+ typeof input === 'object' &&
98
+ input !== null &&
99
+ 'measureInWindow' in input &&
100
+ typeof input.measureInWindow === 'function'
101
+ )
102
+ }
103
+
104
+ function toValidatedWindowSpaceRect(rect: LayoutRectShape): WindowSpaceRect {
105
+ return toWindowSpaceRect(toValidatedLayoutRect(rect))
106
+ }
107
+
108
+ // REQ-LAYOUT-011/016/019: a host read ahead of the view's first layout pass reports a 0×0 window
109
+ // rect — the native null read. It is not a registry rect: skip the commit so the holder stays
110
+ // unmeasured (identity transform + unsettled) and the next layout epoch re-enters the measure.
111
+ // Never coerced into a projection and never thrown through the host callback (the 2026-09-08 r2
112
+ // walk FATAL). Non-finite fields remain a loud caller/host defect via toValidatedLayoutRect.
113
+ function isNotYetLaidOut(rect: LayoutRectShape): boolean {
114
+ return (
115
+ typeof rect.width === 'number' &&
116
+ Number.isFinite(rect.width) &&
117
+ typeof rect.height === 'number' &&
118
+ Number.isFinite(rect.height) &&
119
+ (rect.width <= 0 || rect.height <= 0)
120
+ )
121
+ }
122
+
123
+ function requestWindowSpaceMeasure(
124
+ input: LayoutIdentityMeasureInput,
125
+ commit: (rect: WindowSpaceRect) => void,
126
+ ): void {
127
+ if (!isMeasureHandle(input)) {
128
+ commit(input)
129
+ return
130
+ }
131
+ input.measureInWindow((x, y, width, height) => {
132
+ const rect = { x, y, width, height }
133
+ if (isNotYetLaidOut(rect)) return
134
+ commit(toValidatedWindowSpaceRect(rect))
135
+ })
136
+ }
137
+
138
+ // The binding is created over a live or manual `WorkletDriverBinding` (the driver hook/factory the
139
+ // screen already owns); the flight spring is the driver's `layoutSpring` option — one config per
140
+ // driver, fixed at the layout slot's creation.
141
+ export function createLayoutIdentityBinding(driver: WorkletDriverBinding): LayoutIdentityBinding {
142
+ const layout = driver.layout
143
+
144
+ // Each measurement request mints a JS-side epoch. Async host measurements capture this object,
145
+ // so a newer request, detach, or detach→reattach invalidates only that holder's older pending
146
+ // work. The driver remains fail-loud for direct constructive commits; this gate handles solely
147
+ // the stale completion class that cannot safely throw from a host callback (REQ-LAYOUT-016/018).
148
+ const measurementEpochs = new Map<LayoutIdToken, { readonly id: string }>()
149
+
150
+ const commitIfCurrent = (
151
+ epoch: { readonly id: string },
152
+ token: LayoutIdToken,
153
+ measured: WindowSpaceRect,
154
+ options?: LayoutIdentityBindingOptions,
155
+ onMeasuredCommit?: (startedFlight: boolean) => void,
156
+ ): void => {
157
+ if (measurementEpochs.get(token) !== epoch) return
158
+ // A host measurement can arrive in a later JS turn. Read the flight edge at the actual
159
+ // driver commit, rather than at the earlier onLayout request, so a mounted retarget cannot
160
+ // lose its completion arm while measureInWindow is pending. Callers without lifecycle
161
+ // completion ownership keep the established one-way commit path.
162
+ // The ordinary binding path has no completion owner and deliberately needs only commitRect;
163
+ // preserve that smaller test/runtime seam by consulting settledness only when one was supplied.
164
+ const settledBefore =
165
+ onMeasuredCommit === undefined || options?.instant === true
166
+ ? false
167
+ : layout.isSettled(epoch.id)
168
+ const committedLead =
169
+ options === undefined
170
+ ? layout.commitRect(epoch.id, token, measured)
171
+ : layout.commitRect(epoch.id, token, measured, options)
172
+ if (onMeasuredCommit !== undefined) {
173
+ // A force-instant lead stays settled before and after its commit, so settledness cannot
174
+ // identify its completed flight. The driver reports the current holder role at the commit
175
+ // edge; sole/trailing commits return false and never arm an unrelated completion callback.
176
+ // The ordinary lane retains its existing settled-edge proof, avoiding a callback for a
177
+ // no-op lead measure that began no projection work.
178
+ onMeasuredCommit(
179
+ options?.instant === true ? committedLead : settledBefore && !layout.isSettled(epoch.id),
180
+ )
181
+ }
182
+ }
183
+
184
+ // Keep the default three-argument path for ordinary identity calls. A component-tree address
185
+ // is a lifecycle-only extension that the UI engine needs to publish a live layoutId transform.
186
+ const flightOpts = (
187
+ options?: LayoutIdentityBindingOptions,
188
+ ): LayoutIdentityBindingOptions | undefined => {
189
+ if (
190
+ options?.instant !== true &&
191
+ options?.projectionComponentId === undefined &&
192
+ options?.timing === undefined &&
193
+ options?.spring === undefined
194
+ )
195
+ return undefined
196
+ return {
197
+ ...(options?.instant === true ? { instant: true as const } : {}),
198
+ ...(options?.projectionComponentId === undefined
199
+ ? {}
200
+ : { projectionComponentId: options.projectionComponentId }),
201
+ ...(options?.timing === undefined ? {} : { timing: options.timing }),
202
+ ...(options?.spring === undefined ? {} : { spring: options.spring }),
203
+ }
204
+ }
205
+
206
+ return {
207
+ projectionBinding: driver,
208
+ attach(id, token, measure, options) {
209
+ // Enter unmeasured, then let the (possibly async) host measurement land as the first
210
+ // commit — identical routing for handles and pre-branded rects.
211
+ const opts = flightOpts(options)
212
+ const role =
213
+ opts === undefined ? layout.attach(id, token, null) : layout.attach(id, token, null, opts)
214
+ const epoch = { id }
215
+ measurementEpochs.set(token, epoch)
216
+ if (measure !== undefined && measure !== null) {
217
+ requestWindowSpaceMeasure(measure, (measured) => {
218
+ commitIfCurrent(epoch, token, measured, opts)
219
+ })
220
+ }
221
+ return role
222
+ },
223
+
224
+ commitLayout(id, token, measure, options, onMeasuredCommit) {
225
+ const attachedEpoch = measurementEpochs.get(token)
226
+ if (attachedEpoch === undefined || attachedEpoch.id !== id) {
227
+ throw new Error(
228
+ `layoutId binding: commitLayout for '${id}' requires an attached holder; ` +
229
+ 'constructive measurement calls after detach are lifecycle defects.',
230
+ )
231
+ }
232
+ const epoch = { id }
233
+ measurementEpochs.set(token, epoch)
234
+ const opts = flightOpts(options)
235
+ requestWindowSpaceMeasure(measure, (measured) => {
236
+ commitIfCurrent(epoch, token, measured, opts, onMeasuredCommit)
237
+ })
238
+ },
239
+
240
+ sharedViewFor: (id, token) => layout.sharedFor(id, token),
241
+
242
+ opacityWitnessFor: (id) => layout.opacityWitnessFor(id),
243
+
244
+ detach(id, token, options) {
245
+ const opts = flightOpts(options)
246
+ const exitingHolderRetained =
247
+ opts === undefined ? layout.detach(id, token) : layout.detach(id, token, opts)
248
+ const epoch = measurementEpochs.get(token)
249
+ if (epoch?.id === id) measurementEpochs.delete(token)
250
+ return exitingHolderRetained
251
+ },
252
+
253
+ releasable: (id, token) => layout.releasable(id, token),
254
+ isSettled: (id) => layout.isSettled(id),
255
+ holds: (id, token) => (typeof layout.holds === 'function' ? layout.holds(id, token) : false),
256
+ isLead: (id, token) => (typeof layout.isLead === 'function' ? layout.isLead(id, token) : true),
257
+ subscribeSettled: (id, onSettled) => layout.subscribeSettled(id, onSettled),
258
+
259
+ coupleRelease: (id, token, safeToRemove) => layout.coupleRelease(id, token, safeToRemove),
260
+ }
261
+ }
@@ -0,0 +1,51 @@
1
+ // Family-3 + T22: map gated element transitions to the layoutId projection flight config.
2
+ import {
3
+ toSpringConfig,
4
+ toTimingConfig,
5
+ type SpringConfig,
6
+ type TimingConfig,
7
+ type Transition,
8
+ } from '@unrulysystems/native-motion-core'
9
+
10
+ /**
11
+ * ONE resolver, three outcomes (T22 L1): executable tween shapes → `timing` (ms), typed spring
12
+ * shapes (physics or time-defined) → `spring` (engine SpringConfig, ms), anything else →
13
+ * undefined (the driver-global flight spring). Mutually exclusive by construction — the gate
14
+ * admits exactly what this resolver executes (G-INV-8 both directions).
15
+ */
16
+ export type LayoutIdFlightConfig =
17
+ | { readonly timing: TimingConfig; readonly spring?: undefined }
18
+ | { readonly spring: SpringConfig; readonly timing?: undefined }
19
+
20
+ /**
21
+ * Duration-only / tween paths ship as flight timing; `type: 'spring'` ships as a per-flight
22
+ * spring through toSpringConfig (velocity never reaches here — the gate refuses it on layoutId,
23
+ * REQ-LAYOUT-023 b). Instant/reduced-motion is handled by forceInstant, not here. Duration-only
24
+ * (type omitted) injects easeOut — same law as ordinary layout projection
25
+ * (MotionView createNoFlashLayoutTransition / pinned Motion).
26
+ */
27
+ export function resolveLayoutIdFlightConfig(
28
+ transition: Transition | undefined,
29
+ ): LayoutIdFlightConfig | undefined {
30
+ if (transition === undefined) return undefined
31
+ if (transition.type === 'spring') return { spring: toSpringConfig(transition) }
32
+ // Catalog residuals author duration-only (type omitted) or explicit tween.
33
+ if (transition.duration === undefined && transition.type !== 'tween') return undefined
34
+ // Per-segment ease lists require a keyframe-array property target — never layoutId flight
35
+ // (Array of easings, not a 4-number cubic-bezier).
36
+ const ease = transition.ease ?? transition.easings ?? transition.easing
37
+ if (Array.isArray(ease) && ease.length > 0 && typeof ease[0] !== 'number') return undefined
38
+ const timing = toTimingConfig(transition)
39
+ if (timing.duration === undefined && timing.ease === undefined && transition.type !== 'tween')
40
+ return undefined
41
+ const durationOnlyTween =
42
+ transition.type === undefined &&
43
+ transition.duration !== undefined &&
44
+ transition.ease === undefined &&
45
+ transition.easings === undefined &&
46
+ transition.easing === undefined
47
+ if (durationOnlyTween) {
48
+ return { timing: { ...timing, ease: 'easeOut' } }
49
+ }
50
+ return { timing }
51
+ }
@@ -0,0 +1,89 @@
1
+ // The layout binding's brain (specs/M2.5-BUILD-PACKET.md): validated RN onLayout rects → core's
2
+ // projection session. The session owns ALL projection math (FLIP inversion, visual-rect springs,
3
+ // retarget continuity, settle); this layer owns only (a) fail-loud rect validation, (b) the
4
+ // token-keyed previous-rect capture (the FIRST rect is the initial measure — before=null → no
5
+ // projection on mount, fail-closed), and (c) begin-vs-retarget routing (a layout change lands as
6
+ // `begin` from idle and as `retarget` mid-flight).
7
+ //
8
+ import {
9
+ createProjectionSession,
10
+ type MotionGraph,
11
+ type ProjectionSession,
12
+ type Rect,
13
+ type SpringConfig,
14
+ } from '@unrulysystems/native-motion-core'
15
+
16
+ // The fields this binding reads off an RN onLayout payload. Loose on purpose — validation IS the
17
+ // contract; a malformed rect fails loud naming the field, never coerced.
18
+ export interface LayoutRectShape {
19
+ readonly x?: unknown
20
+ readonly y?: unknown
21
+ readonly width?: unknown
22
+ readonly height?: unknown
23
+ }
24
+
25
+ export interface LayoutProjectionOptions {
26
+ readonly graph: MotionGraph
27
+ readonly token: string
28
+ readonly spring?: SpringConfig
29
+ }
30
+
31
+ export interface LayoutProjection {
32
+ // Feed one validated layout rect. First rect = initial measure (no projection); subsequent rects
33
+ // begin (from idle) or retarget (mid-flight) the projection.
34
+ onLayoutRect(rect: LayoutRectShape): void
35
+ // The last validated rect this binding has seen (the projection's layout frame), or null.
36
+ lastRect(): Rect | null
37
+ readonly session: ProjectionSession
38
+ }
39
+
40
+ // Fail-loud field extraction (packet §semantics 6): a missing or non-numeric layout field is a
41
+ // caller/host bug — naming the field beats coercing to 0 and projecting a garbage delta.
42
+ function requireNumber(value: unknown, field: string): number {
43
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
44
+ throw new Error(
45
+ `layout projection: onLayout field '${field}' is ${JSON.stringify(value)} — a finite number ` +
46
+ 'is required and is never coerced (specs/M2.5-BUILD-PACKET.md §semantics 6).',
47
+ )
48
+ }
49
+ return value
50
+ }
51
+
52
+ // Exported for sibling layout bindings (L2's layoutIdBinding) that must apply the SAME
53
+ // fail-loud posture — one validation, reused not forked (specs/L2-BUILD-PACKET.md §semantics 6).
54
+ export function toValidatedLayoutRect(rect: LayoutRectShape): Rect {
55
+ return {
56
+ x: requireNumber(rect.x, 'x'),
57
+ y: requireNumber(rect.y, 'y'),
58
+ width: requireNumber(rect.width, 'width'),
59
+ height: requireNumber(rect.height, 'height'),
60
+ }
61
+ }
62
+
63
+ export function createLayoutProjection(options: LayoutProjectionOptions): LayoutProjection {
64
+ const session = createProjectionSession({
65
+ graph: options.graph,
66
+ ...(options.spring === undefined ? {} : { spring: options.spring }),
67
+ })
68
+ // The projection's layout frame: the last rect the host committed. The FIRST rect is the initial
69
+ // measure — before=null means no projection on mount (fail-closed, packet §semantics 4).
70
+ let last: Rect | null = null
71
+
72
+ return {
73
+ onLayoutRect(shape) {
74
+ const rect = toValidatedLayoutRect(shape) // validate BEFORE any state change — a bad rect never half-applies
75
+ const previous = last
76
+ last = rect
77
+ if (previous === null) return // initial measure: nothing to project from
78
+ // A layout change lands as `begin` from idle and as `retarget` mid-flight — the session owns
79
+ // the continuity math either way (packet §semantics 3).
80
+ if (session.state() === 'idle') {
81
+ session.begin(options.token, previous, rect)
82
+ } else {
83
+ session.retarget(rect)
84
+ }
85
+ },
86
+ lastRect: () => last,
87
+ session,
88
+ }
89
+ }
@@ -0,0 +1,204 @@
1
+ // REQ-LAYOUT-022 / REQ-API-038 — layoutScroll: scroll-offset measurement roots for the
2
+ // layout-identity path. React context stack + measure-handle correction; CORE owns the rect
3
+ // math (accountForLayoutScroll). No parallel measurement stack. Boolean gate lives next to
4
+ // the other layout prop gates in MotionView (same severity path).
5
+ //
6
+ // Pin (motion@12.42.2): HTMLProjectionNode.measureScroll reads instance.scrollLeft/scrollTop
7
+ // (container scroll on the layoutScroll element); DocumentProjectionNode is the always-on
8
+ // window-scroll root. Catalog layout-stress-scroll puts layoutScroll on the overflow:scroll
9
+ // motion.div that IS the scroller.
10
+ //
11
+ // Native (REQ-LAYOUT-022(c), R13 V2 live-offset): stack + pure math, plus a public composition
12
+ // path for live non-zero container scroll without expanding the element set. Closed hosts
13
+ // View/Text/Image still do not emit onScroll on device; product code feeds offsets via
14
+ // LayoutScrollOffsetProvider (consumer ScrollView onScroll / useScroll MotionValue getters).
15
+ // Pin-faithful: measureScroll of a non-scroller is {0,0} until a live source is registered.
16
+
17
+ import {
18
+ accountForLayoutScroll,
19
+ type LayoutScrollOffset,
20
+ type Rect,
21
+ } from '@unrulysystems/native-motion-core'
22
+ import { createContext, createElement, useContext, type ReactNode } from 'react'
23
+ import type { LayoutIdentityMeasureHandle } from './layoutIdBinding'
24
+
25
+ export type { LayoutScrollOffset }
26
+
27
+ export type LayoutScrollOffsetRef = { current: LayoutScrollOffset }
28
+
29
+ /**
30
+ * Pin measureScroll equivalent for native: fold a scroll event into the layoutScroll root's
31
+ * live offset ref. Accepts RN `nativeEvent.contentOffset` and DOM scrollLeft/scrollTop.
32
+ * No-op on unrecognized shapes (never throws — measure path stays total).
33
+ */
34
+ function finiteOffsetComponent(value: unknown, label: string): number {
35
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
36
+ throw new Error(
37
+ `layoutScroll offset ${label} must be a finite number (received ${describeOffset(value)}; REQ-LAYOUT-022)`,
38
+ )
39
+ }
40
+ return value
41
+ }
42
+
43
+ function describeOffset(value: unknown): string {
44
+ if (typeof value === 'number') return String(value)
45
+ return `${typeof value}`
46
+ }
47
+
48
+ function asFiniteOffset(x: unknown, y: unknown): LayoutScrollOffset {
49
+ return {
50
+ x: finiteOffsetComponent(x === undefined ? 0 : x, 'x'),
51
+ y: finiteOffsetComponent(y === undefined ? 0 : y, 'y'),
52
+ }
53
+ }
54
+
55
+ export function recordLayoutScrollOffsetFromEvent(
56
+ event: unknown,
57
+ target: LayoutScrollOffsetRef,
58
+ ): void {
59
+ if (event === null || typeof event !== 'object') return
60
+ const record = event as Record<string, unknown>
61
+ const nativeEvent = record['nativeEvent']
62
+ if (nativeEvent !== null && typeof nativeEvent === 'object') {
63
+ const contentOffset = (nativeEvent as Record<string, unknown>)['contentOffset']
64
+ if (contentOffset !== null && typeof contentOffset === 'object') {
65
+ const offset = contentOffset as { x?: unknown; y?: unknown }
66
+ // Absent axes default to 0; present non-finite values fail loud before projection math.
67
+ target.current = asFiniteOffset(
68
+ offset.x === undefined ? 0 : offset.x,
69
+ offset.y === undefined ? 0 : offset.y,
70
+ )
71
+ return
72
+ }
73
+ }
74
+ if (typeof record['scrollLeft'] === 'number' || typeof record['scrollTop'] === 'number') {
75
+ target.current = asFiniteOffset(
76
+ record['scrollLeft'] === undefined ? 0 : record['scrollLeft'],
77
+ record['scrollTop'] === undefined ? 0 : record['scrollTop'],
78
+ )
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolve the live offset for a layoutScroll root at measure time: prefer host instance
84
+ * fields when present (DOM / instrumented hosts), else the scroll-event-fed ref.
85
+ */
86
+ export function readLayoutScrollRootOffset(
87
+ host: unknown,
88
+ offsetRef: LayoutScrollOffsetRef,
89
+ ): LayoutScrollOffset {
90
+ if (host !== null && typeof host === 'object') {
91
+ const node = host as Record<string, unknown>
92
+ if (typeof node['scrollLeft'] === 'number' || typeof node['scrollTop'] === 'number') {
93
+ return asFiniteOffset(
94
+ typeof node['scrollLeft'] === 'number' ? node['scrollLeft'] : 0,
95
+ typeof node['scrollTop'] === 'number' ? node['scrollTop'] : 0,
96
+ )
97
+ }
98
+ const contentOffset = node['contentOffset']
99
+ if (
100
+ contentOffset !== null &&
101
+ typeof contentOffset === 'object' &&
102
+ ('x' in contentOffset || 'y' in contentOffset)
103
+ ) {
104
+ const co = contentOffset as { x?: unknown; y?: unknown }
105
+ return asFiniteOffset(co.x === undefined ? 0 : co.x, co.y === undefined ? 0 : co.y)
106
+ }
107
+ }
108
+ const current = offsetRef.current
109
+ return asFiniteOffset(current.x, current.y)
110
+ }
111
+
112
+ /** Wrap a measureInWindow handle so the committed rect is content-space (pin removeElementScroll). */
113
+ export function withLayoutScrollCorrection(
114
+ handle: LayoutIdentityMeasureHandle,
115
+ offsets: readonly LayoutScrollOffset[],
116
+ ): LayoutIdentityMeasureHandle {
117
+ if (offsets.length === 0) return handle
118
+ return {
119
+ measureInWindow(callback) {
120
+ handle.measureInWindow((x, y, width, height) => {
121
+ const corrected = accountForLayoutScroll({ x, y, width, height }, offsets)
122
+ callback(corrected.x, corrected.y, corrected.width, corrected.height)
123
+ })
124
+ },
125
+ }
126
+ }
127
+
128
+ /** Snapshot offsets from live getters (read at measure time, not at provider mount). */
129
+ export function readLayoutScrollOffsets(
130
+ getters: readonly (() => LayoutScrollOffset)[],
131
+ ): LayoutScrollOffset[] {
132
+ const offsets: LayoutScrollOffset[] = []
133
+ for (let i = 0; i < getters.length; i++) {
134
+ // The getter's OWN throw propagates as a fault (never caught here); the RESULT's shape is a
135
+ // public-boundary contract (review badd85f67c43): a non-null offset RECORD, never an array —
136
+ // otherwise the axes read raw-TypeErrors or silently defaults to {0,0}.
137
+ const raw: unknown = getters[i]!()
138
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
139
+ const received = raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw
140
+ throw new Error(
141
+ `layoutScroll getOffset() must return an offset record {x, y} (received ${received}; REQ-LAYOUT-022)`,
142
+ )
143
+ }
144
+ const record = raw as { x?: unknown; y?: unknown }
145
+ offsets.push(asFiniteOffset(record.x, record.y))
146
+ }
147
+ return offsets
148
+ }
149
+
150
+ /** Correct a plain rect (test / pre-branded synthetic measure path). */
151
+ export function correctLayoutRectForScroll(
152
+ rect: Rect,
153
+ offsets: readonly LayoutScrollOffset[],
154
+ ): Rect {
155
+ return accountForLayoutScroll(rect, offsets)
156
+ }
157
+
158
+ // Stack of offset getters from layoutScroll=true ancestors (root → leaf).
159
+ const LayoutScrollStackContext = createContext<readonly (() => LayoutScrollOffset)[]>([])
160
+
161
+ export function useLayoutScrollOffsetGetters(): readonly (() => LayoutScrollOffset)[] {
162
+ return useContext(LayoutScrollStackContext)
163
+ }
164
+
165
+ export function useLayoutScrollOffsets(): readonly LayoutScrollOffset[] {
166
+ return readLayoutScrollOffsets(useLayoutScrollOffsetGetters())
167
+ }
168
+
169
+ export function LayoutScrollStackProvider({
170
+ getter,
171
+ children,
172
+ }: {
173
+ readonly getter: () => LayoutScrollOffset
174
+ readonly children: ReactNode
175
+ }): ReactNode {
176
+ const parent = useContext(LayoutScrollStackContext)
177
+ const stack = parent.length === 0 ? [getter] : [...parent, getter]
178
+ return createElement(LayoutScrollStackContext.Provider, { value: stack }, children)
179
+ }
180
+
181
+ /**
182
+ * Public composition (R13 V2 / REQ-LAYOUT-022(c) live-offset): inject a live container-scroll
183
+ * offset into the layoutScroll stack without a new host element. Product wraps scrollable
184
+ * content (e.g. RN ScrollView children) so nested layout / layoutId measures correct for
185
+ * mid-scroll flights. `getOffset` is read at measure time (ref / MotionValue.get), never
186
+ * snapshotted at provider mount.
187
+ */
188
+ export interface LayoutScrollOffsetProviderProps {
189
+ readonly getOffset: () => LayoutScrollOffset
190
+ readonly children: ReactNode
191
+ }
192
+
193
+ export function LayoutScrollOffsetProvider({
194
+ getOffset,
195
+ children,
196
+ }: LayoutScrollOffsetProviderProps): ReactNode {
197
+ // Boundary validate at measure time via readLayoutScrollOffsets (finite x/y).
198
+ return createElement(LayoutScrollStackProvider, { getter: getOffset, children })
199
+ }
200
+
201
+ /** Empty offset ref for ScrollView / useScroll feed composition. */
202
+ export function createLayoutScrollOffsetRef(): LayoutScrollOffsetRef {
203
+ return { current: { x: 0, y: 0 } }
204
+ }