@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,214 @@
|
|
|
1
|
+
// R15 D5 (REQ-API-046 / REQ-GESTURE-030): redirect drag session live reads, writes, and release
|
|
2
|
+
// commands to consumer-supplied MotionValues (`_dragX` / `_dragY`) instead of the host's own
|
|
3
|
+
// axis channels. The element's transform is never written by the session on an external axis
|
|
4
|
+
// (the pin's getAxisMotionValue branch).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
GESTURE_RELEASE_SPRING,
|
|
8
|
+
inertiaGenerator,
|
|
9
|
+
resolveSpringGenerator,
|
|
10
|
+
type DragAxis,
|
|
11
|
+
type Driver,
|
|
12
|
+
type ElementHandle,
|
|
13
|
+
type InertiaTransition,
|
|
14
|
+
type MotionValue,
|
|
15
|
+
type PropTransition,
|
|
16
|
+
type SpringConfig,
|
|
17
|
+
} from '@unrulysystems/native-motion-core'
|
|
18
|
+
import { resolveDefaultValueChannel } from './valueHooks'
|
|
19
|
+
|
|
20
|
+
export type ExternalDragValues = Partial<Record<DragAxis, MotionValue<number>>>
|
|
21
|
+
|
|
22
|
+
function springConfigFromRelease(transition: PropTransition): SpringConfig {
|
|
23
|
+
if (transition.type === 'spring') {
|
|
24
|
+
return {
|
|
25
|
+
stiffness: transition.stiffness ?? GESTURE_RELEASE_SPRING.stiffness,
|
|
26
|
+
damping: transition.damping ?? GESTURE_RELEASE_SPRING.damping,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ...GESTURE_RELEASE_SPRING }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* R16 (REQ-GESTURE-023/-030, review major 1): the free-drag release's two-phase inertia
|
|
34
|
+
* generator on an external MotionValue — host parity with the driver's `kind:'inertia'`
|
|
35
|
+
* command. The descriptor's physics are used verbatim; the generator recomputes its ideal from
|
|
36
|
+
* the seed and owns the walls (an out-of-bounds origin boundary-springs from rest — it is
|
|
37
|
+
* never jumped to the target synchronously, the pin's t=0 latch). An MV the default graph
|
|
38
|
+
* cannot animate (a hostile/test double) is a consumer error and FAILS LOUD (severity law,
|
|
39
|
+
* review r2 major 1) — never suppressed into the synchronous jump this path forbids.
|
|
40
|
+
*/
|
|
41
|
+
function inertiaReleaseExternal(
|
|
42
|
+
value: MotionValue<number>,
|
|
43
|
+
from: number,
|
|
44
|
+
velocity: number,
|
|
45
|
+
transition: InertiaTransition,
|
|
46
|
+
): void {
|
|
47
|
+
const factory = inertiaGenerator({
|
|
48
|
+
power: transition.power,
|
|
49
|
+
timeConstant: transition.timeConstant,
|
|
50
|
+
bounceStiffness: transition.bounceStiffness,
|
|
51
|
+
bounceDamping: transition.bounceDamping,
|
|
52
|
+
restDelta: transition.restDelta,
|
|
53
|
+
restSpeed: transition.restSpeed,
|
|
54
|
+
...(transition.min !== undefined ? { min: transition.min } : {}),
|
|
55
|
+
...(transition.max !== undefined ? { max: transition.max } : {}),
|
|
56
|
+
})
|
|
57
|
+
value.set(from)
|
|
58
|
+
resolveDefaultValueChannel().graph.animate(value, () => factory({ from, velocity }))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Velocity-seeded spring on an external MotionValue (REQ-GESTURE-022 parity with host
|
|
63
|
+
* driver.command release). The MV must belong to the default value-channel graph
|
|
64
|
+
* (useMotionValue / useSpring followers do).
|
|
65
|
+
*/
|
|
66
|
+
function springReleaseExternal(
|
|
67
|
+
value: MotionValue<number>,
|
|
68
|
+
from: number,
|
|
69
|
+
to: number,
|
|
70
|
+
velocity: number,
|
|
71
|
+
transition: PropTransition,
|
|
72
|
+
): void {
|
|
73
|
+
const config = springConfigFromRelease(transition)
|
|
74
|
+
const factory = resolveSpringGenerator(to, config)
|
|
75
|
+
// Seed the handoff origin so the spring starts from the release sample, not a stale MV.
|
|
76
|
+
value.set(from)
|
|
77
|
+
try {
|
|
78
|
+
resolveDefaultValueChannel().graph.animate(value, () => factory({ from, velocity }))
|
|
79
|
+
} catch {
|
|
80
|
+
// MV not owned by the default graph (hostile/test double) — land at target fail-closed.
|
|
81
|
+
value.set(to)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wrap a Driver so drag traffic for the named axes lands on external MotionValues.
|
|
87
|
+
* Other axes and non-handle traffic pass through unchanged.
|
|
88
|
+
*/
|
|
89
|
+
export function wrapDriverForExternalDragValues(
|
|
90
|
+
driver: Driver,
|
|
91
|
+
handle: ElementHandle,
|
|
92
|
+
external: ExternalDragValues,
|
|
93
|
+
): Driver {
|
|
94
|
+
const hasX = external.x !== undefined
|
|
95
|
+
const hasY = external.y !== undefined
|
|
96
|
+
if (!hasX && !hasY) return driver
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
register: (initial) => driver.register(initial),
|
|
100
|
+
step: (elapsedMs) => driver.step(elapsedMs),
|
|
101
|
+
committed: (h) => driver.committed(h),
|
|
102
|
+
setActive: (h, active) => driver.setActive(h, active),
|
|
103
|
+
isActive: (h) => driver.isActive(h),
|
|
104
|
+
liveFor(h, key) {
|
|
105
|
+
if (h === handle && (key === 'x' || key === 'y')) {
|
|
106
|
+
const mv = external[key]
|
|
107
|
+
if (mv !== undefined) {
|
|
108
|
+
const value = mv.get()
|
|
109
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`external _drag${key.toUpperCase()} must hold a finite number, got ${String(value)} (REQ-API-046).`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
const velocity = mv.getVelocity()
|
|
115
|
+
return { value, velocity: Number.isFinite(velocity) ? velocity : 0 }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return driver.liveFor(h, key)
|
|
119
|
+
},
|
|
120
|
+
write(h, values, velocities) {
|
|
121
|
+
if (h !== handle) {
|
|
122
|
+
driver.write(h, values, velocities)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const rest: Record<string, number> = {}
|
|
126
|
+
const restVel: Record<string, number> = {}
|
|
127
|
+
for (const [key, value] of Object.entries(values)) {
|
|
128
|
+
if ((key === 'x' || key === 'y') && external[key] !== undefined) {
|
|
129
|
+
external[key]!.set(value)
|
|
130
|
+
} else {
|
|
131
|
+
rest[key] = value
|
|
132
|
+
if (velocities !== undefined && velocities[key] !== undefined) {
|
|
133
|
+
restVel[key] = velocities[key]!
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (Object.keys(rest).length > 0) {
|
|
138
|
+
driver.write(h, rest, Object.keys(restVel).length > 0 ? restVel : undefined)
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
command(h, command) {
|
|
142
|
+
if (h !== handle || command.kind !== 'start') {
|
|
143
|
+
driver.command(h, command)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const restTargets: Record<string, (typeof command.targets)[string]> = {}
|
|
147
|
+
const releaseTransition: PropTransition = command.transition
|
|
148
|
+
for (const [key, target] of Object.entries(command.targets)) {
|
|
149
|
+
if ((key === 'x' || key === 'y') && external[key] !== undefined && target !== undefined) {
|
|
150
|
+
const to = target.to
|
|
151
|
+
if (typeof to !== 'number' || !Number.isFinite(to)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`external _drag${key.toUpperCase()} release target must be a finite number (REQ-API-046).`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
const from = typeof target.from === 'number' ? target.from : external[key]!.get()
|
|
157
|
+
// R16 review minor 8: `from` is the generator seed origin — a non-finite one (a hostile
|
|
158
|
+
// MV's get(), or a corrupt descriptor field) would silently NaN the whole trajectory.
|
|
159
|
+
// Same fail-loud law as the `to` check above and the liveFor value guard.
|
|
160
|
+
if (!Number.isFinite(from)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`external _drag${key.toUpperCase()} release origin must be a finite number, got ${String(from)} (REQ-API-046).`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
// Fix-up review: velocity is the last descriptor field without a finiteness guard —
|
|
166
|
+
// `typeof NaN === 'number'`, so a bare typeof check launders a non-finite velocity into
|
|
167
|
+
// a silent NaN trajectory (inertia) or a NaN-seeded spring. Same law as `from`/`to`.
|
|
168
|
+
const velocity = typeof target.velocity === 'number' ? target.velocity : 0
|
|
169
|
+
if (!Number.isFinite(velocity)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`external _drag${key.toUpperCase()} release velocity must be a finite number, got ${String(velocity)} (REQ-API-046).`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
const targetTransition: PropTransition = target.transition ?? command.transition
|
|
175
|
+
if (targetTransition.type === 'inertia') {
|
|
176
|
+
// R16 review minor 7: this channel consumes the raw per-target descriptor WITHOUT
|
|
177
|
+
// core's prepare validation. A non-positive/non-finite boundary-spring pair
|
|
178
|
+
// degenerates the solver to a silent NaN trajectory — refuse it here (the config
|
|
179
|
+
// boundary refuses it at mount on the public path; this guards the direct seam).
|
|
180
|
+
if (
|
|
181
|
+
!Number.isFinite(targetTransition.bounceStiffness) ||
|
|
182
|
+
targetTransition.bounceStiffness <= 0 ||
|
|
183
|
+
!Number.isFinite(targetTransition.bounceDamping) ||
|
|
184
|
+
targetTransition.bounceDamping <= 0
|
|
185
|
+
) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`external _drag${key.toUpperCase()} inertia release requires finite, greater-than-zero ` +
|
|
188
|
+
`bounceStiffness/bounceDamping, got bounceStiffness=${String(targetTransition.bounceStiffness)} ` +
|
|
189
|
+
`bounceDamping=${String(targetTransition.bounceDamping)} (REQ-API-046).`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
// Free-drag release (R16): the per-target inertia descriptor drives the two-phase
|
|
193
|
+
// generator on the external MV — decay + boundary spring, never the obsolete
|
|
194
|
+
// clamped spring and never a synchronous jump on an out-of-bounds origin.
|
|
195
|
+
inertiaReleaseExternal(external[key]!, from, velocity, targetTransition)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
// Cancel / momentum=false (v=0) on the spring path: land without a spring flight.
|
|
199
|
+
// Momentum=true: velocity-seeded spring to project-then-snap target (host parity).
|
|
200
|
+
if (velocity === 0) {
|
|
201
|
+
external[key]!.set(to)
|
|
202
|
+
} else {
|
|
203
|
+
springReleaseExternal(external[key]!, from, to, velocity, releaseTransition)
|
|
204
|
+
}
|
|
205
|
+
} else if (target !== undefined) {
|
|
206
|
+
restTargets[key] = target
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(restTargets).length > 0) {
|
|
210
|
+
driver.command(h, { ...command, targets: restTargets })
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// REQ-API-058 — public `frameData`, the pin's read-only JS-frame clock.
|
|
2
|
+
// Pin: motion-dom frameloop/batcher.ts state `{delta, timestamp, isProcessing}`,
|
|
3
|
+
// first elapsed 1000/60, subsequent delta clamped [1, 40] ms, frozen at idle.
|
|
4
|
+
// Native timebase is the JS-thread clock (packet F1). The `frame` scheduler is not shipped.
|
|
5
|
+
|
|
6
|
+
export interface FrameData {
|
|
7
|
+
delta: number
|
|
8
|
+
timestamp: number
|
|
9
|
+
isProcessing: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createFrameDataState(): FrameData {
|
|
13
|
+
return { delta: 0, timestamp: 0, isProcessing: false }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The public singleton. Mutated in place like the pin's batcher state; never replaced. */
|
|
17
|
+
export const frameData: FrameData = createFrameDataState()
|
|
18
|
+
|
|
19
|
+
const MAX_ELAPSED_MS = 40
|
|
20
|
+
const DEFAULT_ELAPSED_MS = 1000 / 60
|
|
21
|
+
|
|
22
|
+
export function applyFrameDataTick(
|
|
23
|
+
state: FrameData,
|
|
24
|
+
timestamp: number,
|
|
25
|
+
isFirstAfterIdle: boolean,
|
|
26
|
+
): void {
|
|
27
|
+
state.delta = isFirstAfterIdle
|
|
28
|
+
? DEFAULT_ELAPSED_MS
|
|
29
|
+
: Math.max(Math.min(timestamp - state.timestamp, MAX_ELAPSED_MS), 1)
|
|
30
|
+
state.timestamp = timestamp
|
|
31
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The gesture binding's brain (specs/M2.4-BUILD-PACKET.md): platform Pan events → validated
|
|
2
|
+
// GestureSamples → session calls. The session owns EVERY semantic (dead-zone, continuity,
|
|
3
|
+
// constraints, snap, cancel targets, release springs); this layer owns only (a) fail-loud event
|
|
4
|
+
// shape validation (never coerce a missing field to 0 — the M2.2/M2.3 boundary lesson) and (b) the
|
|
5
|
+
// clock discipline: samples are timestamped off the SAME injectable clock the stepper advances.
|
|
6
|
+
//
|
|
7
|
+
import {
|
|
8
|
+
createGestureSession,
|
|
9
|
+
type Clock,
|
|
10
|
+
type DragConstraints,
|
|
11
|
+
type GestureSample,
|
|
12
|
+
type GestureSession,
|
|
13
|
+
type MotionGraph,
|
|
14
|
+
type SnapConfig,
|
|
15
|
+
type SpringConfig,
|
|
16
|
+
} from '@unrulysystems/native-motion-core'
|
|
17
|
+
|
|
18
|
+
// The fields this binding reads off a gesture-handler Pan event. Kept loose (unknown) because the
|
|
19
|
+
// validation IS the contract — a malformed event fails loud at this boundary.
|
|
20
|
+
export interface PanEventShape {
|
|
21
|
+
readonly translationX?: unknown
|
|
22
|
+
readonly velocityX?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GestureBindingOptions {
|
|
26
|
+
readonly graph: MotionGraph
|
|
27
|
+
// The same clock the stepper advances — never Date.now(), never a second time source.
|
|
28
|
+
readonly clock: Clock
|
|
29
|
+
readonly initial?: number
|
|
30
|
+
readonly constraints?: DragConstraints
|
|
31
|
+
readonly snap?: SnapConfig
|
|
32
|
+
readonly releaseSpring?: SpringConfig
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GestureBinding {
|
|
36
|
+
onStart(event: PanEventShape): void
|
|
37
|
+
onUpdate(event: PanEventShape): void
|
|
38
|
+
onEnd(event: PanEventShape): void
|
|
39
|
+
onCancel(): void
|
|
40
|
+
onFail(): void
|
|
41
|
+
readonly session: GestureSession
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fail-loud field extraction (packet §semantics 7): a missing or non-numeric recognizer field is a
|
|
45
|
+
// caller/platform bug — naming the field beats silently coercing to 0 and dragging to the origin.
|
|
46
|
+
function requireNumber(value: unknown, field: string): number {
|
|
47
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`gesture binding: Pan event field '${field}' is ${JSON.stringify(value)} — a finite number ` +
|
|
50
|
+
'is required and is never coerced (specs/M2.4-BUILD-PACKET.md §semantics 7).',
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createGestureBinding(options: GestureBindingOptions): GestureBinding {
|
|
57
|
+
const session = createGestureSession({
|
|
58
|
+
graph: options.graph,
|
|
59
|
+
...(options.initial === undefined ? {} : { initial: options.initial }),
|
|
60
|
+
...(options.constraints === undefined ? {} : { constraints: options.constraints }),
|
|
61
|
+
...(options.snap === undefined ? {} : { snap: options.snap }),
|
|
62
|
+
...(options.releaseSpring === undefined ? {} : { releaseSpring: options.releaseSpring }),
|
|
63
|
+
})
|
|
64
|
+
const clock = options.clock
|
|
65
|
+
|
|
66
|
+
// Validate BEFORE any session call so a malformed event never half-applies, and timestamp off the
|
|
67
|
+
// injected clock — the platform velocity passes through untouched (§G).
|
|
68
|
+
function toSample(event: PanEventShape): GestureSample {
|
|
69
|
+
return {
|
|
70
|
+
t: clock.now(),
|
|
71
|
+
translation: requireNumber(event.translationX, 'translationX'),
|
|
72
|
+
velocity: requireNumber(event.velocityX, 'velocityX'),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
onStart(event) {
|
|
78
|
+
session.begin(requireNumber(event.translationX, 'translationX'))
|
|
79
|
+
},
|
|
80
|
+
onUpdate(event) {
|
|
81
|
+
session.active(toSample(event))
|
|
82
|
+
},
|
|
83
|
+
onEnd(event) {
|
|
84
|
+
session.end(toSample(event))
|
|
85
|
+
},
|
|
86
|
+
onCancel() {
|
|
87
|
+
session.cancel()
|
|
88
|
+
},
|
|
89
|
+
onFail() {
|
|
90
|
+
session.fail()
|
|
91
|
+
},
|
|
92
|
+
session,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// RNGH end/finalize routing (review cycle 1 major 1): gesture-handler fires `onEnd(event, false)`
|
|
97
|
+
// for gestures that FAIL or are CANCELLED — before `onFinalize(event, false)`. Only a SUCCESSFUL
|
|
98
|
+
// end is a release; an unsuccessful end must leave the session active so the finalize path can
|
|
99
|
+
// cancel to the pre-grab value (REQ-GESTURE-018 totality). These helpers ARE the screen's wiring,
|
|
100
|
+
// exported so the checks drive the exact routing.
|
|
101
|
+
export function routePanEnd(binding: GestureBinding, event: PanEventShape, success: boolean): void {
|
|
102
|
+
if (success) binding.onEnd(event)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function routePanFinalize(binding: GestureBinding, success: boolean): void {
|
|
106
|
+
if (!success && binding.session.state() === 'active') binding.onCancel()
|
|
107
|
+
}
|