@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,816 @@
1
+ // REQ-API-039 — native imperative surface: standalone animate() + the controls contract the
2
+ // catalog rows exercise (headless number/color, MotionValue, reverse via time/speed). Transitions
3
+ // capture through the R8 prepared seam (prepareDriverCommand); the value channel drives samples —
4
+ // no parallel imperative engine.
5
+
6
+ import {
7
+ describeValue,
8
+ isColor,
9
+ mixColor,
10
+ parseColor,
11
+ timingGenerator,
12
+ toTimingConfig,
13
+ transformColor,
14
+ type MotionValue,
15
+ type Transition,
16
+ } from '@unrulysystems/native-motion-core'
17
+ import { prepareDriverCommand } from '@unrulysystems/native-motion-core/internal-driver'
18
+ import { ambientNativeSeverity, consoleReporter } from './severity'
19
+ import { resolveDefaultValueChannel } from './valueHooks'
20
+ import type { ValueChannel } from './valueChannel'
21
+
22
+ export const ANIMATE_OWNER = 'animate' as const
23
+ export const USE_ANIMATE_OWNER = 'useAnimate' as const
24
+
25
+ /** Public playback controls surface (catalog-exercised members) — SHALLOW, pin-aligned. */
26
+ export interface AnimationPlaybackControls {
27
+ time: number
28
+ speed: number
29
+ duration: number
30
+ readonly finished: Promise<unknown>
31
+ play: () => void
32
+ pause: () => void
33
+ stop: () => void
34
+ cancel: () => void
35
+ complete: () => void
36
+ then: (onResolve: () => void, onReject?: () => void) => Promise<void>
37
+ }
38
+
39
+ export type AnimationPlaybackControlsWithThen = AnimationPlaybackControls
40
+
41
+ /**
42
+ * Shared shallow public callable (REQ-API-039). Named AnimateFn to match the web entry so
43
+ * dual-entry type-identity probes never expand pin overload surfaces.
44
+ */
45
+ export type AnimateFn = (
46
+ subject: unknown,
47
+ keyframes?: unknown,
48
+ transition?: unknown,
49
+ ) => AnimationPlaybackControls
50
+
51
+ export interface AnimationScope<T = unknown> {
52
+ current: T | null
53
+ animations: AnimationPlaybackControls[]
54
+ }
55
+
56
+ export interface ImperativeAnimateGate {
57
+ readonly severity: 'development' | 'production'
58
+ readonly report: (error: Error) => void
59
+ readonly owner: typeof ANIMATE_OWNER | typeof USE_ANIMATE_OWNER
60
+ }
61
+
62
+ export interface CreateScopedAnimateOptions {
63
+ readonly scope?: AnimationScope
64
+ readonly channel?: ValueChannel
65
+ readonly gate?: ImperativeAnimateGate
66
+ /** When true (MotionConfig.skipAnimations), commands jump to the end. */
67
+ readonly skipAnimations?: boolean
68
+ }
69
+
70
+ type ValueOptions = {
71
+ readonly duration?: number
72
+ readonly ease?: Transition['ease']
73
+ readonly type?: Transition['type']
74
+ readonly onUpdate?: (latest: number | string) => void
75
+ readonly onComplete?: () => void
76
+ }
77
+
78
+ function refuse(gate: ImperativeAnimateGate, message: string): never {
79
+ const error = new Error(message)
80
+ if (gate.severity === 'development') throw error
81
+ gate.report(error)
82
+ // Production refuse: return a inert controls shell so call sites that ignore still
83
+ // cannot schedule work (caller must not assume throw). Unreachable for typed never after
84
+ // throw in development; production falls through to throw so createRoot/act still observe.
85
+ throw error
86
+ }
87
+
88
+ function isMotionValue(candidate: unknown): candidate is MotionValue<number> {
89
+ return (
90
+ typeof candidate === 'object' &&
91
+ candidate !== null &&
92
+ typeof (candidate as MotionValue<number>).get === 'function' &&
93
+ typeof (candidate as MotionValue<number>).set === 'function' &&
94
+ typeof (candidate as MotionValue<number>).getVelocity === 'function'
95
+ )
96
+ }
97
+
98
+ function isSequence(value: unknown): value is readonly unknown[] {
99
+ return Array.isArray(value) && value.some((segment) => Array.isArray(segment))
100
+ }
101
+
102
+ /** Capture the transition through the SAME prepared seam as declarative props (R8 / REQ-DRIVER-030). */
103
+ export function prepareImperativeTransition(
104
+ options: ValueOptions | undefined,
105
+ gate: ImperativeAnimateGate,
106
+ from: number,
107
+ to: number,
108
+ ): { readonly durationMs: number; readonly transition: Transition } {
109
+ const durationSec = options?.duration ?? 0.3
110
+ if (typeof durationSec !== 'number' || !Number.isFinite(durationSec) || durationSec < 0) {
111
+ refuse(
112
+ gate,
113
+ `${gate.owner}: invalid duration = ${describeValue(options?.duration)} — expected a non-negative number of seconds (REQ-API-039).`,
114
+ )
115
+ }
116
+ // U7a: the authored instant lane (`type: false`) ships on the DECLARATIVE property lane only.
117
+ // NumericPlayback is duration-based (time/speed seek) and commits a zero-duration run
118
+ // SYNCHRONOUSLY at construction — not the pin's next-frame commit (motion-dom
119
+ // motion-value.ts:122-136) — so `type: false` refuses loud here (demand-gated divergence)
120
+ // rather than collapsing into a tween, which is what the type normalizer below would do with it.
121
+ if (options?.type === false) {
122
+ refuse(
123
+ gate,
124
+ `${gate.owner}: type: false (the authored instant lane) is not executable by the imperative ` +
125
+ 'playback lane — its controls are duration-based and would commit synchronously, not on ' +
126
+ 'the next frame (U7a / G-INV-8). Use the declarative transition prop for the instant lane.',
127
+ )
128
+ }
129
+ // U7b scoping note (review minor): the GLOBAL instant window (useInstantTransition) is likewise
130
+ // not consulted on this lane — an imperative start inside the window runs full-duration. The
131
+ // pin's flag is global, so if that class is ever demanded here it must REFUSE loud like the
132
+ // arm above, never silently ignore.
133
+ // exactOptionalPropertyTypes: only include ease when present; type is required.
134
+ const authored: Transition =
135
+ options?.ease !== undefined
136
+ ? {
137
+ type: options.type === 'spring' ? 'spring' : 'tween',
138
+ duration: durationSec,
139
+ ease: options.ease as NonNullable<Transition['ease']>,
140
+ }
141
+ : {
142
+ type: options?.type === 'spring' ? 'spring' : 'tween',
143
+ duration: durationSec,
144
+ }
145
+ // Prepared seam: a hostile or unknown option is refused by capture/validate, never reaches a backend.
146
+ try {
147
+ const prepared = prepareDriverCommand({
148
+ kind: 'start',
149
+ targets: { value: { to, from } },
150
+ transition: authored,
151
+ })
152
+ if (prepared.kind !== 'start') {
153
+ refuse(gate, `${gate.owner}: prepared seam returned a non-start command (REQ-API-039).`)
154
+ }
155
+ const prop = prepared.targets['value']
156
+ if (prop === undefined) {
157
+ refuse(gate, `${gate.owner}: prepared seam dropped the value target (REQ-API-039).`)
158
+ }
159
+ const timing = toTimingConfig(prop.transition as Transition)
160
+ return {
161
+ durationMs: timing.duration ?? durationSec * 1000,
162
+ transition: prop.transition as Transition,
163
+ }
164
+ } catch (error) {
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ refuse(gate, `${gate.owner}: ${message}`)
167
+ }
168
+ }
169
+
170
+ function resolvedEase(transition: Transition): NonNullable<Transition['ease']> | undefined {
171
+ return transition.ease
172
+ }
173
+
174
+ /**
175
+ * Playback controls with time/speed (catalog reverse: time = duration; speed = -1).
176
+ * Samples through the value channel via graph.animate; seek/reverse restart from the prepared
177
+ * transition snapshot — never a second raw transition path.
178
+ */
179
+ class NumericPlayback implements AnimationPlaybackControlsWithThen {
180
+ #timeSec = 0
181
+ #speed = 1
182
+ #playing = true
183
+ #finished = false
184
+ #handle: { stop: () => void } | null = null
185
+ #detachComplete: (() => void) | null = null
186
+ #detachChange: (() => void) | null = null
187
+ #settle!: () => void
188
+ readonly finished: Promise<void>
189
+ readonly #durationSec: number
190
+ readonly #from: number
191
+ readonly #to: number
192
+ readonly #value: MotionValue<number>
193
+ readonly #channel: ValueChannel
194
+ readonly #durationMs: number
195
+ readonly #transition: Transition
196
+ readonly #onUpdate: ((latest: number) => void) | undefined
197
+ readonly #onComplete: (() => void) | undefined
198
+ readonly #onSample: ((latest: number) => void) | undefined
199
+
200
+ constructor(args: {
201
+ readonly channel: ValueChannel
202
+ readonly value: MotionValue<number>
203
+ readonly from: number
204
+ readonly to: number
205
+ readonly durationMs: number
206
+ readonly transition: Transition
207
+ readonly onUpdate?: (latest: number) => void
208
+ readonly onComplete?: () => void
209
+ readonly onSample?: (latest: number) => void
210
+ readonly skipAnimations?: boolean
211
+ }) {
212
+ this.#channel = args.channel
213
+ this.#value = args.value
214
+ this.#from = args.from
215
+ this.#to = args.to
216
+ this.#durationMs = args.durationMs
217
+ this.#durationSec = args.durationMs / 1000
218
+ this.#transition = args.transition
219
+ this.#onUpdate = args.onUpdate
220
+ this.#onComplete = args.onComplete
221
+ this.#onSample = args.onSample
222
+ this.finished = new Promise<void>((resolve) => {
223
+ this.#settle = resolve
224
+ })
225
+ if (args.skipAnimations || args.durationMs <= 0) {
226
+ this.#applyTimeProgress(1)
227
+ this.#markDone()
228
+ return
229
+ }
230
+ this.#restartFromProgress(0)
231
+ }
232
+
233
+ get time(): number {
234
+ return this.#timeSec
235
+ }
236
+
237
+ set time(next: number) {
238
+ if (this.#finished) return
239
+ const clamped = Math.max(0, Math.min(this.#durationSec, next))
240
+ this.#timeSec = clamped
241
+ const progress = this.#durationSec === 0 ? 1 : clamped / this.#durationSec
242
+ this.#applyTimeProgress(progress)
243
+ if (this.#playing) this.#restartFromProgress(progress)
244
+ }
245
+
246
+ get speed(): number {
247
+ return this.#speed
248
+ }
249
+
250
+ set speed(next: number) {
251
+ if (this.#finished) return
252
+ this.#speed = next
253
+ // Catalog reverse: seek to duration (may idle at endpoint) then set speed = -1 — resume
254
+ // from the current time even if the prior leg had no remaining travel.
255
+ this.#playing = true
256
+ const progress = this.#durationSec === 0 ? 1 : this.#timeSec / this.#durationSec
257
+ this.#restartFromProgress(progress)
258
+ }
259
+
260
+ get duration(): number {
261
+ return this.#durationSec
262
+ }
263
+
264
+ play = (): void => {
265
+ if (this.#finished) return
266
+ this.#playing = true
267
+ const progress = this.#durationSec === 0 ? 1 : this.#timeSec / this.#durationSec
268
+ this.#restartFromProgress(progress)
269
+ }
270
+
271
+ pause = (): void => {
272
+ if (this.#finished) return
273
+ this.#playing = false
274
+ this.#stopHandle()
275
+ }
276
+
277
+ stop = (): void => {
278
+ this.#stopHandle()
279
+ this.#playing = false
280
+ this.#markDone()
281
+ }
282
+
283
+ cancel = (): void => {
284
+ this.#stopHandle()
285
+ this.#applyTimeProgress(0)
286
+ this.#timeSec = 0
287
+ this.#playing = false
288
+ this.#markDone()
289
+ }
290
+
291
+ complete = (): void => {
292
+ this.#stopHandle()
293
+ this.#applyTimeProgress(1)
294
+ this.#timeSec = this.#durationSec
295
+ this.#markDone()
296
+ }
297
+
298
+ // Pin AnimationPlaybackControlsWithThen: controls are thenable (await animation).
299
+ // oxlint-disable-next-line unicorn/no-thenable -- pin thenable contract (REQ-API-039)
300
+ then(onResolve: () => void, onReject?: () => void): Promise<void> {
301
+ return this.finished.then(onResolve, onReject)
302
+ }
303
+
304
+ #sampleAtProgress(progress: number): number {
305
+ const p = Math.max(0, Math.min(1, progress))
306
+ const ease = resolvedEase(this.#transition)
307
+ const timing =
308
+ ease !== undefined && !Array.isArray(ease)
309
+ ? { duration: this.#durationMs, ease: ease as never }
310
+ : { duration: this.#durationMs, ease: 'linear' as const }
311
+ const gen = timingGenerator(this.#to, timing)({ from: this.#from, velocity: 0 })
312
+ return gen.sample(p * this.#durationMs).value
313
+ }
314
+
315
+ #applyTimeProgress(progress: number): void {
316
+ const value = this.#sampleAtProgress(progress)
317
+ this.#value.jump(value)
318
+ this.#onUpdate?.(value)
319
+ this.#onSample?.(value)
320
+ }
321
+
322
+ #markDone(): void {
323
+ if (this.#finished) return
324
+ this.#finished = true
325
+ this.#playing = false
326
+ this.#stopHandle()
327
+ this.#onComplete?.()
328
+ this.#settle()
329
+ }
330
+
331
+ #stopHandle(): void {
332
+ this.#detachComplete?.()
333
+ this.#detachComplete = null
334
+ this.#detachChange?.()
335
+ this.#detachChange = null
336
+ this.#handle?.stop()
337
+ this.#handle = null
338
+ }
339
+
340
+ #restartFromProgress(progress: number): void {
341
+ this.#stopHandle()
342
+ if (this.#speed === 0) return
343
+ const forward = this.#speed > 0
344
+ const remainingProgress = forward ? 1 - progress : progress
345
+ if (remainingProgress <= 1e-9) {
346
+ // Catalog reverse seeks to duration THEN sets speed = -1. Sitting at an endpoint with
347
+ // no remaining travel must NOT settle finished — only natural completion / stop / complete.
348
+ this.#applyTimeProgress(forward ? 1 : 0)
349
+ this.#timeSec = forward ? this.#durationSec : 0
350
+ return
351
+ }
352
+ this.#playing = true
353
+ const legFrom = this.#sampleAtProgress(progress)
354
+ const legTo = forward ? this.#to : this.#from
355
+ const legDurationMs = (remainingProgress * this.#durationMs) / Math.abs(this.#speed)
356
+ this.#startLeg(legFrom, legTo, legDurationMs, forward)
357
+ }
358
+
359
+ #startLeg(from: number, to: number, durationMs: number, forward: boolean): void {
360
+ this.#value.jump(from)
361
+ // Linear leg between the current visual value and the endpoint — reverse/seek always
362
+ // restarts a prepared-duration fraction; the full-run ease was applied in #applyTimeProgress.
363
+ const factory = timingGenerator(to, { duration: durationMs, ease: 'linear' })
364
+ const startTimeSec = this.#timeSec
365
+ this.#handle = this.#channel.graph.animate(this.#value, (seed) =>
366
+ factory({ from: seed.from, velocity: 0 }),
367
+ )
368
+ this.#detachChange = this.#value.on('change', (latest: number) => {
369
+ this.#onUpdate?.(latest)
370
+ this.#onSample?.(latest)
371
+ // Map visual progress of this leg back onto overall time.
372
+ const span = to - from
373
+ const legP = Math.abs(span) < 1e-12 ? 1 : (latest - from) / span
374
+ const clampedLeg = Math.max(0, Math.min(1, legP))
375
+ this.#timeSec = forward
376
+ ? startTimeSec + clampedLeg * (this.#durationSec - startTimeSec)
377
+ : startTimeSec - clampedLeg * startTimeSec
378
+ })
379
+ this.#detachComplete = this.#value.on('animationComplete', () => {
380
+ this.#timeSec = forward ? this.#durationSec : 0
381
+ this.#applyTimeProgress(forward ? 1 : 0)
382
+ this.#markDone()
383
+ })
384
+ }
385
+ }
386
+
387
+ function animateNumeric(
388
+ channel: ValueChannel,
389
+ value: MotionValue<number>,
390
+ to: number,
391
+ options: ValueOptions | undefined,
392
+ gate: ImperativeAnimateGate,
393
+ skipAnimations: boolean,
394
+ onSample?: (latest: number) => void,
395
+ ): AnimationPlaybackControlsWithThen {
396
+ const from = value.get()
397
+ if (typeof from !== 'number' || typeof to !== 'number') {
398
+ refuse(
399
+ gate,
400
+ `${gate.owner}: numeric animate requires number endpoints — got from=${describeValue(from)} to=${describeValue(to)} (REQ-API-039).`,
401
+ )
402
+ }
403
+ const prepared = prepareImperativeTransition(options, gate, from, to)
404
+ return new NumericPlayback({
405
+ channel,
406
+ value,
407
+ from,
408
+ to,
409
+ durationMs: prepared.durationMs,
410
+ transition: prepared.transition,
411
+ ...(typeof options?.onUpdate === 'function'
412
+ ? { onUpdate: options.onUpdate as (latest: number) => void }
413
+ : {}),
414
+ ...(typeof options?.onComplete === 'function' ? { onComplete: options.onComplete } : {}),
415
+ ...(onSample !== undefined ? { onSample } : {}),
416
+ skipAnimations,
417
+ })
418
+ }
419
+
420
+ function animateHeadlessNumber(
421
+ channel: ValueChannel,
422
+ from: number,
423
+ to: number,
424
+ options: ValueOptions | undefined,
425
+ gate: ImperativeAnimateGate,
426
+ skipAnimations: boolean,
427
+ ): AnimationPlaybackControlsWithThen {
428
+ const value = channel.graph.motionValue(from)
429
+ return animateNumeric(channel, value, to, options, gate, skipAnimations)
430
+ }
431
+
432
+ function animateHeadlessColor(
433
+ channel: ValueChannel,
434
+ fromRaw: string,
435
+ toRaw: string,
436
+ options: ValueOptions | undefined,
437
+ gate: ImperativeAnimateGate,
438
+ skipAnimations: boolean,
439
+ ): AnimationPlaybackControlsWithThen {
440
+ if (!isColor(fromRaw) || !isColor(toRaw)) {
441
+ refuse(
442
+ gate,
443
+ `${gate.owner}: color animate requires parseable color strings — got from=${describeValue(fromRaw)} to=${describeValue(toRaw)} (REQ-API-039).`,
444
+ )
445
+ }
446
+ const fromColor = parseColor(fromRaw)
447
+ const toColor = parseColor(toRaw)
448
+ const progress = channel.graph.motionValue(0)
449
+ const onUpdate = options?.onUpdate
450
+ return animateNumeric(
451
+ channel,
452
+ progress,
453
+ 1,
454
+ {
455
+ ...options,
456
+ onUpdate: (p) => {
457
+ if (typeof p !== 'number') return
458
+ const mixed = transformColor(mixColor(fromColor, toColor, p))
459
+ onUpdate?.(mixed)
460
+ },
461
+ },
462
+ gate,
463
+ skipAnimations,
464
+ )
465
+ }
466
+
467
+ function isDomElement(value: unknown): value is Element {
468
+ return typeof Element !== 'undefined' && value instanceof Element
469
+ }
470
+
471
+ function resolveSelectorSubjects(selector: string, scope: AnimationScope | undefined): Element[] {
472
+ const root =
473
+ scope?.current !== null && scope?.current !== undefined && isDomElement(scope.current)
474
+ ? scope.current
475
+ : typeof document !== 'undefined'
476
+ ? document
477
+ : null
478
+ if (root === null || typeof (root as ParentNode).querySelectorAll !== 'function') return []
479
+ return Array.from((root as ParentNode).querySelectorAll(selector))
480
+ }
481
+
482
+ function readElementNumber(el: Element, key: string): number {
483
+ if (!(el instanceof HTMLElement)) return 0
484
+ if (key === 'opacity') {
485
+ const o = el.style.opacity
486
+ return o === '' ? 1 : Number(o)
487
+ }
488
+ if (key === 'x' || key === 'y' || key === 'scale') {
489
+ // Best-effort parse from transform translate/scale for catalog reverse/initial-transform.
490
+ const transform = el.style.transform || ''
491
+ if (key === 'x') {
492
+ const m = /translate(?:3d|X)?\(\s*([-\d.]+)/.exec(transform)
493
+ return m ? Number(m[1]) : 0
494
+ }
495
+ if (key === 'y') {
496
+ const m = /translate(?:3d)?\(\s*[^,]+,\s*([-\d.]+)/.exec(transform)
497
+ return m ? Number(m[1]) : 0
498
+ }
499
+ if (key === 'scale') {
500
+ const m = /scale\(([^)]+)\)/.exec(transform)
501
+ if (!m) {
502
+ // Catalog initial-transform starts from style.transform scale(0.1)
503
+ return 1
504
+ }
505
+ return Number(m[1])
506
+ }
507
+ }
508
+ return 0
509
+ }
510
+
511
+ function writeElementNumber(
512
+ el: Element,
513
+ key: string,
514
+ value: number,
515
+ state: ElementStyleState,
516
+ ): void {
517
+ if (!(el instanceof HTMLElement)) return
518
+ if (key === 'opacity') {
519
+ el.style.opacity = String(value)
520
+ return
521
+ }
522
+ if (key === 'x') state.x = value
523
+ else if (key === 'y') state.y = value
524
+ else if (key === 'scale') state.scale = value
525
+ el.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`
526
+ }
527
+
528
+ interface ElementStyleState {
529
+ x: number
530
+ y: number
531
+ scale: number
532
+ }
533
+
534
+ function animateElementProps(
535
+ channel: ValueChannel,
536
+ elements: readonly Element[],
537
+ keyframes: Record<string, number>,
538
+ options: ValueOptions | undefined,
539
+ gate: ImperativeAnimateGate,
540
+ skipAnimations: boolean,
541
+ ): AnimationPlaybackControlsWithThen[] {
542
+ const controls: AnimationPlaybackControlsWithThen[] = []
543
+ for (const el of elements) {
544
+ const state: ElementStyleState = {
545
+ x: readElementNumber(el, 'x'),
546
+ y: readElementNumber(el, 'y'),
547
+ scale: readElementNumber(el, 'scale'),
548
+ }
549
+ // Preserve catalog initial scale from CSS when present.
550
+ const cssTransform = el instanceof HTMLElement ? el.style.transform : ''
551
+ const scaleMatch = /scale\(([^)]+)\)/.exec(cssTransform)
552
+ if (scaleMatch) state.scale = Number(scaleMatch[1])
553
+
554
+ for (const [key, to] of Object.entries(keyframes)) {
555
+ if (typeof to !== 'number') {
556
+ refuse(
557
+ gate,
558
+ `${gate.owner}: element keyframe '${key}' must be a number this rung — got ${describeValue(to)} (REQ-API-039).`,
559
+ )
560
+ }
561
+ const from = key === 'scale' && scaleMatch ? state.scale : readElementNumber(el, key)
562
+ const mv = channel.graph.motionValue(from)
563
+ controls.push(
564
+ animateNumeric(channel, mv, to, options, gate, skipAnimations, (latest) => {
565
+ writeElementNumber(el, key, latest, state)
566
+ }),
567
+ )
568
+ }
569
+ }
570
+ return controls
571
+ }
572
+
573
+ class GroupPlayback implements AnimationPlaybackControlsWithThen {
574
+ readonly #items: AnimationPlaybackControlsWithThen[]
575
+ readonly finished: Promise<void>
576
+
577
+ constructor(items: AnimationPlaybackControlsWithThen[]) {
578
+ this.#items = items
579
+ this.finished = Promise.all(items.map((item) => item.finished)).then(() => undefined)
580
+ }
581
+
582
+ get time(): number {
583
+ return this.#items[0]?.time ?? 0
584
+ }
585
+
586
+ set time(next: number) {
587
+ for (const item of this.#items) item.time = next
588
+ }
589
+
590
+ get speed(): number {
591
+ return this.#items[0]?.speed ?? 1
592
+ }
593
+
594
+ set speed(next: number) {
595
+ for (const item of this.#items) item.speed = next
596
+ }
597
+
598
+ get duration(): number {
599
+ return this.#items.reduce((max, item) => Math.max(max, item.duration), 0)
600
+ }
601
+
602
+ play = (): void => {
603
+ for (const item of this.#items) item.play()
604
+ }
605
+
606
+ pause = (): void => {
607
+ for (const item of this.#items) item.pause()
608
+ }
609
+
610
+ stop = (): void => {
611
+ for (const item of this.#items) item.stop()
612
+ }
613
+
614
+ cancel = (): void => {
615
+ for (const item of this.#items) item.cancel()
616
+ }
617
+
618
+ complete = (): void => {
619
+ for (const item of this.#items) item.complete()
620
+ }
621
+
622
+ // oxlint-disable-next-line unicorn/no-thenable -- pin thenable contract (REQ-API-039)
623
+ then(onResolve: () => void, onReject?: () => void): Promise<void> {
624
+ return this.finished.then(onResolve, onReject)
625
+ }
626
+ }
627
+
628
+ function animateSequence(
629
+ channel: ValueChannel,
630
+ sequence: readonly unknown[],
631
+ gate: ImperativeAnimateGate,
632
+ scope: AnimationScope | undefined,
633
+ skipAnimations: boolean,
634
+ ): AnimationPlaybackControlsWithThen {
635
+ const controls: AnimationPlaybackControlsWithThen[] = []
636
+ for (const segment of sequence) {
637
+ // Label-only segment (timeline marker) — pin ignores for playback payload.
638
+ if (typeof segment === 'string') continue
639
+ if (!Array.isArray(segment) || segment.length < 2) {
640
+ refuse(
641
+ gate,
642
+ `${gate.owner}: sequence segment must be [subject, keyframes, options?] — got ${describeValue(segment)} (REQ-API-039).`,
643
+ )
644
+ }
645
+ const subject = segment[0]
646
+ const keyframes = segment[1]
647
+ const options = (segment[2] ?? undefined) as ValueOptions | undefined
648
+ if (typeof subject === 'string') {
649
+ const elements = resolveSelectorSubjects(subject, scope)
650
+ if (elements.length === 0) {
651
+ refuse(
652
+ gate,
653
+ `${gate.owner}: selector ${describeValue(subject)} resolved to no elements in scope (REQ-API-039).`,
654
+ )
655
+ }
656
+ if (typeof keyframes !== 'object' || keyframes === null || Array.isArray(keyframes)) {
657
+ refuse(
658
+ gate,
659
+ `${gate.owner}: element keyframes must be a plain object — got ${describeValue(keyframes)} (REQ-API-039).`,
660
+ )
661
+ }
662
+ controls.push(
663
+ ...animateElementProps(
664
+ channel,
665
+ elements,
666
+ keyframes as Record<string, number>,
667
+ options,
668
+ gate,
669
+ skipAnimations,
670
+ ),
671
+ )
672
+ continue
673
+ }
674
+ if (isMotionValue(subject) && typeof keyframes === 'number') {
675
+ controls.push(animateNumeric(channel, subject, keyframes, options, gate, skipAnimations))
676
+ continue
677
+ }
678
+ refuse(
679
+ gate,
680
+ `${gate.owner}: unsupported sequence subject ${describeValue(subject)} (REQ-API-039).`,
681
+ )
682
+ }
683
+ if (controls.length === 0) {
684
+ // Empty sequence (labels only): already-finished controls.
685
+ const empty = new NumericPlayback({
686
+ channel,
687
+ value: channel.graph.motionValue(0),
688
+ from: 0,
689
+ to: 0,
690
+ durationMs: 0,
691
+ transition: { type: 'tween', duration: 0 },
692
+ skipAnimations: true,
693
+ })
694
+ return empty
695
+ }
696
+ return controls.length === 1 ? controls[0]! : new GroupPlayback(controls)
697
+ }
698
+
699
+ function trackOnScope(
700
+ scope: AnimationScope | undefined,
701
+ controls: AnimationPlaybackControlsWithThen,
702
+ ): AnimationPlaybackControlsWithThen {
703
+ if (scope === undefined) return controls
704
+ scope.animations.push(controls)
705
+ void controls.finished.then(() => {
706
+ const index = scope.animations.indexOf(controls)
707
+ if (index >= 0) scope.animations.splice(index, 1)
708
+ return undefined
709
+ })
710
+ return controls
711
+ }
712
+
713
+ /** Explicit callable shape — keep inference shallow so typecheck does not expand the body. */
714
+ export type ScopedAnimateFn = AnimateFn
715
+
716
+ export function createScopedAnimate(options: CreateScopedAnimateOptions = {}): AnimateFn {
717
+ const scope = options.scope
718
+ // Channel is resolved per call so standalone animate always joins the same lazy app-default
719
+ // graph as useMotionValue. Scoped hooks pass their provider channel explicitly.
720
+ const fixedChannel = options.channel
721
+ const gate: ImperativeAnimateGate = options.gate ?? {
722
+ severity: ambientNativeSeverity(),
723
+ report: consoleReporter,
724
+ owner: scope !== undefined ? USE_ANIMATE_OWNER : ANIMATE_OWNER,
725
+ }
726
+ const skipAnimations = options.skipAnimations === true
727
+
728
+ function resolveChannel(): ValueChannel {
729
+ return fixedChannel ?? resolveDefaultValueChannel()
730
+ }
731
+
732
+ const scopedAnimate: AnimateFn = (subjectOrSequence, keyframesOrOptions?, maybeOptions?) => {
733
+ const channel = resolveChannel()
734
+ if (isSequence(subjectOrSequence)) {
735
+ return trackOnScope(
736
+ scope,
737
+ animateSequence(channel, subjectOrSequence, gate, scope, skipAnimations),
738
+ )
739
+ }
740
+
741
+ // animate(number, number, options?)
742
+ if (typeof subjectOrSequence === 'number' && typeof keyframesOrOptions === 'number') {
743
+ return trackOnScope(
744
+ scope,
745
+ animateHeadlessNumber(
746
+ channel,
747
+ subjectOrSequence,
748
+ keyframesOrOptions,
749
+ maybeOptions as ValueOptions | undefined,
750
+ gate,
751
+ skipAnimations,
752
+ ),
753
+ )
754
+ }
755
+
756
+ // animate(colorString, colorString, options?)
757
+ if (typeof subjectOrSequence === 'string' && typeof keyframesOrOptions === 'string') {
758
+ return trackOnScope(
759
+ scope,
760
+ animateHeadlessColor(
761
+ channel,
762
+ subjectOrSequence,
763
+ keyframesOrOptions,
764
+ maybeOptions as ValueOptions | undefined,
765
+ gate,
766
+ skipAnimations,
767
+ ),
768
+ )
769
+ }
770
+
771
+ // animate(MotionValue, number, options?)
772
+ if (isMotionValue(subjectOrSequence) && typeof keyframesOrOptions === 'number') {
773
+ return trackOnScope(
774
+ scope,
775
+ animateNumeric(
776
+ channel,
777
+ subjectOrSequence,
778
+ keyframesOrOptions,
779
+ maybeOptions as ValueOptions | undefined,
780
+ gate,
781
+ skipAnimations,
782
+ ),
783
+ )
784
+ }
785
+
786
+ // animate(selector, keyframes, options?) with scope
787
+ if (typeof subjectOrSequence === 'string' && typeof keyframesOrOptions === 'object') {
788
+ const elements = resolveSelectorSubjects(subjectOrSequence, scope)
789
+ if (elements.length === 0) {
790
+ refuse(
791
+ gate,
792
+ `${gate.owner}: selector ${describeValue(subjectOrSequence)} resolved to no elements in scope (REQ-API-039).`,
793
+ )
794
+ }
795
+ const group = animateElementProps(
796
+ channel,
797
+ elements,
798
+ keyframesOrOptions as Record<string, number>,
799
+ maybeOptions as ValueOptions | undefined,
800
+ gate,
801
+ skipAnimations,
802
+ )
803
+ const controls = group.length === 1 ? group[0]! : new GroupPlayback(group)
804
+ return trackOnScope(scope, controls)
805
+ }
806
+
807
+ refuse(
808
+ gate,
809
+ `${gate.owner}: unsupported subject ${describeValue(subjectOrSequence)} — catalog shapes are headless number/color, MotionValue, selector+keyframes, or a sequence (REQ-API-039).`,
810
+ )
811
+ }
812
+
813
+ return scopedAnimate
814
+ }
815
+
816
+ export const animate: AnimateFn = createScopedAnimate()