@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
|
@@ -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
|
+
}
|