@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,901 @@
1
+ // The public value-channel hooks on the native entry (REQ-API-025/-026/-027): pinned-oracle names
2
+ // and semantics over CORE's graph — useMotionValue returns the exact graph value the driver seam
3
+ // reads (never a wrapper copy), useTransform derives on core's JS-side graph, useSpring couples a
4
+ // follower through core's spring resolution, useVelocity mirrors core's velocity law. The web
5
+ // entry re-exports motion/react's own hooks under the same names (type-identity namespace parity).
6
+ //
7
+ // Transform parameters re-apply on every commit like the pinned hook (the dependency set alone
8
+ // is fixed at first render); spring coupling identity is fixed per mount. Subscriptions attach
9
+ // per effect lifetime, so a StrictMode replay detaches and re-attaches without orphaning work.
10
+
11
+ import {
12
+ createContext,
13
+ createElement,
14
+ useContext,
15
+ useInsertionEffect,
16
+ useRef,
17
+ type ReactNode,
18
+ } from 'react'
19
+ import {
20
+ collectMotionValueReads,
21
+ mix,
22
+ parseValue,
23
+ resolveSpringGenerator,
24
+ transformValue,
25
+ type MotionValue,
26
+ type MotionValueEvent,
27
+ type SpringConfig,
28
+ } from '@unrulysystems/native-motion-core'
29
+ import {
30
+ createDirectPublicMotionValue,
31
+ type MotionValueDriverPort,
32
+ } from '@unrulysystems/native-motion-core/internal-driver'
33
+ import { createValueChannel, type ValueChannel } from './valueChannel'
34
+
35
+ // ——— channel injection ———————————————————————————————————————————————————————————————————————
36
+
37
+ const ValueChannelContext = createContext<ValueChannel | null>(null)
38
+
39
+ /**
40
+ * Private native observation boundary for an identity-channel MotionValue's transported cache.
41
+ * The cache owns velocity authority; observer frames only decide when React-side consumers
42
+ * re-read it. Keeping both behind one injected port makes frame cadence deterministic without a
43
+ * synchronous source read or a wall-clock timer.
44
+ */
45
+ export interface ValueHooksIdentityChannelPort {
46
+ now(): number
47
+ readTransportedCachedVelocity(source: MotionValue<number>, atTimestampMs: number): number
48
+ subscribeTransportedChange(source: MotionValue<number>, listener: () => void): () => void
49
+ requestObserverFrame(callback: (atTimestampMs: number) => void): () => void
50
+ observeSynchronousCrossings(): ValueHooksIdentityChannelCrossingObservation
51
+ }
52
+
53
+ export interface ValueHooksIdentityChannelCrossingObservation {
54
+ readonly synchronousJsToUiReadCount: number
55
+ }
56
+
57
+ export interface ValueHooksIdentityChannelCrossingObserver {
58
+ observe(): ValueHooksIdentityChannelCrossingObservation
59
+ }
60
+
61
+ export interface CanonicalValueHooksIdentityRegistration {
62
+ readonly source: MotionValue<number>
63
+ readonly driverPort: MotionValueDriverPort<number>
64
+ }
65
+
66
+ export interface CreateCanonicalValueHooksIdentityChannelPortOptions {
67
+ readonly registrations: readonly CanonicalValueHooksIdentityRegistration[]
68
+ readonly clock: { now(): number }
69
+ readonly observerScheduler: {
70
+ requestObserverFrame(callback: (atTimestampMs: number) => void): () => void
71
+ }
72
+ /** Read-only instrumentation owned by the crossing adapter, never by the cache-read callback. */
73
+ readonly crossingObserver: ValueHooksIdentityChannelCrossingObserver
74
+ }
75
+
76
+ export type ValueHooksIdentityChannelInvariant =
77
+ | 'UNREGISTERED_CANONICAL_SOURCE'
78
+ | 'MISMATCHED_CANONICAL_SOURCE'
79
+ | 'MISSING_CANONICAL_CHANNEL_PORT'
80
+ | 'DUPLICATE_CANONICAL_CHANNEL_PORT'
81
+
82
+ export class ValueHooksIdentityChannelInvariantError extends Error {
83
+ override name = 'ValueHooksIdentityChannelInvariantError'
84
+ readonly code = 'NATIVE_MOTION_VALUE_HOOKS_IDENTITY_CHANNEL_INVARIANT' as const
85
+ readonly invariant: ValueHooksIdentityChannelInvariant
86
+
87
+ constructor(invariant: ValueHooksIdentityChannelInvariant) {
88
+ super(`native-motion: value-hooks identity-channel invariant '${invariant}' failed`)
89
+ this.invariant = invariant
90
+ }
91
+ }
92
+
93
+ function canonicalRegistrationFor(
94
+ registrations: readonly CanonicalValueHooksIdentityRegistration[],
95
+ source: MotionValue<number>,
96
+ ): CanonicalValueHooksIdentityRegistration {
97
+ const registration = registrations.find((candidate) => candidate.source === source)
98
+ if (registration === undefined) {
99
+ throw new ValueHooksIdentityChannelInvariantError('UNREGISTERED_CANONICAL_SOURCE')
100
+ }
101
+ if (registration.driverPort.value !== source) {
102
+ throw new ValueHooksIdentityChannelInvariantError('MISMATCHED_CANONICAL_SOURCE')
103
+ }
104
+ return registration
105
+ }
106
+
107
+ /**
108
+ * Production-shaped adapter from the exact-identity CORE port to hook observation. Cache reads
109
+ * stay on JS; the separately owned crossing observer makes that claim measurable rather than
110
+ * letting an injected read callback self-attest.
111
+ */
112
+ export function createCanonicalValueHooksIdentityChannelPort(
113
+ options: CreateCanonicalValueHooksIdentityChannelPortOptions,
114
+ ): ValueHooksIdentityChannelPort {
115
+ return {
116
+ now: () => options.clock.now(),
117
+ readTransportedCachedVelocity(source, atTimestampMs) {
118
+ return canonicalRegistrationFor(options.registrations, source).driverPort.readCachedVelocity(
119
+ atTimestampMs,
120
+ )
121
+ },
122
+ subscribeTransportedChange(source, listener) {
123
+ canonicalRegistrationFor(options.registrations, source)
124
+ return source.subscribe(() => listener())
125
+ },
126
+ requestObserverFrame: (callback) => options.observerScheduler.requestObserverFrame(callback),
127
+ observeSynchronousCrossings: () => options.crossingObserver.observe(),
128
+ }
129
+ }
130
+
131
+ export type ValueHooksIdentityChannelResolution =
132
+ | { readonly kind: 'EXPLICIT_PROVIDER'; readonly port: ValueHooksIdentityChannelPort }
133
+ | { readonly kind: 'CANONICAL_VALUE_CHANNEL'; readonly port: ValueHooksIdentityChannelPort }
134
+
135
+ const canonicalIdentityPorts = new WeakMap<ValueChannel, ValueHooksIdentityChannelPort>()
136
+
137
+ /** Registers the one canonical identity observer for a channel; cleanup is identity-guarded. */
138
+ export function registerCanonicalValueHooksIdentityChannelPort(
139
+ channel: ValueChannel,
140
+ port: ValueHooksIdentityChannelPort,
141
+ ): () => void {
142
+ if (canonicalIdentityPorts.has(channel)) {
143
+ throw new ValueHooksIdentityChannelInvariantError('DUPLICATE_CANONICAL_CHANNEL_PORT')
144
+ }
145
+ canonicalIdentityPorts.set(channel, port)
146
+ return () => {
147
+ if (canonicalIdentityPorts.get(channel) === port) canonicalIdentityPorts.delete(channel)
148
+ }
149
+ }
150
+
151
+ /** The production resolution seam `useVelocity` must consume; explicit injection is test-only. */
152
+ export function resolveValueHooksIdentityChannelPort(
153
+ channel: ValueChannel,
154
+ explicitPort: ValueHooksIdentityChannelPort | null,
155
+ ): ValueHooksIdentityChannelResolution {
156
+ if (explicitPort !== null) return { kind: 'EXPLICIT_PROVIDER', port: explicitPort }
157
+ const port = canonicalIdentityPorts.get(channel)
158
+ if (port === undefined) {
159
+ throw new ValueHooksIdentityChannelInvariantError('MISSING_CANONICAL_CHANNEL_PORT')
160
+ }
161
+ return { kind: 'CANONICAL_VALUE_CHANNEL', port }
162
+ }
163
+
164
+ const ValueHooksIdentityChannelContext = createContext<ValueHooksIdentityChannelPort | null>(null)
165
+
166
+ export function ValueHooksIdentityChannelProvider(props: {
167
+ readonly port: ValueHooksIdentityChannelPort
168
+ readonly children?: ReactNode
169
+ }): ReturnType<typeof createElement> {
170
+ return createElement(
171
+ ValueHooksIdentityChannelContext.Provider,
172
+ { value: props.port },
173
+ props.children,
174
+ )
175
+ }
176
+
177
+ // The app-default runtime: ONE module singleton so values compose across the whole tree (the
178
+ // pinned oracle's global frameloop model). Lazy so tests that always inject never create it.
179
+ let defaultChannel: ValueChannel | null = null
180
+ /**
181
+ * The one app-default graph. Imperative commands import this resolver instead of creating a
182
+ * sibling singleton, so standalone animation and hook-created MotionValues share interruption
183
+ * and settle accounting.
184
+ */
185
+ export function resolveDefaultValueChannel(): ValueChannel {
186
+ if (defaultChannel === null) defaultChannel = createValueChannel()
187
+ return defaultChannel
188
+ }
189
+
190
+ /** Test seam for deterministic frames; production always uses the lazy app-default graph. */
191
+ export function setDefaultValueChannelForTests(channel: ValueChannel | null): void {
192
+ defaultChannel = channel
193
+ }
194
+
195
+ export function ValueChannelProvider(props: {
196
+ readonly channel: ValueChannel
197
+ readonly children?: ReactNode
198
+ }): ReturnType<typeof createElement> {
199
+ return createElement(ValueChannelContext.Provider, { value: props.channel }, props.children)
200
+ }
201
+
202
+ export function useValueChannel(): ValueChannel {
203
+ return useContext(ValueChannelContext) ?? resolveDefaultValueChannel()
204
+ }
205
+
206
+ // ——— useMotionValue (REQ-API-025) ————————————————————————————————————————————————————————————
207
+
208
+ export function useMotionValue<T = number>(initial: T): MotionValue<T> {
209
+ const channel = useValueChannel()
210
+ const ref = useRef<MotionValue<T> | null>(null)
211
+ if (ref.current === null) ref.current = createDirectPublicMotionValue(channel.graph, initial)
212
+ return ref.current
213
+ }
214
+
215
+ /**
216
+ * Native subsystem factory for graph-fed values such as scroll offsets. It is intentionally absent
217
+ * from the package entry: only a consumer's direct `useMotionValue` call receives public-direct
218
+ * provenance.
219
+ */
220
+ export function useInternalMotionValue<T = number>(initial: T): MotionValue<T> {
221
+ const channel = useValueChannel()
222
+ const ref = useRef<MotionValue<T> | null>(null)
223
+ if (ref.current === null) ref.current = channel.graph.motionValue(initial)
224
+ return ref.current
225
+ }
226
+
227
+ // ——— useMotionValueEvent (REQ-API-027) ———————————————————————————————————————————————————————
228
+
229
+ export function useMotionValueEvent<T>(
230
+ value: MotionValue<T>,
231
+ event: 'change',
232
+ handler: (latest: T) => void,
233
+ ): void
234
+ export function useMotionValueEvent<T>(
235
+ value: MotionValue<T>,
236
+ event: Exclude<MotionValueEvent, 'change'>,
237
+ handler: () => void,
238
+ ): void
239
+ export function useMotionValueEvent<T>(
240
+ value: MotionValue<T>,
241
+ event: MotionValueEvent,
242
+ handler: ((latest: T) => void) | (() => void),
243
+ ): void {
244
+ // Latest-ref so the subscription never holds a stale closure (the presence-rig pattern).
245
+ // Payload typing follows the pinned union (r2 major a66e69c0f004): 'change' delivers the
246
+ // latest value; the lifecycle events carry no payload. The change handler FORWARDS the
247
+ // event's own payload (r16 major 2) — a later re-read of mutable state corrupts event
248
+ // identity under reentrant writes.
249
+ const latest = useRef(handler)
250
+ latest.current = handler
251
+ useInsertionEffect(() => {
252
+ if (event === 'change') {
253
+ return value.on('change', (latestValue: T) =>
254
+ (latest.current as (payload: T) => void)(latestValue),
255
+ )
256
+ }
257
+ return value.on(event as Exclude<MotionValueEvent, 'change'>, () =>
258
+ (latest.current as () => void)(),
259
+ )
260
+ }, [value, event])
261
+ }
262
+
263
+ // ——— useTransform (REQ-API-027, the five pinned public forms) ————————————————————————————————
264
+
265
+ export type SingleTransformer<I, O> = (input: I) => O
266
+ export type MultiTransformer<I, O> = (inputs: I[]) => O
267
+ export type EasingFunction = (progress: number) => number
268
+
269
+ // Generic like the pinned motion-dom TransformOptions<T> (r13 major 4 — the name must resolve
270
+ // identically through both entries, including the generic form); `never` default keeps the
271
+ // bare name valid, matching the web entry's re-surfaced default.
272
+ export interface TransformOptions<T = never> {
273
+ /** Clamp input to the range (the pinned default). */
274
+ readonly clamp?: boolean
275
+ /**
276
+ * Easing between each pair of range stops (pinned contract: an array must be one item
277
+ * shorter than the ranges — one easing per segment).
278
+ */
279
+ readonly ease?: EasingFunction | readonly EasingFunction[]
280
+ /** Custom per-segment interpolator, overriding the typed mix (pinned contract). */
281
+ readonly mixer?: (from: T, to: T) => (progress: number) => unknown
282
+ }
283
+
284
+ // A faithful port of the pinned interpolate (motion-dom utils/interpolate.ts at the §B pin,
285
+ // review r3): single-stop and equal-two-output constants; the zero-delta first-segment guard;
286
+ // a highest→lowest input reverses BOTH ranges but NOT the ease array (ease[i] belongs to the
287
+ // i-th segment of the REVERSED ranges); mixers are created ONCE per segment with the easing
288
+ // piped in; clamp bounds the INPUT to the range ends. The typed default mixer rides core's
289
+ // parity-pinned value types (numbers lerp; strings parse once per endpoint and mix per
290
+ // family). One deliberate, recorded divergence: a short ease array is a LOUD failure here —
291
+ // the pin silently pipes a broken segment (fail-loud law over pinned undefined behavior).
292
+ function buildInterpolator(
293
+ inputRangeAuthored: readonly number[],
294
+ outputRangeAuthored: readonly (number | string)[],
295
+ options: TransformOptions,
296
+ ): (input: number) => unknown {
297
+ // The shipped mixing families are number and string (core parseValue's parity-pinned
298
+ // types). Objects/composites belong to the value-breadth successor work — without a custom
299
+ // mixer they fail TYPED here (r14 major 9), never as parseValue's raw internals.
300
+ if (options.mixer === undefined) {
301
+ for (const output of outputRangeAuthored) {
302
+ if (typeof output !== 'number' && typeof output !== 'string') {
303
+ throw new Error(
304
+ `useTransform: output ${JSON.stringify(output)} is outside the shipped mixing ` +
305
+ 'families (number | string) — object/composite outputs are deferred to the ' +
306
+ 'value-breadth rung; supply options.mixer to interpolate them (REQ-API-027, ' +
307
+ 'specs/R4-VALUE-CHANNEL-BUILD-PACKET.md).',
308
+ )
309
+ }
310
+ }
311
+ }
312
+ if (inputRangeAuthored.length !== outputRangeAuthored.length) {
313
+ throw new Error(
314
+ `useTransform: inputRange and outputRange must be the same length ` +
315
+ `(got ${inputRangeAuthored.length}/${outputRangeAuthored.length}) — REQ-API-027`,
316
+ )
317
+ }
318
+ if (inputRangeAuthored.length === 1) return () => outputRangeAuthored[0]
319
+ if (inputRangeAuthored.length === 2 && outputRangeAuthored[0] === outputRangeAuthored[1]) {
320
+ return () => outputRangeAuthored[1]
321
+ }
322
+ const isZeroDeltaRange = inputRangeAuthored[0] === inputRangeAuthored[1]
323
+ const descending = inputRangeAuthored[0]! > inputRangeAuthored[inputRangeAuthored.length - 1]!
324
+ const input = descending ? [...inputRangeAuthored].reverse() : inputRangeAuthored
325
+ const output = descending ? [...outputRangeAuthored].reverse() : outputRangeAuthored
326
+
327
+ const segments = input.length - 1
328
+ const easings: readonly EasingFunction[] | null =
329
+ options.ease === undefined
330
+ ? null
331
+ : Array.isArray(options.ease)
332
+ ? options.ease
333
+ : Array.from({ length: segments }, () => options.ease as EasingFunction)
334
+ if (easings !== null && easings.length !== segments) {
335
+ throw new Error(
336
+ `useTransform: an ease array must be one item shorter than the ranges ` +
337
+ `(${segments} segments, got ${easings.length} easings) — the pinned contract`,
338
+ )
339
+ }
340
+ const mixerFactory = options.mixer as
341
+ | ((from: number | string, to: number | string) => (progress: number) => unknown)
342
+ | undefined
343
+ // Mixers once per segment (the pinned createMixers), easing piped into each.
344
+ const mixers: Array<(progress: number) => unknown> = []
345
+ for (let i = 0; i < segments; i++) {
346
+ const from = output[i]!
347
+ const to = output[i + 1]!
348
+ let segmentMixer: (progress: number) => unknown
349
+ if (mixerFactory !== undefined) {
350
+ segmentMixer = mixerFactory(from, to)
351
+ } else if (typeof from === 'number' && typeof to === 'number') {
352
+ segmentMixer = (progress) => from + (to - from) * progress
353
+ } else {
354
+ const typedFrom = parseValue(typeof from === 'number' ? String(from) : from)
355
+ const typedTo = parseValue(typeof to === 'number' ? String(to) : to)
356
+ segmentMixer = (progress) => transformValue(mix(typedFrom, typedTo, progress))
357
+ }
358
+ if (easings !== null) {
359
+ const easing = easings[i]!
360
+ const baseMixer = segmentMixer
361
+ segmentMixer = (progress) => baseMixer(easing(progress))
362
+ }
363
+ mixers.push(segmentMixer)
364
+ }
365
+
366
+ const interpolator = (value: number): unknown => {
367
+ if (isZeroDeltaRange && value < input[0]!) return output[0]
368
+ let i = 0
369
+ if (mixers.length > 1) {
370
+ for (; i < input.length - 2; i++) {
371
+ if (value < input[i + 1]!) break
372
+ }
373
+ }
374
+ const from = input[i]!
375
+ const to = input[i + 1]!
376
+ // Pinned motion-utils progress: a zero-delta segment reports progress 1, not 0.
377
+ const progress = to === from ? 1 : (value - from) / (to - from)
378
+ return mixers[i]!(progress)
379
+ }
380
+ const clampInput = options.clamp !== false
381
+ return clampInput
382
+ ? (value: number) =>
383
+ interpolator(Math.min(Math.max(value, input[0]!), input[input.length - 1]!))
384
+ : interpolator
385
+ }
386
+
387
+ export function useTransform<O extends number | string>(
388
+ value: MotionValue<number>,
389
+ inputRange: readonly number[],
390
+ outputRange: readonly O[],
391
+ // The generic FLOWS into the options (r16 major 4, the pinned TransformOptions<O>): a
392
+ // custom mixer's inferred params are O, never `never`.
393
+ options?: TransformOptions<O>,
394
+ ): MotionValue<O>
395
+ // The documented mixer EXCEPTION (r15 7a0e46b8f213): arbitrary output types compile when — and
396
+ // only when — a custom `mixer` interpolates them (`mixer` REQUIRED here; the narrowed form
397
+ // above owns the mixer-less calls). Runtime already routes every segment through the supplied
398
+ // mixer, so no runtime branch changes.
399
+ export function useTransform<O>(
400
+ value: MotionValue<number>,
401
+ inputRange: readonly number[],
402
+ outputRange: readonly O[],
403
+ options: Omit<TransformOptions<O>, 'mixer'> & {
404
+ readonly mixer: (from: O, to: O) => (progress: number) => unknown
405
+ },
406
+ ): MotionValue<O>
407
+ // The pinned OUTPUT-MAP form (r11): one input, an object of output ranges, a record of derived
408
+ // values back — keys must stay constant across renders (the pinned contract).
409
+ export function useTransform<T extends Record<string, readonly (number | string)[]>>(
410
+ value: MotionValue<number>,
411
+ inputRange: readonly number[],
412
+ outputMap: T,
413
+ // Pinned: TransformOptions<T[keyof T][number]> — the mixer sees the union of output types.
414
+ options?: TransformOptions<T[keyof T][number]>,
415
+ ): { [K in keyof T]: MotionValue<T[K][number]> }
416
+ export function useTransform<I, O>(
417
+ input: MotionValue<I>,
418
+ transformer: SingleTransformer<I, O>,
419
+ ): MotionValue<O>
420
+ export function useTransform<I, O>(
421
+ input: readonly MotionValue<I>[],
422
+ transformer: MultiTransformer<I, O>,
423
+ ): MotionValue<O>
424
+ export function useTransform<O>(transformer: () => O): MotionValue<O>
425
+ export function useTransform(
426
+ first: MotionValue<unknown> | readonly MotionValue<unknown>[] | (() => unknown),
427
+ second?: unknown,
428
+ third?: unknown,
429
+ fourth?: unknown,
430
+ ): MotionValue<unknown> | Record<string, MotionValue<unknown>> {
431
+ const channel = useValueChannel()
432
+ // The output-map form derives ONE value per key over a shared input (r11, the pinned fifth
433
+ // form); every other form derives a single value. Both ride the same handle lifecycle.
434
+ const isOutputMap =
435
+ isMotionValue(first) &&
436
+ Array.isArray(second) &&
437
+ typeof third === 'object' &&
438
+ third !== null &&
439
+ !Array.isArray(third)
440
+ // Computes ride refs refreshed EVERY render, so re-parameterized ranges/transformers take
441
+ // effect like the pinned hook (r2 — never silently init-once); the dependency set and (for
442
+ // the map form) the KEY SET stay fixed at first render (the pinned contract).
443
+ const computesRef = useRef<Record<string, () => unknown>>({})
444
+ if (isOutputMap) {
445
+ const source = first as MotionValue<number>
446
+ const inputRange = second as readonly number[]
447
+ const outputMap = third as Record<string, readonly (number | string)[]>
448
+ const options = (fourth as TransformOptions | undefined) ?? {}
449
+ for (const [key, outputRange] of Object.entries(outputMap)) {
450
+ const interpolate = buildInterpolator(inputRange, outputRange, options)
451
+ computesRef.current[key] = () => interpolate(source.get())
452
+ }
453
+ } else {
454
+ computesRef.current[SINGLE_KEY] = resolveTransformCompute(first, second, third, fourth)
455
+ }
456
+ const ref = useRef<Record<string, DerivedHandle> | null>(null)
457
+ if (ref.current === null) {
458
+ const handles: Record<string, DerivedHandle> = {}
459
+ const dependencies = resolveTransformDependencies(first)
460
+ for (const key of Object.keys(computesRef.current)) {
461
+ handles[key] = deriveFrom(channel, dependencies, () => computesRef.current[key]!())
462
+ // Render-phase creation leaves NO live input subscription (r8): an aborted concurrent
463
+ // render or a discarded StrictMode render must never retain the sources. The initial
464
+ // value was computed at construction; the commit's attach below resubscribes and resyncs.
465
+ handles[key].detach()
466
+ }
467
+ ref.current = handles
468
+ } else {
469
+ // Re-parameterization applies SYNCHRONOUSLY during render like the pinned hook (r4: a
470
+ // consumer reading during the same render pass must see the new mapping, not wait for a
471
+ // passive effect). The outputs are non-React stores, so a render-phase resync is legal.
472
+ for (const handle of Object.values(ref.current)) handle.refresh()
473
+ }
474
+ // Subscriptions attach per INSERTION-effect lifetime (r8 — live before any layout effect can
475
+ // write); a StrictMode replay detaches and RE-attaches the same output values (r3).
476
+ useInsertionEffect(() => {
477
+ const handles = Object.values(ref.current!)
478
+ for (const handle of handles) handle.attach()
479
+ return () => {
480
+ for (const handle of handles) handle.detach()
481
+ }
482
+ }, [])
483
+ // The record itself is identity-stable across renders (r12 major b27f0d61ac48 — the pinned
484
+ // hook's useConstant): consumers hold it in dependency arrays and memo comparisons.
485
+ const recordRef = useRef<Record<string, MotionValue<unknown>> | null>(null)
486
+ if (isOutputMap) {
487
+ if (recordRef.current === null) {
488
+ const out: Record<string, MotionValue<unknown>> = {}
489
+ for (const [key, handle] of Object.entries(ref.current)) out[key] = handle.value
490
+ recordRef.current = out
491
+ }
492
+ return recordRef.current
493
+ }
494
+ return ref.current[SINGLE_KEY]!.value
495
+ }
496
+
497
+ // The single-output forms store their one compute/handle under this reserved key.
498
+ const SINGLE_KEY = '__single__'
499
+
500
+ // The dependency set per pinned form, resolved ONCE at first render.
501
+ function resolveTransformDependencies(
502
+ first: MotionValue<unknown> | readonly MotionValue<unknown>[] | (() => unknown),
503
+ ): readonly MotionValue<unknown>[] {
504
+ if (typeof first === 'function') return [...collectMotionValueReads(first).reads]
505
+ if (Array.isArray(first)) return first as readonly MotionValue<unknown>[]
506
+ return [first as MotionValue<unknown>]
507
+ }
508
+
509
+ // The compute per pinned form, rebuilt every render from the CURRENT arguments.
510
+ function resolveTransformCompute(
511
+ first: MotionValue<unknown> | readonly MotionValue<unknown>[] | (() => unknown),
512
+ second?: unknown,
513
+ third?: unknown,
514
+ fourth?: unknown,
515
+ ): () => unknown {
516
+ // Form 4 — reactive compute.
517
+ if (typeof first === 'function') return first as () => unknown
518
+ // Form 1 — range mapping.
519
+ if (isMotionValue(first) && Array.isArray(second)) {
520
+ const interpolate = buildInterpolator(
521
+ second as readonly number[],
522
+ third as readonly (number | string)[],
523
+ (fourth as TransformOptions | undefined) ?? {},
524
+ )
525
+ const source = first as MotionValue<number>
526
+ return () => interpolate(source.get())
527
+ }
528
+ // Form 3 — multi-input function.
529
+ if (Array.isArray(first)) {
530
+ const inputs = first as readonly MotionValue<unknown>[]
531
+ const transformer = second as MultiTransformer<unknown, unknown>
532
+ return () => transformer(inputs.map((input) => input.get()))
533
+ }
534
+ // Form 2 — single-input function.
535
+ const input = first as MotionValue<unknown>
536
+ const transformer = second as SingleTransformer<unknown, unknown>
537
+ return () => transformer(input.get())
538
+ }
539
+
540
+ function isMotionValue(candidate: unknown): candidate is MotionValue<unknown> {
541
+ return (
542
+ typeof candidate === 'object' &&
543
+ candidate !== null &&
544
+ typeof (candidate as MotionValue<unknown>).get === 'function' &&
545
+ typeof (candidate as MotionValue<unknown>).subscribe === 'function' &&
546
+ typeof (candidate as MotionValue<unknown>).getVelocity === 'function'
547
+ )
548
+ }
549
+
550
+ interface DerivedHandle {
551
+ readonly value: MotionValue<unknown>
552
+ attach(): void
553
+ detach(): void
554
+ refresh(): void
555
+ }
556
+
557
+ // EVERY derivation rides CORE's derived graph (r5: string/color inputs included — the graph's
558
+ // DerivedSource contract is structural over any MotionValue<T>), so frame-coalesced topological
559
+ // flushing makes ALL diamonds emit one final value (REQ-VALUE-013), and the re-attachable
560
+ // lifecycle (attach/detach/refresh) survives StrictMode replays with pending dirt CANCELLED on
561
+ // detach. No subscription fallback exists — one derivation authority.
562
+ function deriveFrom(
563
+ channel: ValueChannel,
564
+ dependencies: readonly MotionValue<unknown>[],
565
+ compute: () => unknown,
566
+ ): DerivedHandle {
567
+ const value = channel.graph.derived(dependencies, () => compute())
568
+ return {
569
+ value,
570
+ attach: () => value.attachInputs(),
571
+ detach: () => value.detachInputs(),
572
+ refresh: () => value.refresh(),
573
+ }
574
+ }
575
+
576
+ // ——— useMotionTemplate (REQ-API-025, U5) ———————————————————————————————————————————————————————
577
+
578
+ // A faithful port of the pinned tagged-template hook (framer-motion/src/value/
579
+ // use-motion-template.ts:28-52 at the §B pin) over the same deriveFrom machinery useTransform
580
+ // rides — exactly as the pin's delegates to useCombineMotionValues (use-combine-values.ts:7-45):
581
+ // the FIRST value computes synchronously at creation, the compute is rebuilt EVERY render so
582
+ // static (non-MotionValue) interpolations read the CURRENT closure (the pin's buildValue), only
583
+ // MotionValue interpolations are dependencies, and attach/detach follows the insertion-effect
584
+ // lifetime (the pinned unsubscribe + cancelFrame cleanup).
585
+ export function useMotionTemplate(
586
+ fragments: TemplateStringsArray,
587
+ ...values: Array<MotionValue<unknown> | number | string>
588
+ ): MotionValue<string> {
589
+ const channel = useValueChannel()
590
+ const buildRef = useRef<() => string>(() => '')
591
+ buildRef.current = () => {
592
+ let output = ``
593
+ for (let i = 0; i < fragments.length; i++) {
594
+ output += fragments[i]
595
+ const value = values[i]
596
+ // The pinned truthiness check verbatim (use-motion-template.ts:43): a falsy static
597
+ // interpolation (0, '') is OMITTED, not rendered.
598
+ if (value) output += (isMotionValue(value) ? value.get() : value) as string | number
599
+ }
600
+ return output
601
+ }
602
+ const ref = useRef<DerivedHandle | null>(null)
603
+ if (ref.current === null) {
604
+ // The dependency set is fixed at first render like useTransform's (the pinned contract);
605
+ // render-phase creation holds NO live input subscription (r8).
606
+ ref.current = deriveFrom(channel, values.filter(isMotionValue), () => buildRef.current())
607
+ ref.current.detach()
608
+ } else {
609
+ // Re-captured statics apply SYNCHRONOUSLY during render (r4; the pin calls updateValue()
610
+ // during render) — a same-pass reader sees the new statics, not one commit later.
611
+ ref.current.refresh()
612
+ }
613
+ useInsertionEffect(() => {
614
+ const handle = ref.current!
615
+ handle.attach()
616
+ return () => handle.detach()
617
+ }, [])
618
+ return ref.current.value as MotionValue<string>
619
+ }
620
+
621
+ // ——— useSpring (REQ-API-027) —————————————————————————————————————————————————————————————————
622
+
623
+ /**
624
+ * The PUBLIC spring options (r12 major 9d31a6c8f0e2): a standalone type in the pinned public
625
+ * units — `duration` is SECONDS like motion's SpringOptions, never core's ms-based SpringConfig
626
+ * (an internal type that must not leak through the public API). `skipInitialAnimation` is the
627
+ * pinned FollowValueOptions member: the first tracked-source change jumps instead of animating.
628
+ */
629
+ export interface SpringValueOptions {
630
+ readonly stiffness?: number
631
+ readonly damping?: number
632
+ readonly mass?: number
633
+ /** Spring duration in SECONDS (the pinned public unit). */
634
+ readonly duration?: number
635
+ /** 0 = no bounce, 1 = extremely bouncy (pinned semantics; pairs with `duration`). */
636
+ readonly bounce?: number
637
+ /** Perceptual duration in seconds; overrides `duration` when set (pinned semantics). */
638
+ readonly visualDuration?: number
639
+ readonly velocity?: number
640
+ /** Rest threshold on speed, units/sec (pinned member; follow default 0.01). */
641
+ readonly restSpeed?: number
642
+ /** Rest threshold on distance-to-target, value units (pinned member; follow default 0.001). */
643
+ readonly restDelta?: number
644
+ /** The first change from a tracked source JUMPS instead of animating (pinned member). */
645
+ readonly skipInitialAnimation?: boolean
646
+ }
647
+
648
+ const MILLISECONDS_PER_SECOND = 1000
649
+
650
+ /**
651
+ * The ONE public→core unit boundary (r12): seconds convert to core's ms here and nowhere else.
652
+ * `visualDuration` passes through — core's SpringConfig already takes it in seconds.
653
+ */
654
+ // The pinned follow-path settle defaults (motion-dom follow-value.ts at the SS B pin:
655
+ // `restDelta: 0.001, restSpeed: 0.01, ...options`): applied before the caller's options so an
656
+ // explicit member always wins.
657
+ const FOLLOW_REST_DELTA = 0.001
658
+ const FOLLOW_REST_SPEED = 0.01
659
+
660
+ export function resolveSpringValueConfig(options: SpringValueOptions): SpringConfig {
661
+ const config: {
662
+ stiffness?: number
663
+ damping?: number
664
+ mass?: number
665
+ duration?: number
666
+ bounce?: number
667
+ visualDuration?: number
668
+ velocity?: number
669
+ restSpeed?: number
670
+ restDelta?: number
671
+ } = {}
672
+ if (options.stiffness !== undefined) config.stiffness = options.stiffness
673
+ if (options.damping !== undefined) config.damping = options.damping
674
+ if (options.mass !== undefined) config.mass = options.mass
675
+ if (options.duration !== undefined) {
676
+ config.duration = options.duration * MILLISECONDS_PER_SECOND
677
+ }
678
+ if (options.bounce !== undefined) config.bounce = options.bounce
679
+ if (options.visualDuration !== undefined) config.visualDuration = options.visualDuration
680
+ if (options.velocity !== undefined) config.velocity = options.velocity
681
+ if (options.restSpeed !== undefined) config.restSpeed = options.restSpeed
682
+ if (options.restDelta !== undefined) config.restDelta = options.restDelta
683
+ return config
684
+ }
685
+
686
+ // The pinned velocity override (r14 major 7 — follow-value.ts spreads `...options` AFTER the
687
+ // sampled velocity, so an explicit `velocity` option replaces the seed's). Carry/zero by
688
+ // resolution type (REQ-SPRING-007) still applies downstream.
689
+ // The follow path's settle defaults ride the PUBLIC options (pinned spread order — defaults
690
+ // first, the caller's members win), keeping resolveSpringValueConfig a pure unit mapping.
691
+ function resolveFollowSpringConfig(options: SpringValueOptions): SpringConfig {
692
+ return resolveSpringValueConfig({
693
+ restDelta: FOLLOW_REST_DELTA,
694
+ restSpeed: FOLLOW_REST_SPEED,
695
+ ...options,
696
+ })
697
+ }
698
+
699
+ function withSeedVelocity(
700
+ factory: ReturnType<typeof resolveSpringGenerator>,
701
+ velocity: number | undefined,
702
+ ): ReturnType<typeof resolveSpringGenerator> {
703
+ if (velocity === undefined) return factory
704
+ return (seed) => factory({ from: seed.from, velocity })
705
+ }
706
+
707
+ export function useSpring(
708
+ source: MotionValue<number> | number,
709
+ options: SpringValueOptions = {},
710
+ ): MotionValue<number> {
711
+ const channel = useValueChannel()
712
+ // The LATEST options ride a ref (r12 major 19a46e8b3c75): a retarget after a rerender uses
713
+ // the current config, never the first render's capture.
714
+ const optionsRef = useRef(options)
715
+ optionsRef.current = options
716
+ const ref = useRef<MotionValue<number> | null>(null)
717
+ const followerRef = useRef<MotionValue<number> | null>(null)
718
+ // The LIVE tracked-source unsubscribe (r15 91d5aa6cb730): destroy() must detach it like the
719
+ // pinned attachFollow teardown — a source outliving a destroyed follower must never animate
720
+ // the corpse. Written by the attach effect; read by the wrapper's destroy.
721
+ const sourceDetachRef = useRef<(() => void) | undefined>(undefined)
722
+ if (ref.current === null) {
723
+ const initial = isMotionValue(source) ? (source as MotionValue<number>).get() : source
724
+ const follower = channel.graph.motionValue(initial)
725
+ followerRef.current = follower
726
+ const springTo = (target: number): void => {
727
+ channel.graph.animate(
728
+ follower,
729
+ withSeedVelocity(
730
+ resolveSpringGenerator(target, resolveFollowSpringConfig(optionsRef.current)),
731
+ optionsRef.current.velocity,
732
+ ),
733
+ )
734
+ }
735
+ // The returned value IS the follower for reads/subscriptions; `set` retargets the coupling
736
+ // spring (the pinned useSpring semantic) and `jump` teleports — both delegate every other
737
+ // behavior to the ONE underlying graph value (no parallel state, REQ-DRIVER-029).
738
+ ref.current = {
739
+ get: () => follower.get(),
740
+ set: springTo,
741
+ // Core jump owns the pinned order (update -> notify -> stop; r16 major 1) — a wrapper
742
+ // pre-stop would emit the cancellation BEFORE the change event, reversing the oracle.
743
+ jump: (value: number) => follower.jump(value),
744
+ subscribe: (listener) => follower.subscribe(listener),
745
+ getVelocity: () => follower.getVelocity(),
746
+ // The two-overload public `on` (r16 major 5): delegate by narrowing the event — the
747
+ // wrapper adds no shape of its own.
748
+ on: ((event: MotionValueEvent, listener: (latest: number) => void) =>
749
+ event === 'change'
750
+ ? follower.on('change', listener)
751
+ : follower.on(event, listener as unknown as () => void)) as MotionValue<number>['on'],
752
+ stop: () => follower.stop(),
753
+ isAnimating: () => follower.isAnimating(),
754
+ destroy: () => {
755
+ // Pinned attachFollow removes the source listener when the follower is destroyed.
756
+ sourceDetachRef.current?.()
757
+ sourceDetachRef.current = undefined
758
+ follower.destroy()
759
+ },
760
+ }
761
+ }
762
+ // Serialized options as the attach dependency — the PINNED re-parameterization law
763
+ // (use-follow-value.ts at the §B pin attaches on `[value, JSON.stringify(options)]`): an
764
+ // options change re-attaches the coupling, which also resets skipInitialAnimation's
765
+ // first-change tracking exactly like the pinned attachFollow lifetime.
766
+ const serializedOptions = JSON.stringify(options)
767
+ useInsertionEffect(() => {
768
+ const follower = followerRef.current!
769
+ // Pinned FollowValueOptions.skipInitialAnimation: the FIRST tracked change per attach
770
+ // lifetime jumps; subsequent changes animate.
771
+ let sawFirstChange = false
772
+ const unsubscribe = isMotionValue(source)
773
+ ? (source as MotionValue<number>).subscribe(() => {
774
+ const target = (source as MotionValue<number>).get()
775
+ const skipFirst = optionsRef.current.skipInitialAnimation === true && !sawFirstChange
776
+ sawFirstChange = true
777
+ if (skipFirst) {
778
+ follower.jump(target)
779
+ return
780
+ }
781
+ channel.graph.animate(
782
+ follower,
783
+ withSeedVelocity(
784
+ resolveSpringGenerator(target, resolveFollowSpringConfig(optionsRef.current)),
785
+ optionsRef.current.velocity,
786
+ ),
787
+ )
788
+ })
789
+ : undefined
790
+ sourceDetachRef.current = unsubscribe
791
+ return () => {
792
+ // The PINNED attachFollow cleanup shape (r14 major 8): a TRACKED source's cleanup only
793
+ // removes listeners — an options-change reattach must never freeze a mid-flight
794
+ // follower (the next source change animates under the new options). The DIRECT form's
795
+ // cleanup IS stopAnimation in the pin, so an options change stops its in-flight spring.
796
+ if (unsubscribe !== undefined) unsubscribe()
797
+ else follower.stop()
798
+ if (sourceDetachRef.current === unsubscribe) sourceDetachRef.current = undefined
799
+ }
800
+ // Coupling identity is fixed per (channel, options) attach lifetime; `source` identity is
801
+ // fixed at first render like the pinned hook's `value`.
802
+ // eslint-disable-next-line react-hooks/exhaustive-deps
803
+ }, [serializedOptions])
804
+ useInsertionEffect(() => {
805
+ const follower = followerRef.current!
806
+ return () => {
807
+ // UNMOUNT stops the coupling spring for BOTH forms — the r2 major 58aa03d1a law: a
808
+ // source outliving its consumer must not keep animating orphaned work in the shared
809
+ // graph. (Options-change reattaches above deliberately do not pass through here.)
810
+ follower.stop()
811
+ }
812
+ }, [])
813
+ return ref.current
814
+ }
815
+
816
+ // ——— useVelocity (REQ-API-027) ———————————————————————————————————————————————————————————————
817
+
818
+ export function useVelocity(source: MotionValue<number>): MotionValue<number> {
819
+ const channel = useValueChannel()
820
+ const explicitPort = useContext(ValueHooksIdentityChannelContext)
821
+ // The identity-channel cache port resolves ONCE at first render (the pinned dependency-fixing
822
+ // contract): an explicit provider wins (test-only); the canonical channel registration is the
823
+ // production seam. Without either, the source is an ordinary graph value and the legacy
824
+ // core-graph observation below applies — cache-only observation needs no fallback crossing.
825
+ const resolutionRef = useRef<ValueHooksIdentityChannelResolution | null>(null)
826
+ if (
827
+ resolutionRef.current === null &&
828
+ (explicitPort !== null || canonicalIdentityPorts.has(channel))
829
+ ) {
830
+ resolutionRef.current = resolveValueHooksIdentityChannelPort(channel, explicitPort)
831
+ }
832
+ const resolution = resolutionRef.current
833
+ const ref = useRef<MotionValue<number> | null>(null)
834
+ // Seed from the CURRENT source velocity (r12 major 5ce847a2d913, the pinned hook's
835
+ // `useMotionValue(value.getVelocity())`): mounting against an already-moving source must not
836
+ // report a phantom zero until the next change arrives. The identity channel reads the
837
+ // TRANSPORTED CACHE (zero synchronous UI crossings, Decision 13) — never source.getVelocity().
838
+ if (ref.current === null) {
839
+ ref.current = channel.graph.motionValue(
840
+ resolution === null
841
+ ? source.getVelocity()
842
+ : resolution.port.readTransportedCachedVelocity(source, resolution.port.now()),
843
+ )
844
+ }
845
+ const out = ref.current
846
+ useInsertionEffect(() => {
847
+ if (resolution !== null) {
848
+ const port = resolution.port
849
+ // Cache-only observer cadence: observer frames re-read the transported cache — never a
850
+ // synchronous source read, never a wall-clock timer, never an animationComplete
851
+ // subscription (a completion has no cache authority; staleness decay is the only zero).
852
+ let cancelFrame: (() => void) | null = null
853
+ const arm = (): void => {
854
+ if (cancelFrame !== null) return
855
+ cancelFrame = port.requestObserverFrame((atTimestampMs) => {
856
+ cancelFrame = null
857
+ const cached = port.readTransportedCachedVelocity(source, atTimestampMs)
858
+ out.set(cached)
859
+ if (cached !== 0) arm()
860
+ })
861
+ }
862
+ const unsubscribe = port.subscribeTransportedChange(source, () => {
863
+ const cached = port.readTransportedCachedVelocity(source, port.now())
864
+ out.set(cached)
865
+ if (cached !== 0) arm()
866
+ })
867
+ // Arm only while nonzero: a zero cache needs no decay re-reads.
868
+ if (out.get() !== 0) arm()
869
+ return () => {
870
+ if (cancelFrame !== null) cancelFrame()
871
+ unsubscribe()
872
+ }
873
+ }
874
+ // A manual sample goes stale silently — change events stop arriving, so a nonzero velocity
875
+ // would otherwise persist forever (review r1 major 5). Each sample arms ONE bounded trailing
876
+ // re-read past core's staleness window; getVelocity() itself reports the decay to zero.
877
+ let trailing: ReturnType<typeof setTimeout> | null = null
878
+ const sample = (): void => {
879
+ out.set(source.getVelocity())
880
+ if (trailing !== null) clearTimeout(trailing)
881
+ trailing = setTimeout(() => {
882
+ trailing = null
883
+ const decayed = source.getVelocity()
884
+ out.set(decayed)
885
+ channel.kick()
886
+ }, VELOCITY_DECAY_RESAMPLE_MS)
887
+ }
888
+ const unsubscribeChange = source.subscribe(sample)
889
+ // Core's rest law: an animation ending puts the value at rest — mirror the zero explicitly.
890
+ const unsubscribeComplete = source.on('animationComplete', () => out.set(0))
891
+ return () => {
892
+ if (trailing !== null) clearTimeout(trailing)
893
+ unsubscribeChange()
894
+ unsubscribeComplete()
895
+ }
896
+ }, [source, out, channel, resolution])
897
+ return out
898
+ }
899
+
900
+ // Just past core's MAX_VELOCITY_DELTA_MS staleness window, so the re-read observes the decay.
901
+ const VELOCITY_DECAY_RESAMPLE_MS = 60