@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,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()
|