From 44e3458098fe7a4d10047b18b6c917d532f202b6 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:01:38 -0400 Subject: [PATCH 01/23] fix(build): fully specified relative imports for native Node ESM Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/node-esm-specifiers.md | 5 +++ package.json | 2 +- src/lib/animate/animate-value.ts | 8 ++-- src/lib/animate/core/animate.ts | 18 ++++----- src/lib/animate/core/controller.ts | 10 ++--- src/lib/animate/draw.ts | 8 ++-- src/lib/animate/index.ts | 38 +++++++++--------- src/lib/animate/keyframes/easing-utils.ts | 10 ++--- src/lib/animate/keyframes/fold.ts | 4 +- src/lib/animate/keyframes/keyframes.ts | 12 +++--- src/lib/animate/keyframes/keyword.ts | 8 ++-- src/lib/animate/keyframes/normalize.ts | 8 ++-- src/lib/animate/motion-path.ts | 4 +- src/lib/animate/properties/prop-utils.ts | 6 +-- src/lib/animate/properties/properties.ts | 2 +- src/lib/animate/properties/transform-setup.ts | 4 +- .../animate/properties/transform-tracker.ts | 4 +- src/lib/animate/spring-value.ts | 6 +-- src/lib/animate/stagger.ts | 6 +-- .../animate/timeline/timeline-internals.ts | 4 +- src/lib/animate/timeline/timeline.ts | 10 ++--- src/lib/animate/types.ts | 2 +- src/lib/easing/css.ts | 6 +-- src/lib/easing/cubic-bezier.ts | 4 +- src/lib/easing/index.ts | 8 ++-- src/lib/easing/primitive.ts | 2 +- src/lib/easing/spring.ts | 4 +- src/lib/flip/anchored.ts | 14 +++---- src/lib/flip/animator.ts | 16 ++++---- src/lib/flip/attachment.svelte.ts | 18 ++++----- src/lib/flip/bridge.ts | 2 +- src/lib/flip/controller-slot.ts | 8 ++-- src/lib/flip/dynamic.ts | 2 +- src/lib/flip/geometry.ts | 4 +- src/lib/flip/index.ts | 26 ++++++------ src/lib/flip/options.ts | 10 ++--- src/lib/flip/switcher.svelte.ts | 8 ++-- src/lib/flip/types.ts | 4 +- src/lib/gestures/draggable.ts | 20 +++++----- src/lib/gestures/focus.ts | 6 +-- src/lib/gestures/hover.ts | 6 +-- src/lib/gestures/index.ts | 40 +++++++++---------- src/lib/gestures/move.ts | 14 +++---- src/lib/gestures/pinch.ts | 14 +++---- src/lib/gestures/pointer-capture.ts | 4 +- src/lib/gestures/press.ts | 8 ++-- src/lib/gestures/reorder.ts | 16 ++++---- src/lib/gestures/swipe.ts | 10 ++--- src/lib/gestures/wheel.ts | 16 ++++---- src/lib/gradient/gradient.ts | 10 ++--- src/lib/gradient/index.ts | 8 ++-- src/lib/gradient/parse.ts | 2 +- src/lib/index.ts | 23 +++++------ src/lib/morph/index.ts | 12 +++--- src/lib/morph/interpolate.ts | 4 +- src/lib/morph/morph.ts | 10 ++--- src/lib/morph/normalize.ts | 2 +- src/lib/presence/index.ts | 4 +- src/lib/presence/transitions.ts | 6 +-- src/lib/scroll/in-view.ts | 4 +- src/lib/scroll/index.ts | 10 ++--- src/lib/scroll/progress.ts | 2 +- src/lib/scroll/scroll.ts | 10 ++--- src/lib/shared/spring-core.ts | 2 +- src/lib/text/index.ts | 4 +- src/lib/text/split.ts | 2 +- src/lib/variants/index.ts | 4 +- src/lib/variants/variants.svelte.ts | 6 +-- src/lib/view-transition/controller.ts | 8 ++-- src/lib/view-transition/index.ts | 12 +++--- src/lib/view-transition/name.svelte.ts | 4 +- src/lib/view-transition/navigation.ts | 6 +-- src/lib/view-transition/timing.ts | 6 +-- src/lib/view-transition/types.ts | 2 +- src/lib/view-transition/view-transition.ts | 8 ++-- 75 files changed, 322 insertions(+), 318 deletions(-) create mode 100644 .changeset/node-esm-specifiers.md diff --git a/.changeset/node-esm-specifiers.md b/.changeset/node-esm-specifiers.md new file mode 100644 index 0000000..613c12c --- /dev/null +++ b/.changeset/node-esm-specifiers.md @@ -0,0 +1,5 @@ +--- +'@ixirjs/pulse': patch +--- + +The published `dist` now uses fully specified relative imports (`./x.js`, `./dir/index.js`), so the package loads under native Node ESM (server scripts, SSR without a bundler) instead of failing with `ERR_UNSUPPORTED_DIR_IMPORT`. `prepack` now import-checks the build. diff --git a/package.json b/package.json index 6d03919..a34ac26 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "build": "vite build && npm run prepack", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", - "prepack": "svelte-kit sync && svelte-package && publint", + "prepack": "svelte-kit sync && svelte-package && publint && node -e \"import('@ixirjs/pulse')\"", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", diff --git a/src/lib/animate/animate-value.ts b/src/lib/animate/animate-value.ts index 86b90bc..d9ba001 100644 --- a/src/lib/animate/animate-value.ts +++ b/src/lib/animate/animate-value.ts @@ -25,10 +25,10 @@ * ``` */ -import { isBrowser, shouldReduceMotion } from '../shared/browser'; -import { frameTween } from '../shared/frame-tween'; -import { easeOut } from '../easing'; -import type { EasingFn } from './types'; +import { isBrowser, shouldReduceMotion } from '../shared/browser.js'; +import { frameTween } from '../shared/frame-tween.js'; +import { easeOut } from '../easing/index.js'; +import type { EasingFn } from './types.js'; /** Default tween duration in ms when none is supplied. */ const DEFAULT_DURATION = 300; diff --git a/src/lib/animate/core/animate.ts b/src/lib/animate/core/animate.ts index 46eaf15..8d36acc 100644 --- a/src/lib/animate/core/animate.ts +++ b/src/lib/animate/core/animate.ts @@ -19,9 +19,9 @@ * ``` */ -import { createController, noopController } from './controller'; -import { buildKeyframes, type KeyframeGroup } from '../keyframes/keyframes'; -import { normalizeInput } from '../keyframes/normalize'; +import { createController, noopController } from './controller.js'; +import { buildKeyframes, type KeyframeGroup } from '../keyframes/keyframes.js'; +import { normalizeInput } from '../keyframes/normalize.js'; import { demoteFoldedTransforms, deregisterTransformAnimation, @@ -29,17 +29,17 @@ import { registerFoldedTransforms, registerTransformAnimation, type FoldedTransform -} from '../properties/properties'; +} from '../properties/properties.js'; import { ensurePropertiesRegistered, ensureTransformWired, isTransformOwned, wireTransform -} from '../properties/transform-setup'; -import { applyFolds, planFolds } from '../keyframes/fold'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types'; -import { isBrowser, shouldReduceMotion } from '../../shared/browser'; -import { formatValue, resolveProp } from '../properties/prop-utils'; +} from '../properties/transform-setup.js'; +import { applyFolds, planFolds } from '../keyframes/fold.js'; +import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types.js'; +import { isBrowser, shouldReduceMotion } from '../../shared/browser.js'; +import { formatValue, resolveProp } from '../properties/prop-utils.js'; /** * Animate one or more properties on an element using the Web Animations API. diff --git a/src/lib/animate/core/controller.ts b/src/lib/animate/core/controller.ts index 078d0cf..cc020e3 100644 --- a/src/lib/animate/core/controller.ts +++ b/src/lib/animate/core/controller.ts @@ -3,11 +3,11 @@ * and own the lifecycle hooks (`onStart` / `onEnd`) plus inline-style cleanup. */ -import { isBrowser } from '../../shared/browser'; -import { demoteFoldedTransforms } from '../properties/properties'; -import { playbackControls } from '../../shared/playback'; -import type { AnimateDefaults, AnimationController, MotionElement } from '../types'; -import type { CssWrite } from '../keyframes/keyframes'; +import { isBrowser } from '../../shared/browser.js'; +import { demoteFoldedTransforms } from '../properties/properties.js'; +import { playbackControls } from '../../shared/playback.js'; +import type { AnimateDefaults, AnimationController, MotionElement } from '../types.js'; +import type { CssWrite } from '../keyframes/keyframes.js'; const NOOP = (): void => {}; const EMPTY_ANIMATIONS: readonly Animation[] = Object.freeze([]); diff --git a/src/lib/animate/draw.ts b/src/lib/animate/draw.ts index af840ec..a31ca31 100644 --- a/src/lib/animate/draw.ts +++ b/src/lib/animate/draw.ts @@ -18,10 +18,10 @@ * ``` */ -import { animate } from './core/animate'; -import { isBrowser } from '../shared/browser'; -import { noopController } from './core/controller'; -import type { AnimateDefaults, AnimationController } from './types'; +import { animate } from './core/animate.js'; +import { isBrowser } from '../shared/browser.js'; +import { noopController } from './core/controller.js'; +import type { AnimateDefaults, AnimationController } from './types.js'; export interface DrawOptions extends AnimateDefaults { /** Fraction of the path drawn at the start (`0` = hidden, `1` = full). Default 0. */ diff --git a/src/lib/animate/index.ts b/src/lib/animate/index.ts index b49f815..b8e78fd 100644 --- a/src/lib/animate/index.ts +++ b/src/lib/animate/index.ts @@ -4,25 +4,25 @@ * Implementation lives in sibling modules; this file only re-exports. */ -export { animate } from './core/animate'; -export { timeline } from './timeline/timeline'; -export type { Timeline, TimelineDefaults, TimelinePosition } from './timeline/timeline'; -export { spring } from '../shared/spring-core'; -export type { SpringSamples as Spring } from '../shared/spring-core'; -export { cubicBezier, springEasing } from '../easing'; -export type { SpringEasingFn } from '../easing'; -export * as easings from '../easing'; -export { stagger } from './stagger'; -export type { StaggerOptions } from './stagger'; +export { animate } from './core/animate.js'; +export { timeline } from './timeline/timeline.js'; +export type { Timeline, TimelineDefaults, TimelinePosition } from './timeline/timeline.js'; +export { spring } from '../shared/spring-core.js'; +export type { SpringSamples as Spring } from '../shared/spring-core.js'; +export { cubicBezier, springEasing } from '../easing/index.js'; +export type { SpringEasingFn } from '../easing/index.js'; +export * as easings from '../easing/index.js'; +export { stagger } from './stagger.js'; +export type { StaggerOptions } from './stagger.js'; -export { motionPath } from './motion-path'; -export type { MotionPathOptions } from './motion-path'; -export { draw } from './draw'; -export type { DrawOptions } from './draw'; -export { createSpringValue } from './spring-value'; -export type { SpringValue, SpringValueOptions } from './spring-value'; -export { animateValue, countUp } from './animate-value'; -export type { AnimateValueOptions, CountUpOptions, ValueController } from './animate-value'; +export { motionPath } from './motion-path.js'; +export type { MotionPathOptions } from './motion-path.js'; +export { draw } from './draw.js'; +export type { DrawOptions } from './draw.js'; +export { createSpringValue } from './spring-value.js'; +export type { SpringValue, SpringValueOptions } from './spring-value.js'; +export { animateValue, countUp } from './animate-value.js'; +export type { AnimateValueOptions, CountUpOptions, ValueController } from './animate-value.js'; export type { AnimatableValue, @@ -37,4 +37,4 @@ export type { PropInput, SpringInput, SpringOptions -} from './types'; +} from './types.js'; diff --git a/src/lib/animate/keyframes/easing-utils.ts b/src/lib/animate/keyframes/easing-utils.ts index 9ad364f..cc6327d 100644 --- a/src/lib/animate/keyframes/easing-utils.ts +++ b/src/lib/animate/keyframes/easing-utils.ts @@ -5,11 +5,11 @@ * with a WeakMap cache so repeated issuance with the same function is free. */ -import type { EasingFn } from '../types'; -import { cubicOut } from '../../easing/primitive'; -import { cssKeywordOf } from '../../easing/css'; -import { isSpringEasing } from '../../easing/spring'; -import { samplesToLinearEasing } from '../../shared/spring-core'; +import type { EasingFn } from '../types.js'; +import { cubicOut } from '../../easing/primitive.js'; +import { cssKeywordOf } from '../../easing/css.js'; +import { isSpringEasing } from '../../easing/spring.js'; +import { samplesToLinearEasing } from '../../shared/spring-core.js'; export const DEFAULT_DURATION = 300; /** Default easing — CSS `ease-out` cubic-bezier, shared with `cubicOut`. */ diff --git a/src/lib/animate/keyframes/fold.ts b/src/lib/animate/keyframes/fold.ts index 8a3ea41..b179d99 100644 --- a/src/lib/animate/keyframes/fold.ts +++ b/src/lib/animate/keyframes/fold.ts @@ -19,8 +19,8 @@ * composition contract still holds. */ -import { TRANSFORM_TEMPLATES } from '../properties/properties'; -import type { KeyframeGroup } from './keyframes'; +import { TRANSFORM_TEMPLATES } from '../properties/properties.js'; +import type { KeyframeGroup } from './keyframes.js'; /** A CSS property whose value is composed from motion vars. */ export type TransformTarget = keyof typeof TRANSFORM_TEMPLATES; diff --git a/src/lib/animate/keyframes/keyframes.ts b/src/lib/animate/keyframes/keyframes.ts index 499dccc..bc58cc9 100644 --- a/src/lib/animate/keyframes/keyframes.ts +++ b/src/lib/animate/keyframes/keyframes.ts @@ -3,18 +3,18 @@ * effective timing so we issue a single WAAPI animation per timing bucket. */ -import { VAR_BIT, type PropDef } from '../properties/properties'; -import type { AnimatableValue, AnimateDefaults, AnimateProps, MotionElement } from '../types'; -import { normalizeInput, resolveTiming } from './normalize'; -import { isBrowser } from '../../shared/browser'; -import { isAutoKeyword, measureKeywordValue } from './keyword'; +import { VAR_BIT, type PropDef } from '../properties/properties.js'; +import type { AnimatableValue, AnimateDefaults, AnimateProps, MotionElement } from '../types.js'; +import { normalizeInput, resolveTiming } from './normalize.js'; +import { isBrowser } from '../../shared/browser.js'; +import { isAutoKeyword, measureKeywordValue } from './keyword.js'; import { formatValue, isCompositable, readCurrentValue, resolveProp, toKeyframeKey -} from '../properties/prop-utils'; +} from '../properties/prop-utils.js'; export interface KeyframeGroup { timing: { duration: number; easing: string; delay: number }; diff --git a/src/lib/animate/keyframes/keyword.ts b/src/lib/animate/keyframes/keyword.ts index 63efedd..f9f948e 100644 --- a/src/lib/animate/keyframes/keyword.ts +++ b/src/lib/animate/keyframes/keyword.ts @@ -9,10 +9,10 @@ * element stays responsive to layout changes. */ -import type { PropDef } from '../properties/properties'; -import type { AnimatableValue, MotionElement } from '../types'; -import { isBrowser } from '../../shared/browser'; -import { restoreStyleProp, saveStyleProp } from '../../shared/inline-style'; +import type { PropDef } from '../properties/properties.js'; +import type { AnimatableValue, MotionElement } from '../types.js'; +import { isBrowser } from '../../shared/browser.js'; +import { restoreStyleProp, saveStyleProp } from '../../shared/inline-style.js'; const AUTO_KEYWORDS = new Set([ 'auto', diff --git a/src/lib/animate/keyframes/normalize.ts b/src/lib/animate/keyframes/normalize.ts index 2eeef96..c7c0916 100644 --- a/src/lib/animate/keyframes/normalize.ts +++ b/src/lib/animate/keyframes/normalize.ts @@ -6,7 +6,7 @@ * / delay) — including spring sampling. */ -import { getCachedSpring } from '../../shared/spring-core'; +import { getCachedSpring } from '../../shared/spring-core.js'; import type { AnimatableValue, AnimateDefaults, @@ -16,9 +16,9 @@ import type { PropInput, SpringInput, SpringOptions -} from '../types'; -import { DEFAULT_DURATION, easingToCss } from './easing-utils'; -import { isSpringEasing } from '../../easing/spring'; +} from '../types.js'; +import { DEFAULT_DURATION, easingToCss } from './easing-utils.js'; +import { isSpringEasing } from '../../easing/spring.js'; interface ResolvedTiming { duration: number; diff --git a/src/lib/animate/motion-path.ts b/src/lib/animate/motion-path.ts index 59df66e..243f242 100644 --- a/src/lib/animate/motion-path.ts +++ b/src/lib/animate/motion-path.ts @@ -18,8 +18,8 @@ * ``` */ -import { animate } from './core/animate'; -import type { AnimateDefaults, AnimationController, MotionElement } from './types'; +import { animate } from './core/animate.js'; +import type { AnimateDefaults, AnimationController, MotionElement } from './types.js'; export interface MotionPathOptions extends AnimateDefaults { /** Start position along the path. Number → percent (`0` = path start). Default 0. */ diff --git a/src/lib/animate/properties/prop-utils.ts b/src/lib/animate/properties/prop-utils.ts index a8ab118..d717357 100644 --- a/src/lib/animate/properties/prop-utils.ts +++ b/src/lib/animate/properties/prop-utils.ts @@ -5,9 +5,9 @@ * converts JS values to CSS strings, and reads live computed values from the DOM. */ -import type { AnimatableValue, MotionElement } from '../types'; -import { PROPERTY_REGISTRY, type PropDef } from './properties'; -import { isBrowser } from '../../shared/browser'; +import type { AnimatableValue, MotionElement } from '../types.js'; +import { PROPERTY_REGISTRY, type PropDef } from './properties.js'; +import { isBrowser } from '../../shared/browser.js'; /** * Convert a possibly-numeric value to its CSS string form, applying the diff --git a/src/lib/animate/properties/properties.ts b/src/lib/animate/properties/properties.ts index 45053f4..ac5bd99 100644 --- a/src/lib/animate/properties/properties.ts +++ b/src/lib/animate/properties/properties.ts @@ -214,4 +214,4 @@ export { hasActiveTransforms, measureWithoutAncestorTransforms, type FoldedTransform -} from './transform-tracker'; +} from './transform-tracker.js'; diff --git a/src/lib/animate/properties/transform-setup.ts b/src/lib/animate/properties/transform-setup.ts index fd3b24d..2fcfea1 100644 --- a/src/lib/animate/properties/transform-setup.ts +++ b/src/lib/animate/properties/transform-setup.ts @@ -6,8 +6,8 @@ * data in `properties.ts` so the data can be read without triggering setup. */ -import { PROPERTY_REGISTRY, TRANSFORM_TEMPLATES } from './properties'; -import type { MotionElement } from '../types'; +import { PROPERTY_REGISTRY, TRANSFORM_TEMPLATES } from './properties.js'; +import type { MotionElement } from '../types.js'; let registered = false; diff --git a/src/lib/animate/properties/transform-tracker.ts b/src/lib/animate/properties/transform-tracker.ts index 1dcd865..4840cd7 100644 --- a/src/lib/animate/properties/transform-tracker.ts +++ b/src/lib/animate/properties/transform-tracker.ts @@ -17,8 +17,8 @@ * static registry data and the dynamic runtime state live in different modules. */ -import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from '../../shared/inline-style'; -import type { MotionElement } from '../types'; +import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from '../../shared/inline-style.js'; +import type { MotionElement } from '../types.js'; /** * Identity values for every motion CSS custom property that drives a diff --git a/src/lib/animate/spring-value.ts b/src/lib/animate/spring-value.ts index ff89d36..147f7ff 100644 --- a/src/lib/animate/spring-value.ts +++ b/src/lib/animate/spring-value.ts @@ -23,9 +23,9 @@ * ``` */ -import { isBrowser } from '../shared/browser'; -import { SPRING_DEFAULTS } from '../shared/spring-core'; -import type { SpringOptions } from '../shared/types'; +import { isBrowser } from '../shared/browser.js'; +import { SPRING_DEFAULTS } from '../shared/spring-core.js'; +import type { SpringOptions } from '../shared/types.js'; /** Largest physics step (s) — clamps dt after a tab regains focus. */ const MAX_DT = 1 / 30; diff --git a/src/lib/animate/stagger.ts b/src/lib/animate/stagger.ts index 9a994fa..c17b415 100644 --- a/src/lib/animate/stagger.ts +++ b/src/lib/animate/stagger.ts @@ -6,7 +6,7 @@ * * @example * ```ts - * import { animate, stagger } from './index'; + * import { animate, stagger } from './index.js'; * * const delay = stagger(50); * items.forEach((el, i) => { @@ -28,8 +28,8 @@ * ``` */ -import { clamp01 } from '../shared/math'; -import type { EasingFn } from './types'; +import { clamp01 } from '../shared/math.js'; +import type { EasingFn } from './types.js'; export interface StaggerOptions { /** diff --git a/src/lib/animate/timeline/timeline-internals.ts b/src/lib/animate/timeline/timeline-internals.ts index 0960987..cfb8dfb 100644 --- a/src/lib/animate/timeline/timeline-internals.ts +++ b/src/lib/animate/timeline/timeline-internals.ts @@ -12,8 +12,8 @@ import type { AnimateProps, MotionElement, PropInput -} from '../types'; -import { isPropConfig, normalizeInput, resolveTiming, tupleToConfig } from '../keyframes/normalize'; +} from '../types.js'; +import { isPropConfig, normalizeInput, resolveTiming, tupleToConfig } from '../keyframes/normalize.js'; // --------------------------------------------------------------------------- // Entry shapes diff --git a/src/lib/animate/timeline/timeline.ts b/src/lib/animate/timeline/timeline.ts index ab22c17..5a7263a 100644 --- a/src/lib/animate/timeline/timeline.ts +++ b/src/lib/animate/timeline/timeline.ts @@ -33,16 +33,16 @@ * ``` */ -import { animate } from '../core/animate'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types'; -import { type Anchor, type TimelinePosition, resolvePosition } from './timeline-position'; +import { animate } from '../core/animate.js'; +import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types.js'; +import { type Anchor, type TimelinePosition, resolvePosition } from './timeline-position.js'; import { type Entry, type SetEntry, computeAnimateDuration, offsetProps -} from './timeline-internals'; -import { isBrowser } from '../../shared/browser'; +} from './timeline-internals.js'; +import { isBrowser } from '../../shared/browser.js'; // --------------------------------------------------------------------------- // Public types diff --git a/src/lib/animate/types.ts b/src/lib/animate/types.ts index 5e71657..19a45e5 100644 --- a/src/lib/animate/types.ts +++ b/src/lib/animate/types.ts @@ -2,7 +2,7 @@ * Public types for the `animate()` library. */ -import type { EasingFn, MotionElement, SpringOptions } from '../shared/types'; +import type { EasingFn, MotionElement, SpringOptions } from '../shared/types.js'; export type { EasingFn, MotionElement, SpringOptions }; diff --git a/src/lib/easing/css.ts b/src/lib/easing/css.ts index d740920..2852e69 100644 --- a/src/lib/easing/css.ts +++ b/src/lib/easing/css.ts @@ -1,6 +1,6 @@ -import type { EasingFn } from '../shared/types'; -import { cubicBezier } from './cubic-bezier'; -import { linear } from './primitive'; +import type { EasingFn } from '../shared/types.js'; +import { cubicBezier } from './cubic-bezier.js'; +import { linear } from './primitive.js'; /** * Tag an easing with the CSS keyword it is exactly equivalent to, so diff --git a/src/lib/easing/cubic-bezier.ts b/src/lib/easing/cubic-bezier.ts index af3b41a..d01b514 100644 --- a/src/lib/easing/cubic-bezier.ts +++ b/src/lib/easing/cubic-bezier.ts @@ -1,5 +1,5 @@ -import type { EasingFn } from '../shared/types'; -import { linear } from './primitive'; +import type { EasingFn } from '../shared/types.js'; +import { linear } from './primitive.js'; const NEWTON_ITERATIONS = 8; const NEWTON_MIN_SLOPE = 0.001; diff --git a/src/lib/easing/index.ts b/src/lib/easing/index.ts index 9447ff6..7c4fe55 100644 --- a/src/lib/easing/index.ts +++ b/src/lib/easing/index.ts @@ -16,7 +16,7 @@ * ``` */ -export * from './primitive'; -export * from './cubic-bezier'; -export * from './css'; -export * from './spring'; +export * from './primitive.js'; +export * from './cubic-bezier.js'; +export * from './css.js'; +export * from './spring.js'; diff --git a/src/lib/easing/primitive.ts b/src/lib/easing/primitive.ts index c9d1094..f22e8eb 100644 --- a/src/lib/easing/primitive.ts +++ b/src/lib/easing/primitive.ts @@ -1,4 +1,4 @@ -import type { EasingFn } from '../shared/types'; +import type { EasingFn } from '../shared/types.js'; export const linear: EasingFn = (t) => t; diff --git a/src/lib/easing/spring.ts b/src/lib/easing/spring.ts index 9f329e9..a8bd1cc 100644 --- a/src/lib/easing/spring.ts +++ b/src/lib/easing/spring.ts @@ -1,5 +1,5 @@ -import type { EasingFn, SpringOptions } from '../shared/types'; -import { getCachedSpring, sampleAt } from '../shared/spring-core'; +import type { EasingFn, SpringOptions } from '../shared/types.js'; +import { getCachedSpring, sampleAt } from '../shared/spring-core.js'; /** * A spring-physics easing function returned by `springEasing()`. diff --git a/src/lib/flip/anchored.ts b/src/lib/flip/anchored.ts index e39770c..83022e3 100644 --- a/src/lib/flip/anchored.ts +++ b/src/lib/flip/anchored.ts @@ -1,10 +1,10 @@ -import type { AnimationController } from '../animate/types'; -import { saveStyleProp, restoreStyleProp, type SavedStyleProp } from '../shared/inline-style'; -import { isBrowser } from '../shared/browser'; -import { animateFlip } from './animator'; -import { measure } from './geometry'; -import { resolveOpacity } from './options'; -import type { FlipOptions, FlipRect, MotionElement } from './types'; +import type { AnimationController } from '../animate/types.js'; +import { saveStyleProp, restoreStyleProp, type SavedStyleProp } from '../shared/inline-style.js'; +import { isBrowser } from '../shared/browser.js'; +import { animateFlip } from './animator.js'; +import { measure } from './geometry.js'; +import { resolveOpacity } from './options.js'; +import type { FlipOptions, FlipRect, MotionElement } from './types.js'; const FLIP_STYLE_PROPERTIES = ['--flip-x', '--flip-y', '--flip-scale-x', '--flip-scale-y'] as const; const OWNED_STYLE_PROPERTIES = [...FLIP_STYLE_PROPERTIES, 'opacity', 'pointer-events'] as const; diff --git a/src/lib/flip/animator.ts b/src/lib/flip/animator.ts index f244adc..b78c9bf 100644 --- a/src/lib/flip/animator.ts +++ b/src/lib/flip/animator.ts @@ -16,14 +16,14 @@ * the same controller so a single `cancel()` aborts the whole effect. */ -import { animate } from '../animate/core/animate'; -import type { AnimationController, MotionElement } from '../animate/types'; -import { buildFlipProps, isIdentityDelta, rectsEqual, resolveFlipDelta } from './geometry'; -import type { FlipDelta } from './geometry'; -import { isBrowser, shouldReduceMotion } from '../shared/browser'; -import { restoreStyleProp, saveStyleProp } from '../shared/inline-style'; -import { DEFAULT_DELAY, resolveDuration, resolveEasing, resolveOpacity } from './options'; -import type { FlipAnimateArgs } from './types'; +import { animate } from '../animate/core/animate.js'; +import type { AnimationController, MotionElement } from '../animate/types.js'; +import { buildFlipProps, isIdentityDelta, rectsEqual, resolveFlipDelta } from './geometry.js'; +import type { FlipDelta } from './geometry.js'; +import { isBrowser, shouldReduceMotion } from '../shared/browser.js'; +import { restoreStyleProp, saveStyleProp } from '../shared/inline-style.js'; +import { DEFAULT_DELAY, resolveDuration, resolveEasing, resolveOpacity } from './options.js'; +import type { FlipAnimateArgs } from './types.js'; /** * Parse a CSS `transform-origin` computed value (always in `px` in computed diff --git a/src/lib/flip/attachment.svelte.ts b/src/lib/flip/attachment.svelte.ts index 4e45336..03a0c1a 100644 --- a/src/lib/flip/attachment.svelte.ts +++ b/src/lib/flip/attachment.svelte.ts @@ -9,15 +9,15 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { createFrameBatch } from '../shared/frame-batch'; -import type { LayoutBridge } from './bridge'; -import { createDynamicAttrs } from './dynamic'; -import { createControllerSlot } from './controller-slot'; -import { carryVisualOffset, measure, measureVisual, rectsEqual } from './geometry'; -import { createObserverManager } from './observers'; -import { readOptions } from './options'; -import type { FlipOptions, FlipOptionsInput, FlipRect, MotionElement } from './types'; +import { isBrowser } from '../shared/browser.js'; +import { createFrameBatch } from '../shared/frame-batch.js'; +import type { LayoutBridge } from './bridge.js'; +import { createDynamicAttrs } from './dynamic.js'; +import { createControllerSlot } from './controller-slot.js'; +import { carryVisualOffset, measure, measureVisual, rectsEqual } from './geometry.js'; +import { createObserverManager } from './observers.js'; +import { readOptions } from './options.js'; +import type { FlipOptions, FlipOptionsInput, FlipRect, MotionElement } from './types.js'; /** * Core FLIP attachment factory. Shared between the standalone `flip()` diff --git a/src/lib/flip/bridge.ts b/src/lib/flip/bridge.ts index f74956b..598817a 100644 --- a/src/lib/flip/bridge.ts +++ b/src/lib/flip/bridge.ts @@ -7,7 +7,7 @@ * or two, so anything older is stale by definition. */ -import type { FlipRect } from './types'; +import type { FlipRect } from './types.js'; interface LayoutRecord { rect: FlipRect; diff --git a/src/lib/flip/controller-slot.ts b/src/lib/flip/controller-slot.ts index 03678eb..8dbd0f1 100644 --- a/src/lib/flip/controller-slot.ts +++ b/src/lib/flip/controller-slot.ts @@ -8,10 +8,10 @@ * cancel / wrap-onEnd / null-on-finish boilerplate. */ -import type { AnimationController } from '../animate/types'; -import { animateFlip } from './animator'; -import { resolveDuration } from './options'; -import type { FlipAnimateArgs } from './types'; +import type { AnimationController } from '../animate/types.js'; +import { animateFlip } from './animator.js'; +import { resolveDuration } from './options.js'; +import type { FlipAnimateArgs } from './types.js'; /** * Floor for an interrupted run's shortened duration — below this a retarget diff --git a/src/lib/flip/dynamic.ts b/src/lib/flip/dynamic.ts index 2b9f659..c41db52 100644 --- a/src/lib/flip/dynamic.ts +++ b/src/lib/flip/dynamic.ts @@ -18,7 +18,7 @@ */ import type { ClassValue } from 'svelte/elements'; -import type { MotionElement } from '../shared/types'; +import type { MotionElement } from '../shared/types.js'; export interface DynamicAttrs { /** Write class/style; returns `true` when the DOM actually changed. */ diff --git a/src/lib/flip/geometry.ts b/src/lib/flip/geometry.ts index 5275e5b..39b29d2 100644 --- a/src/lib/flip/geometry.ts +++ b/src/lib/flip/geometry.ts @@ -7,8 +7,8 @@ * is pure. */ -import { measureWithoutAncestorTransforms } from '../animate/properties/properties'; -import type { AnimateProps } from '../animate/types'; +import { measureWithoutAncestorTransforms } from '../animate/properties/properties.js'; +import type { AnimateProps } from '../animate/types.js'; // --------------------------------------------------------------------------- // Types diff --git a/src/lib/flip/index.ts b/src/lib/flip/index.ts index 56d4483..51f6fa7 100644 --- a/src/lib/flip/index.ts +++ b/src/lib/flip/index.ts @@ -8,12 +8,12 @@ */ import type { Attachment } from 'svelte/attachments'; -import type { AnimationController } from '../animate/types'; -import { animateFlip } from './animator'; -import { createFlipAttachment } from './attachment.svelte'; -import { createLayoutBridge } from './bridge'; -import { measure } from './geometry'; -import type { FlipOptions, FlipOptionsInput, FlipRect, FlipScope, MotionElement } from './types'; +import type { AnimationController } from '../animate/types.js'; +import { animateFlip } from './animator.js'; +import { createFlipAttachment } from './attachment.svelte.js'; +import { createLayoutBridge } from './bridge.js'; +import { measure } from './geometry.js'; +import type { FlipOptions, FlipOptionsInput, FlipRect, FlipScope, MotionElement } from './types.js'; // --------------------------------------------------------------------------- // High-level API @@ -73,22 +73,22 @@ export const createFlipScope = (): FlipScope => { }; }; -export { anchoredFlip } from './anchored'; +export { anchoredFlip } from './anchored.js'; export type { AnchoredFlipNodeFunction, AnchoredFlipOptions, AnchoredFlipReference, AnchoredFlipReferenceGetter -} from './anchored'; +} from './anchored.js'; // --------------------------------------------------------------------------- // Low-level re-exports // --------------------------------------------------------------------------- -export { createFlipSwitcher } from './switcher.svelte'; -export type { FlipSwitcher, FlipSwitchRole } from './switcher.svelte'; +export { createFlipSwitcher } from './switcher.svelte.js'; +export type { FlipSwitcher, FlipSwitchRole } from './switcher.svelte.js'; -export { animateFlip } from './animator'; -export { measure } from './geometry'; +export { animateFlip } from './animator.js'; +export { measure } from './geometry.js'; -export type * from './types'; +export type * from './types.js'; diff --git a/src/lib/flip/options.ts b/src/lib/flip/options.ts index 109bea4..1ccd1ab 100644 --- a/src/lib/flip/options.ts +++ b/src/lib/flip/options.ts @@ -3,10 +3,10 @@ * animator. All helpers are pure — no DOM access, no side effects. */ -import { CSS_EASINGS } from '../easing'; -import type { EasingFn } from '../shared/types'; -import { DEFAULT_EASING } from '../animate/keyframes/easing-utils'; -import { FLIP_DEFAULT_DURATION, diagonal } from './geometry'; +import { CSS_EASINGS } from '../easing/index.js'; +import type { EasingFn } from '../shared/types.js'; +import { DEFAULT_EASING } from '../animate/keyframes/easing-utils.js'; +import { FLIP_DEFAULT_DURATION, diagonal } from './geometry.js'; import type { FlipDuration, FlipEasing, @@ -14,7 +14,7 @@ import type { FlipOptions, FlipOptionsInput, FlipRectPair -} from './types'; +} from './types.js'; export const DEFAULT_DELAY = 0; diff --git a/src/lib/flip/switcher.svelte.ts b/src/lib/flip/switcher.svelte.ts index 45400cd..68a9e9a 100644 --- a/src/lib/flip/switcher.svelte.ts +++ b/src/lib/flip/switcher.svelte.ts @@ -20,10 +20,10 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { createControllerSlot } from './controller-slot'; -import { measure } from './geometry'; -import type { FlipOptions, FlipRect, MotionElement } from './types'; +import { isBrowser } from '../shared/browser.js'; +import { createControllerSlot } from './controller-slot.js'; +import { measure } from './geometry.js'; +import type { FlipOptions, FlipRect, MotionElement } from './types.js'; // --------------------------------------------------------------------------- // Public types diff --git a/src/lib/flip/types.ts b/src/lib/flip/types.ts index bd6770d..e79e859 100644 --- a/src/lib/flip/types.ts +++ b/src/lib/flip/types.ts @@ -1,7 +1,7 @@ import type { Attachment } from 'svelte/attachments'; import type { ClassValue } from 'svelte/elements'; -import type { FlipRect, FlipRectPair } from './geometry'; -import type { EasingFn, MotionElement } from '../shared/types'; +import type { FlipRect, FlipRectPair } from './geometry.js'; +import type { EasingFn, MotionElement } from '../shared/types.js'; // --------------------------------------------------------------------------- // Core geometry diff --git a/src/lib/gestures/draggable.ts b/src/lib/gestures/draggable.ts index c8b54b5..6007af3 100644 --- a/src/lib/gestures/draggable.ts +++ b/src/lib/gestures/draggable.ts @@ -11,16 +11,16 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import { trackVelocity } from './velocity'; -import type { MotionElement } from '../animate'; -import type { SpringOptions } from '../shared/types'; -import { createSpringValue } from '../animate/spring-value'; -import { capture, lockTouchAction, release } from './pointer-capture'; -import { wireTransform } from '../animate/properties/transform-setup'; -import { hintTransformLayer } from '../animate/properties/properties'; -import { applyConstraint, xBounds, yBounds, type DragConstraints } from './constraints'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import { trackVelocity } from './velocity.js'; +import type { MotionElement } from '../animate/index.js'; +import type { SpringOptions } from '../shared/types.js'; +import { createSpringValue } from '../animate/spring-value.js'; +import { capture, lockTouchAction, release } from './pointer-capture.js'; +import { wireTransform } from '../animate/properties/transform-setup.js'; +import { hintTransformLayer } from '../animate/properties/properties.js'; +import { applyConstraint, xBounds, yBounds, type DragConstraints } from './constraints.js'; export type DragAxis = 'x' | 'y' | 'both'; diff --git a/src/lib/gestures/focus.ts b/src/lib/gestures/focus.ts index a0bedf0..0911cd0 100644 --- a/src/lib/gestures/focus.ts +++ b/src/lib/gestures/focus.ts @@ -19,9 +19,9 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import type { MotionElement } from '../animate'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import type { MotionElement } from '../animate/index.js'; export interface FocusableOptions { onFocusStart?: (element: MotionElement, event: FocusEvent) => void; diff --git a/src/lib/gestures/hover.ts b/src/lib/gestures/hover.ts index 761a25a..7e538b4 100644 --- a/src/lib/gestures/hover.ts +++ b/src/lib/gestures/hover.ts @@ -15,9 +15,9 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import type { MotionElement } from '../animate'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import type { MotionElement } from '../animate/index.js'; export interface HoverableOptions { onHoverStart?: (element: MotionElement, event: PointerEvent) => void; diff --git a/src/lib/gestures/index.ts b/src/lib/gestures/index.ts index 67979b5..4b997d8 100644 --- a/src/lib/gestures/index.ts +++ b/src/lib/gestures/index.ts @@ -14,23 +14,23 @@ * All compose with `animate()` on the same element via the `--motion-*` chain. */ -export { draggable } from './draggable'; -export type { DraggableOptions, DragAxis, DragInfo } from './draggable'; -export { moveable } from './move'; -export type { MoveableOptions, MoveInfo } from './move'; -export { swipeable } from './swipe'; -export type { SwipeableOptions, SwipeAxis, SwipeDirection, SwipeInfo } from './swipe'; -export { hoverable } from './hover'; -export type { HoverableOptions } from './hover'; -export { focusable } from './focus'; -export type { FocusableOptions } from './focus'; -export { pressable } from './press'; -export type { PressableOptions } from './press'; -export { pinchable } from './pinch'; -export type { PinchableOptions, PinchInfo } from './pinch'; -export { wheelable } from './wheel'; -export type { WheelableOptions, WheelInfo } from './wheel'; -export { reorder } from './reorder'; -export type { ReorderOptions, ReorderHandle } from './reorder'; -export { applyConstraint, xBounds, yBounds } from './constraints'; -export type { DragConstraints, AxisBounds } from './constraints'; +export { draggable } from './draggable.js'; +export type { DraggableOptions, DragAxis, DragInfo } from './draggable.js'; +export { moveable } from './move.js'; +export type { MoveableOptions, MoveInfo } from './move.js'; +export { swipeable } from './swipe.js'; +export type { SwipeableOptions, SwipeAxis, SwipeDirection, SwipeInfo } from './swipe.js'; +export { hoverable } from './hover.js'; +export type { HoverableOptions } from './hover.js'; +export { focusable } from './focus.js'; +export type { FocusableOptions } from './focus.js'; +export { pressable } from './press.js'; +export type { PressableOptions } from './press.js'; +export { pinchable } from './pinch.js'; +export type { PinchableOptions, PinchInfo } from './pinch.js'; +export { wheelable } from './wheel.js'; +export type { WheelableOptions, WheelInfo } from './wheel.js'; +export { reorder } from './reorder.js'; +export type { ReorderOptions, ReorderHandle } from './reorder.js'; +export { applyConstraint, xBounds, yBounds } from './constraints.js'; +export type { DragConstraints, AxisBounds } from './constraints.js'; diff --git a/src/lib/gestures/move.ts b/src/lib/gestures/move.ts index 0de14e8..4c41697 100644 --- a/src/lib/gestures/move.ts +++ b/src/lib/gestures/move.ts @@ -18,13 +18,13 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import type { MotionElement } from '../animate'; -import type { SpringOptions } from '../shared/types'; -import { createSpringValue } from '../animate/spring-value'; -import { wireTransform } from '../animate/properties/transform-setup'; -import { hintTransformLayer } from '../animate/properties/properties'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import type { MotionElement } from '../animate/index.js'; +import type { SpringOptions } from '../shared/types.js'; +import { createSpringValue } from '../animate/spring-value.js'; +import { wireTransform } from '../animate/properties/transform-setup.js'; +import { hintTransformLayer } from '../animate/properties/properties.js'; export interface MoveInfo { /** Pointer position in client coordinates. */ diff --git a/src/lib/gestures/pinch.ts b/src/lib/gestures/pinch.ts index 0d6dfcb..244496e 100644 --- a/src/lib/gestures/pinch.ts +++ b/src/lib/gestures/pinch.ts @@ -11,13 +11,13 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import { applyConstraint, type AxisBounds } from './constraints'; -import type { MotionElement } from '../animate'; -import { capture, lockTouchAction, release } from './pointer-capture'; -import { wireTransform } from '../animate/properties/transform-setup'; -import { hintTransformLayer } from '../animate/properties/properties'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import { applyConstraint, type AxisBounds } from './constraints.js'; +import type { MotionElement } from '../animate/index.js'; +import { capture, lockTouchAction, release } from './pointer-capture.js'; +import { wireTransform } from '../animate/properties/transform-setup.js'; +import { hintTransformLayer } from '../animate/properties/properties.js'; export interface PinchInfo { /** Distance ratio of the two pointers relative to gesture start. */ diff --git a/src/lib/gestures/pointer-capture.ts b/src/lib/gestures/pointer-capture.ts index 94bed1a..cd7bf55 100644 --- a/src/lib/gestures/pointer-capture.ts +++ b/src/lib/gestures/pointer-capture.ts @@ -8,8 +8,8 @@ * teardown is always safe. */ -import { restoreStyleProp, saveStyleProp } from '../shared/inline-style'; -import type { MotionElement } from '../shared/types'; +import { restoreStyleProp, saveStyleProp } from '../shared/inline-style.js'; +import type { MotionElement } from '../shared/types.js'; /** The axis a gesture owns; the browser keeps panning on the other one. */ export type GestureAxis = 'x' | 'y' | 'both'; diff --git a/src/lib/gestures/press.ts b/src/lib/gestures/press.ts index 87ca29a..942f96b 100644 --- a/src/lib/gestures/press.ts +++ b/src/lib/gestures/press.ts @@ -23,10 +23,10 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import type { MotionElement } from '../animate'; -import { capture, release } from './pointer-capture'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import type { MotionElement } from '../animate/index.js'; +import { capture, release } from './pointer-capture.js'; export interface PressableOptions { /** Pointer went down on the element. */ diff --git a/src/lib/gestures/reorder.ts b/src/lib/gestures/reorder.ts index e8f8b13..92fe319 100644 --- a/src/lib/gestures/reorder.ts +++ b/src/lib/gestures/reorder.ts @@ -22,14 +22,14 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import { snapshotRect, flipFrom } from '../flip'; -import type { EasingFn } from '../shared/types'; -import { wireTransform } from '../animate/properties/transform-setup'; -import { hintTransformLayer } from '../animate/properties/properties'; -import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from '../shared/inline-style'; -import { capture, lockTouchAction, release } from './pointer-capture'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import { snapshotRect, flipFrom } from '../flip/index.js'; +import type { EasingFn } from '../shared/types.js'; +import { wireTransform } from '../animate/properties/transform-setup.js'; +import { hintTransformLayer } from '../animate/properties/properties.js'; +import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from '../shared/inline-style.js'; +import { capture, lockTouchAction, release } from './pointer-capture.js'; /** Find the nearest slot center to `target`. Linear — reorder lists are short. */ export const nearestCenterIndex = (centers: readonly number[], target: number): number => { diff --git a/src/lib/gestures/swipe.ts b/src/lib/gestures/swipe.ts index f2a091e..e9441a2 100644 --- a/src/lib/gestures/swipe.ts +++ b/src/lib/gestures/swipe.ts @@ -13,11 +13,11 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import { trackVelocity } from './velocity'; -import type { MotionElement } from '../animate'; -import { capture, lockTouchAction, release } from './pointer-capture'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import { trackVelocity } from './velocity.js'; +import type { MotionElement } from '../animate/index.js'; +import { capture, lockTouchAction, release } from './pointer-capture.js'; export type SwipeAxis = 'x' | 'y' | 'both'; export type SwipeDirection = 'left' | 'right' | 'up' | 'down'; diff --git a/src/lib/gestures/wheel.ts b/src/lib/gestures/wheel.ts index 282f22f..b4a2ded 100644 --- a/src/lib/gestures/wheel.ts +++ b/src/lib/gestures/wheel.ts @@ -18,14 +18,14 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { listen } from '../shared/listen'; -import { applyConstraint, type AxisBounds } from './constraints'; -import type { MotionElement } from '../animate'; -import { createSpringValue } from '../animate/spring-value'; -import type { SpringOptions } from '../shared/types'; -import { wireTransform } from '../animate/properties/transform-setup'; -import { hintTransformLayer } from '../animate/properties/properties'; +import { isBrowser } from '../shared/browser.js'; +import { listen } from '../shared/listen.js'; +import { applyConstraint, type AxisBounds } from './constraints.js'; +import type { MotionElement } from '../animate/index.js'; +import { createSpringValue } from '../animate/spring-value.js'; +import type { SpringOptions } from '../shared/types.js'; +import { wireTransform } from '../animate/properties/transform-setup.js'; +import { hintTransformLayer } from '../animate/properties/properties.js'; export interface WheelInfo { /** Accumulated scale since the element mounted (baseline 1). */ diff --git a/src/lib/gradient/gradient.ts b/src/lib/gradient/gradient.ts index 229a072..1c360c9 100644 --- a/src/lib/gradient/gradient.ts +++ b/src/lib/gradient/gradient.ts @@ -25,17 +25,17 @@ * ``` */ -import { isBrowser } from '../shared/browser'; -import { frameTween } from '../shared/frame-tween'; -import { easeOut } from '../easing'; -import type { EasingFn } from '../shared/types'; +import { isBrowser } from '../shared/browser.js'; +import { frameTween } from '../shared/frame-tween.js'; +import { easeOut } from '../easing/index.js'; +import type { EasingFn } from '../shared/types.js'; import { formatInterpolatedLinearGradient, parseLinearGradient, parseRGBA, resolvePositions, type RGBA -} from './parse'; +} from './parse.js'; export interface GradientOptions { /** Duration in ms. Default 400. */ diff --git a/src/lib/gradient/index.ts b/src/lib/gradient/index.ts index 3146bd1..b105fc7 100644 --- a/src/lib/gradient/index.ts +++ b/src/lib/gradient/index.ts @@ -3,7 +3,7 @@ * which WAAPI cannot animate natively. */ -export { animateGradient } from './gradient'; -export type { GradientOptions, GradientController } from './gradient'; -export { parseLinearGradient, formatLinearGradient, parseRGBA, lerpRGBA } from './parse'; -export type { RGBA, LinearGradient, GradientStop } from './parse'; +export { animateGradient } from './gradient.js'; +export type { GradientOptions, GradientController } from './gradient.js'; +export { parseLinearGradient, formatLinearGradient, parseRGBA, lerpRGBA } from './parse.js'; +export type { RGBA, LinearGradient, GradientStop } from './parse.js'; diff --git a/src/lib/gradient/parse.ts b/src/lib/gradient/parse.ts index f2999a5..daedc6f 100644 --- a/src/lib/gradient/parse.ts +++ b/src/lib/gradient/parse.ts @@ -4,7 +4,7 @@ * driver layer, which feeds already-resolved `rgb/rgba/#hex` colours here. */ -import { lerp } from '../shared/math'; +import { lerp } from '../shared/math.js'; export type RGBA = readonly [r: number, g: number, b: number, a: number]; diff --git a/src/lib/index.ts b/src/lib/index.ts index c5a4982..f6d2cee 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,15 +9,14 @@ * - Composes safely with sibling `animate()` calls — both go through the * same `--motion-*` custom properties so transforms never clobber. */ -export * from './flip'; -export * from './animate'; -export * from './easing'; -// Explicit index avoids relying on directory-import resolution in consuming Vite projects. -export * from './scroll/index'; -export * from './gestures'; -export * from './presence'; -export * from './variants'; -export * from './gradient'; -export * from './morph'; -export * from './text'; -export * from './view-transition'; +export * from './flip/index.js'; +export * from './animate/index.js'; +export * from './easing/index.js'; +export * from './scroll/index.js'; +export * from './gestures/index.js'; +export * from './presence/index.js'; +export * from './variants/index.js'; +export * from './gradient/index.js'; +export * from './morph/index.js'; +export * from './text/index.js'; +export * from './view-transition/index.js'; diff --git a/src/lib/morph/index.ts b/src/lib/morph/index.ts index 70d3e42..0dab3a2 100644 --- a/src/lib/morph/index.ts +++ b/src/lib/morph/index.ts @@ -3,9 +3,9 @@ * WAAPI cannot do across arbitrary command lists. */ -export { morph } from './morph'; -export type { MorphOptions, MorphController } from './morph'; -export { parsePath } from './parse'; -export type { PathCommand } from './parse'; -export { normalizePath } from './normalize'; -export type { Subpath, CubicSegment, Point } from './normalize'; +export { morph } from './morph.js'; +export type { MorphOptions, MorphController } from './morph.js'; +export { parsePath } from './parse.js'; +export type { PathCommand } from './parse.js'; +export { normalizePath } from './normalize.js'; +export type { Subpath, CubicSegment, Point } from './normalize.js'; diff --git a/src/lib/morph/interpolate.ts b/src/lib/morph/interpolate.ts index 006c6c5..a20e254 100644 --- a/src/lib/morph/interpolate.ts +++ b/src/lib/morph/interpolate.ts @@ -8,8 +8,8 @@ * DOM-free and pure. */ -import { lerp } from '../shared/math'; -import { normalizePath, type CubicSegment, type Point, type Subpath } from './normalize'; +import { lerp } from '../shared/math.js'; +import { normalizePath, type CubicSegment, type Point, type Subpath } from './normalize.js'; const lerpPoint = (a: Point, b: Point, t: number): Point => [ a[0] + (b[0] - a[0]) * t, diff --git a/src/lib/morph/morph.ts b/src/lib/morph/morph.ts index 0172d86..0590a75 100644 --- a/src/lib/morph/morph.ts +++ b/src/lib/morph/morph.ts @@ -15,11 +15,11 @@ * ``` */ -import { isBrowser, shouldReduceMotion } from '../shared/browser'; -import { frameTween } from '../shared/frame-tween'; -import { easeOut } from '../easing'; -import type { EasingFn } from '../shared/types'; -import { interpolatePlan, planMorph } from './interpolate'; +import { isBrowser, shouldReduceMotion } from '../shared/browser.js'; +import { frameTween } from '../shared/frame-tween.js'; +import { easeOut } from '../easing/index.js'; +import type { EasingFn } from '../shared/types.js'; +import { interpolatePlan, planMorph } from './interpolate.js'; export interface MorphOptions { /** Duration in ms. Default 400. */ diff --git a/src/lib/morph/normalize.ts b/src/lib/morph/normalize.ts index d771d7d..cd4524b 100644 --- a/src/lib/morph/normalize.ts +++ b/src/lib/morph/normalize.ts @@ -8,7 +8,7 @@ * DOM-free and pure. */ -import { parsePath, type PathCommand } from './parse'; +import { parsePath, type PathCommand } from './parse.js'; export type Point = [number, number]; diff --git a/src/lib/presence/index.ts b/src/lib/presence/index.ts index 980729b..312cde5 100644 --- a/src/lib/presence/index.ts +++ b/src/lib/presence/index.ts @@ -4,7 +4,7 @@ * mount/unmount choreography. */ -export { fade, fly, scale, size } from './transitions'; +export { fade, fly, scale, size } from './transitions.js'; export type { PresenceParams, SizeFields, @@ -13,4 +13,4 @@ export type { FlyParams, ScaleParams, SizeParams -} from './transitions'; +} from './transitions.js'; diff --git a/src/lib/presence/transitions.ts b/src/lib/presence/transitions.ts index 84b6688..316f318 100644 --- a/src/lib/presence/transitions.ts +++ b/src/lib/presence/transitions.ts @@ -17,9 +17,9 @@ */ import type { TransitionConfig } from 'svelte/transition'; -import { easeOut, springEasing } from '../easing'; -import { restoreStyleProp, saveStyleProp } from '../shared/inline-style'; -import type { EasingFn, SpringOptions } from '../shared/types'; +import { easeOut, springEasing } from '../easing/index.js'; +import { restoreStyleProp, saveStyleProp } from '../shared/inline-style.js'; +import type { EasingFn, SpringOptions } from '../shared/types.js'; export interface PresenceParams { /** Delay before the transition starts (ms). */ diff --git a/src/lib/scroll/in-view.ts b/src/lib/scroll/in-view.ts index cfae8cd..d9ee761 100644 --- a/src/lib/scroll/in-view.ts +++ b/src/lib/scroll/in-view.ts @@ -17,8 +17,8 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import type { MotionElement } from '../animate'; +import { isBrowser } from '../shared/browser.js'; +import type { MotionElement } from '../animate/index.js'; export interface InViewOptions { /** Fired when the element crosses into view. */ diff --git a/src/lib/scroll/index.ts b/src/lib/scroll/index.ts index c02d604..0f860b3 100644 --- a/src/lib/scroll/index.ts +++ b/src/lib/scroll/index.ts @@ -6,8 +6,8 @@ * - Pure progress math (`coverProgress`, `containProgress`, `pageProgress`). */ -export { scroll } from './scroll'; -export type { ScrollOptions, ScrollAxis, ScrollRange } from './scroll'; -export { inView } from './in-view'; -export type { InViewOptions } from './in-view'; -export { coverProgress, containProgress, pageProgress } from './progress'; +export { scroll } from './scroll.js'; +export type { ScrollOptions, ScrollAxis, ScrollRange } from './scroll.js'; +export { inView } from './in-view.js'; +export type { InViewOptions } from './in-view.js'; +export { coverProgress, containProgress, pageProgress } from './progress.js'; diff --git a/src/lib/scroll/progress.ts b/src/lib/scroll/progress.ts index d6cebf3..9a77674 100644 --- a/src/lib/scroll/progress.ts +++ b/src/lib/scroll/progress.ts @@ -4,7 +4,7 @@ * progress value. */ -import { clamp01 } from '../shared/math'; +import { clamp01 } from '../shared/math.js'; /** Whole-scroller progress: how far the scroll position has travelled. */ export const pageProgress = (scroll: number, scrollSize: number, viewport: number): number => diff --git a/src/lib/scroll/scroll.ts b/src/lib/scroll/scroll.ts index e1df33b..258c119 100644 --- a/src/lib/scroll/scroll.ts +++ b/src/lib/scroll/scroll.ts @@ -24,11 +24,11 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { createFrameBatch } from '../shared/frame-batch'; -import { listen } from '../shared/listen'; -import type { AnimationController, MotionElement } from '../animate'; -import { coverProgress, containProgress, pageProgress } from './progress'; +import { isBrowser } from '../shared/browser.js'; +import { createFrameBatch } from '../shared/frame-batch.js'; +import { listen } from '../shared/listen.js'; +import type { AnimationController, MotionElement } from '../animate/index.js'; +import { coverProgress, containProgress, pageProgress } from './progress.js'; export type ScrollAxis = 'x' | 'y'; export type ScrollRange = 'cover' | 'contain' | 'page'; diff --git a/src/lib/shared/spring-core.ts b/src/lib/shared/spring-core.ts index 8e4cf63..d307055 100644 --- a/src/lib/shared/spring-core.ts +++ b/src/lib/shared/spring-core.ts @@ -6,7 +6,7 @@ * This module has zero DOM dependencies and only imports from `shared/types`. */ -import type { SpringOptions } from './types'; +import type { SpringOptions } from './types.js'; const DT_MS = 1000 / 60; const MAX_DURATION_MS = 10_000; diff --git a/src/lib/text/index.ts b/src/lib/text/index.ts index 8855c67..448ad14 100644 --- a/src/lib/text/index.ts +++ b/src/lib/text/index.ts @@ -4,5 +4,5 @@ * `stagger()` for staggered reveals). The GSAP-SplitText analog. */ -export { splitText, tokenizeWords, tokenizeChars } from './split'; -export type { SplitType, SplitTextOptions, SplitTextResult } from './split'; +export { splitText, tokenizeWords, tokenizeChars } from './split.js'; +export type { SplitType, SplitTextOptions, SplitTextResult } from './split.js'; diff --git a/src/lib/text/split.ts b/src/lib/text/split.ts index 911ae4f..70f3c94 100644 --- a/src/lib/text/split.ts +++ b/src/lib/text/split.ts @@ -8,7 +8,7 @@ * wrapper that builds the spans and is browser-only. */ -import { isBrowser } from '../shared/browser'; +import { isBrowser } from '../shared/browser.js'; /** Which granularity / granularities to split into. Default: `'chars'`. */ export type SplitType = 'chars' | 'words' | 'lines'; diff --git a/src/lib/variants/index.ts b/src/lib/variants/index.ts index ebbe8a3..5f7d551 100644 --- a/src/lib/variants/index.ts +++ b/src/lib/variants/index.ts @@ -5,10 +5,10 @@ * - `variants()` — reactive Svelte attachment driven by a state thunk. */ -export { createVariants, variants } from './variants.svelte'; +export { createVariants, variants } from './variants.svelte.js'; export type { VariantMap, VariantsOptions, VariantsController, VariantsAttachmentOptions -} from './variants.svelte'; +} from './variants.svelte.js'; diff --git a/src/lib/variants/variants.svelte.ts b/src/lib/variants/variants.svelte.ts index 9f41627..ba45591 100644 --- a/src/lib/variants/variants.svelte.ts +++ b/src/lib/variants/variants.svelte.ts @@ -30,9 +30,9 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import { animate } from '../animate'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../animate'; +import { isBrowser } from '../shared/browser.js'; +import { animate } from '../animate/index.js'; +import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../animate/index.js'; /** Map of state name → target props. */ export type VariantMap = Record; diff --git a/src/lib/view-transition/controller.ts b/src/lib/view-transition/controller.ts index 1840cdb..2841539 100644 --- a/src/lib/view-transition/controller.ts +++ b/src/lib/view-transition/controller.ts @@ -11,10 +11,10 @@ * controllable. */ -import type { AnimationController } from '../animate/types'; -import { playbackControls } from '../shared/playback'; -import { resolveViewTransitionTiming } from './timing'; -import type { ViewTransitionOptions } from './types'; +import type { AnimationController } from '../animate/types.js'; +import { playbackControls } from '../shared/playback.js'; +import { resolveViewTransitionTiming } from './timing.js'; +import type { ViewTransitionOptions } from './types.js'; const NOOP = (): void => {}; const EMPTY_ANIMATIONS: readonly Animation[] = Object.freeze([]); diff --git a/src/lib/view-transition/index.ts b/src/lib/view-transition/index.ts index 02264d4..3595c8c 100644 --- a/src/lib/view-transition/index.ts +++ b/src/lib/view-transition/index.ts @@ -23,10 +23,10 @@ * ``` */ -export { viewTransition, supportsViewTransitions } from './view-transition'; -export { viewTransitionName } from './name.svelte'; -export { viewTransitionNavigate } from './navigation'; +export { viewTransition, supportsViewTransitions } from './view-transition.js'; +export { viewTransitionName } from './name.svelte.js'; +export { viewTransitionNavigate } from './navigation.js'; -export type { ViewTransitionOptions, ViewTransitionUpdate } from './types'; -export type { ViewTransitionNameInput } from './name.svelte'; -export type { NavigationLike } from './navigation'; +export type { ViewTransitionOptions, ViewTransitionUpdate } from './types.js'; +export type { ViewTransitionNameInput } from './name.svelte.js'; +export type { NavigationLike } from './navigation.js'; diff --git a/src/lib/view-transition/name.svelte.ts b/src/lib/view-transition/name.svelte.ts index 758c98a..eb6b9dd 100644 --- a/src/lib/view-transition/name.svelte.ts +++ b/src/lib/view-transition/name.svelte.ts @@ -5,8 +5,8 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '../shared/browser'; -import type { MotionElement } from '../shared/types'; +import { isBrowser } from '../shared/browser.js'; +import type { MotionElement } from '../shared/types.js'; /** A static name, or a thunk read reactively so the name can track `$state`. */ export type ViewTransitionNameInput = string | (() => string | undefined); diff --git a/src/lib/view-transition/navigation.ts b/src/lib/view-transition/navigation.ts index 252701c..e12a553 100644 --- a/src/lib/view-transition/navigation.ts +++ b/src/lib/view-transition/navigation.ts @@ -6,9 +6,9 @@ * helper works with any object exposing a `complete` promise. */ -import { isBrowser, shouldReduceMotion } from '../shared/browser'; -import { supportsViewTransitions, viewTransition } from './view-transition'; -import type { ViewTransitionOptions } from './types'; +import { isBrowser, shouldReduceMotion } from '../shared/browser.js'; +import { supportsViewTransitions, viewTransition } from './view-transition.js'; +import type { ViewTransitionOptions } from './types.js'; /** Structural shape of SvelteKit's `Navigation` — only `complete` is needed. */ export interface NavigationLike { diff --git a/src/lib/view-transition/timing.ts b/src/lib/view-transition/timing.ts index f6ccbe1..962491a 100644 --- a/src/lib/view-transition/timing.ts +++ b/src/lib/view-transition/timing.ts @@ -4,9 +4,9 @@ * browser integration in `controller.ts`. */ -import { springEasing } from '../easing'; -import { easingToCss } from '../animate/keyframes/easing-utils'; -import type { ViewTransitionOptions } from './types'; +import { springEasing } from '../easing/index.js'; +import { easingToCss } from '../animate/keyframes/easing-utils.js'; +import type { ViewTransitionOptions } from './types.js'; /** * Resolve the timing options into an `EffectTiming` suitable for diff --git a/src/lib/view-transition/types.ts b/src/lib/view-transition/types.ts index 9cfc432..c056996 100644 --- a/src/lib/view-transition/types.ts +++ b/src/lib/view-transition/types.ts @@ -2,7 +2,7 @@ * Public types for the `view-transition` module. */ -import type { EasingFn, SpringInput } from '../animate/types'; +import type { EasingFn, SpringInput } from '../animate/types.js'; export type { EasingFn, SpringInput }; diff --git a/src/lib/view-transition/view-transition.ts b/src/lib/view-transition/view-transition.ts index ed24dd5..61c3d04 100644 --- a/src/lib/view-transition/view-transition.ts +++ b/src/lib/view-transition/view-transition.ts @@ -3,10 +3,10 @@ * spring/easing engine, behind the uniform {@link AnimationController}. */ -import { isBrowser, shouldReduceMotion } from '../shared/browser'; -import type { AnimationController } from '../animate/types'; -import { createViewTransitionController, resolvedController } from './controller'; -import type { ViewTransitionOptions, ViewTransitionUpdate } from './types'; +import { isBrowser, shouldReduceMotion } from '../shared/browser.js'; +import type { AnimationController } from '../animate/types.js'; +import { createViewTransitionController, resolvedController } from './controller.js'; +import type { ViewTransitionOptions, ViewTransitionUpdate } from './types.js'; /** True when the current document supports the View Transitions API. */ export const supportsViewTransitions = (): boolean => From 7d4fcd2005f8ca105c3a81071bb89659832b0022 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:02:06 -0400 Subject: [PATCH 02/23] fix(morph): stop parsing at malformed path data instead of looping Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 7 +++++++ src/lib/morph/parse.test.ts | 8 ++++++++ src/lib/morph/parse.ts | 5 +++++ 3 files changed, 20 insertions(+) create mode 100644 .changeset/review-fixes.md diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md new file mode 100644 index 0000000..9b21c92 --- /dev/null +++ b/.changeset/review-fixes.md @@ -0,0 +1,7 @@ +--- +'@ixirjs/pulse': patch +--- + +Bug fixes from a performance / DX review: + +- `parsePath` / `morph()` no longer hang on malformed path data (e.g. `M0 0 Z 1`, `M0 0 L?`); parsing stops at the first error, as SVG renderers do. diff --git a/src/lib/morph/parse.test.ts b/src/lib/morph/parse.test.ts index 99585ff..7c53b5d 100644 --- a/src/lib/morph/parse.test.ts +++ b/src/lib/morph/parse.test.ts @@ -53,4 +53,12 @@ describe('parsePath()', () => { { code: 'Z', values: [] } ]); }); + + it('stops at malformed input instead of looping forever', () => { + expect(parsePath('M0 0 Z 1')).toEqual([ + { code: 'M', values: [0, 0] }, + { code: 'Z', values: [] } + ]); + expect(parsePath('M0 0 L?')).toEqual([{ code: 'M', values: [0, 0] }]); + }); }); diff --git a/src/lib/morph/parse.ts b/src/lib/morph/parse.ts index 5c52564..6a3ffc5 100644 --- a/src/lib/morph/parse.ts +++ b/src/lib/morph/parse.ts @@ -72,6 +72,7 @@ export const parsePath = (d: string): PathCommand[] => { while (i < n) { skipSep(); if (i >= n) break; + const start = i; let code: string; const ch = d[i]!; @@ -102,6 +103,10 @@ export const parsePath = (d: string): PathCommand[] => { } else { for (let k = 0; k < count; k++) values.push(readNumber()); } + // Malformed input: a bad number, or an implicit repeat that consumed + // nothing (e.g. a stray number after `Z`). Stop at the error like SVG + // renderers do — continuing would loop forever without advancing. + if (i === start || values.some(Number.isNaN)) break; commands.push({ code, values }); lastCode = code; From fc1e7137c39825f3be9389bfc8177217388afae9 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:03:08 -0400 Subject: [PATCH 03/23] fix(animate): don't re-arm rAF loops cancelled from inside their callback Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/animate/core/animate.svelte.test.ts | 12 ++++++++++++ src/lib/animate/core/controller.ts | 3 ++- src/lib/animate/spring-value.svelte.test.ts | 12 ++++++++++++ src/lib/animate/spring-value.ts | 4 +++- src/lib/shared/frame-tween.test.ts | 11 +++++++++++ src/lib/shared/frame-tween.ts | 1 + 7 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 9b21c92..98b884c 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -5,3 +5,4 @@ Bug fixes from a performance / DX review: - `parsePath` / `morph()` no longer hang on malformed path data (e.g. `M0 0 Z 1`, `M0 0 L?`); parsing stops at the first error, as SVG renderers do. +- `onUpdate` (animate), `frameTween` frames and `createSpringValue` listeners can now cancel/stop their animation from inside the callback; previously the rAF loop re-armed itself and ran forever. diff --git a/src/lib/animate/core/animate.svelte.test.ts b/src/lib/animate/core/animate.svelte.test.ts index 5898296..aaae065 100644 --- a/src/lib/animate/core/animate.svelte.test.ts +++ b/src/lib/animate/core/animate.svelte.test.ts @@ -74,4 +74,16 @@ describe('animate() onUpdate', () => { await new Promise((r) => setTimeout(r, 50)); expect(calls).toBe(after); }); + + it('does not re-arm when onUpdate cancels the controller', async () => { + const node = mount(); + let calls = 0; + const ctrl = animate( + node, + { x: [0, 200] }, + { duration: 300, onUpdate: () => (calls++, ctrl.cancel()) } + ); + await new Promise((r) => setTimeout(r, 80)); + expect(calls).toBe(1); + }); }); diff --git a/src/lib/animate/core/controller.ts b/src/lib/animate/core/controller.ts index cc020e3..daa6243 100644 --- a/src/lib/animate/core/controller.ts +++ b/src/lib/animate/core/controller.ts @@ -113,7 +113,8 @@ export const createController = ({ const tick = (): void => { const progress = primaryAnimation.effect?.getComputedTiming().progress ?? 1; onUpdate(progress, element); - rafId = requestAnimationFrame(tick); + // onUpdate may have cancelled/stopped the controller — don't re-arm. + if (rafId) rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); }; diff --git a/src/lib/animate/spring-value.svelte.test.ts b/src/lib/animate/spring-value.svelte.test.ts index ffb2918..ad3325e 100644 --- a/src/lib/animate/spring-value.svelte.test.ts +++ b/src/lib/animate/spring-value.svelte.test.ts @@ -46,4 +46,16 @@ describe('createSpringValue() — live', () => { expect(s.current).toBe(42); expect(s.animating).toBe(false); }); + + it('does not re-arm when a listener stops the spring', async () => { + const s = createSpringValue(); + let calls = 0; + s.subscribe(() => { + if (++calls > 1) s.stop(); // first call is the synchronous initial value + }); + s.set(100); + await new Promise((r) => setTimeout(r, 80)); + expect(calls).toBe(2); + expect(s.animating).toBe(false); + }); }); diff --git a/src/lib/animate/spring-value.ts b/src/lib/animate/spring-value.ts index 147f7ff..7aae7f4 100644 --- a/src/lib/animate/spring-value.ts +++ b/src/lib/animate/spring-value.ts @@ -106,6 +106,7 @@ export const createSpringValue = (options: SpringValueOptions = {}): SpringValue Math.abs(target - value) < restDelta && Math.abs(velocity) < restSpeed; const tick = (now: number): void => { + const self = frame; // dt in seconds, clamped so a long pause can't explode the integrator. const dt = Math.min((now - lastTime) / 1000, MAX_DT); lastTime = now; @@ -124,7 +125,8 @@ export const createSpringValue = (options: SpringValueOptions = {}): SpringValue return; } notify(); - frame = requestAnimationFrame(tick); + // A listener may have stopped or restarted the spring; that loop owns it now. + if (frame === self) frame = requestAnimationFrame(tick); }; const start = (): void => { diff --git a/src/lib/shared/frame-tween.test.ts b/src/lib/shared/frame-tween.test.ts index 8bd6f48..e40dbd9 100644 --- a/src/lib/shared/frame-tween.test.ts +++ b/src/lib/shared/frame-tween.test.ts @@ -82,4 +82,15 @@ describe('frameTween()', () => { expect(queue).toHaveLength(0); expect(complete).not.toHaveBeenCalled(); }); + + it('does not re-arm when onFrame cancels the tween', () => { + const { queue, flush } = installFrames(); + const tween: { cancel(): void } = frameTween({ + duration: 100, + delay: 0, + onFrame: () => tween.cancel() + }); + flush(0); + expect(queue).toHaveLength(0); + }); }); diff --git a/src/lib/shared/frame-tween.ts b/src/lib/shared/frame-tween.ts index 2c16469..ba07549 100644 --- a/src/lib/shared/frame-tween.ts +++ b/src/lib/shared/frame-tween.ts @@ -57,6 +57,7 @@ export const frameTween = ({ const progress = duration > 0 ? Math.min(elapsed / duration, 1) : 1; onFrame(progress); + if (done) return; // onFrame cancelled the tween if (progress >= 1) finish(true); else frame = requestAnimationFrame(tick); }; From e1547c87c38fb2eee31e72d1ee0ead8c653d4100 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:03:31 -0400 Subject: [PATCH 04/23] fix(animate): make controller teardown idempotent Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/animate/core/animate.svelte.test.ts | 14 ++++++++++++++ src/lib/animate/core/controller.ts | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 98b884c..0af13f8 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -6,3 +6,4 @@ Bug fixes from a performance / DX review: - `parsePath` / `morph()` no longer hang on malformed path data (e.g. `M0 0 Z 1`, `M0 0 L?`); parsing stops at the first error, as SVG renderers do. - `onUpdate` (animate), `frameTween` frames and `createSpringValue` listeners can now cancel/stop their animation from inside the callback; previously the rAF loop re-armed itself and ran forever. +- Cancelling an `animate()` controller more than once, or after it finished, no longer releases a newer animation's transform ownership (which dropped its layer hint and confused FLIP measurement). diff --git a/src/lib/animate/core/animate.svelte.test.ts b/src/lib/animate/core/animate.svelte.test.ts index aaae065..85aefc3 100644 --- a/src/lib/animate/core/animate.svelte.test.ts +++ b/src/lib/animate/core/animate.svelte.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { animate } from './animate'; +import { hasActiveTransforms } from '../properties/transform-tracker'; let el: HTMLElement | null = null; @@ -87,3 +88,16 @@ describe('animate() onUpdate', () => { expect(calls).toBe(1); }); }); + +describe('animate() teardown', () => { + it('releases transform ownership once, however often it is cancelled', () => { + const node = mount(); + const first = animate(node, { x: [0, 100] }, { duration: 1000 }); + const second = animate(node, { x: [0, 200] }, { duration: 1000 }); + first.cancel(); + first.cancel(); + expect(hasActiveTransforms(node)).toBe(true); + second.cancel(); + expect(hasActiveTransforms(node)).toBe(false); + }); +}); diff --git a/src/lib/animate/core/controller.ts b/src/lib/animate/core/controller.ts index daa6243..6f99636 100644 --- a/src/lib/animate/core/controller.ts +++ b/src/lib/animate/core/controller.ts @@ -165,6 +165,11 @@ export const createController = ({ const teardown = (): void => { stopRaf(); cancelAnimations(); + // Shares finalize()'s guard: onTeardown releases per-element bookkeeping + // (transform ownership counts), so running it twice — cancel after finish, + // or cancel twice — would release a newer animation's share. + if (finalized) return; + finalized = true; onTeardown?.(); }; From da892c524d09f6418b706dfa0f055a9bdd5bfeca Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:03:51 -0400 Subject: [PATCH 05/23] fix(flip): evict expired shared-layout records on write Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/flip/bridge.test.ts | 12 ++++++++++++ src/lib/flip/bridge.ts | 10 +++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 0af13f8..421b9c0 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -7,3 +7,4 @@ Bug fixes from a performance / DX review: - `parsePath` / `morph()` no longer hang on malformed path data (e.g. `M0 0 Z 1`, `M0 0 L?`); parsing stops at the first error, as SVG renderers do. - `onUpdate` (animate), `frameTween` frames and `createSpringValue` listeners can now cancel/stop their animation from inside the callback; previously the rAF loop re-armed itself and ran forever. - Cancelling an `animate()` controller more than once, or after it finished, no longer releases a newer animation's transform ownership (which dropped its layer hint and confused FLIP measurement). +- Shared-layout records (`createFlipScope`) for ids that never return are now evicted on the next write, so long-lived scopes over feeds or virtual lists no longer grow without bound. diff --git a/src/lib/flip/bridge.test.ts b/src/lib/flip/bridge.test.ts index a8debd0..5a1ecf2 100644 --- a/src/lib/flip/bridge.test.ts +++ b/src/lib/flip/bridge.test.ts @@ -65,4 +65,16 @@ describe('createLayoutBridge()', () => { a.writeLayout('shared', rect(1, 2, 3, 4)); expect(b.readLayout('shared')).toBeNull(); }); + + it('evicts expired records on write, even if their id is never read again', () => { + vi.useFakeTimers(); + const del = vi.spyOn(Map.prototype, 'delete'); + const bridge = createLayoutBridge(); + bridge.writeLayout('gone', rect()); + vi.advanceTimersByTime(300); + bridge.writeLayout('fresh', rect()); + expect(del).toHaveBeenCalledWith('gone'); + del.mockRestore(); + vi.useRealTimers(); + }); }); diff --git a/src/lib/flip/bridge.ts b/src/lib/flip/bridge.ts index 598817a..f63d6ea 100644 --- a/src/lib/flip/bridge.ts +++ b/src/lib/flip/bridge.ts @@ -28,7 +28,15 @@ export const createLayoutBridge = (): LayoutBridge => { return { writeLayout: (id, rect) => { - registry.set(id, { rect, timestamp: performance.now() }); + const now = performance.now(); + // Re-insert so the Map stays ordered oldest-first, then drop the expired + // head — ids that never come back (feeds, virtual lists) can't pile up. + registry.delete(id); + for (const [key, entry] of registry) { + if (now - entry.timestamp <= LAYOUT_TTL_MS) break; + registry.delete(key); + } + registry.set(id, { rect, timestamp: now }); }, readLayout: (id) => { const entry = registry.get(id); From 843f6e527aa68b4db912d4e1ab60feb743e00708 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:04:13 -0400 Subject: [PATCH 06/23] fix(gestures): release wheelable's layer hint on cleanup Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/gestures/wheel.svelte.test.ts | 8 ++++++++ src/lib/gestures/wheel.ts | 1 + 3 files changed, 10 insertions(+) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 421b9c0..822af76 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -8,3 +8,4 @@ Bug fixes from a performance / DX review: - `onUpdate` (animate), `frameTween` frames and `createSpringValue` listeners can now cancel/stop their animation from inside the callback; previously the rAF loop re-armed itself and ran forever. - Cancelling an `animate()` controller more than once, or after it finished, no longer releases a newer animation's transform ownership (which dropped its layer hint and confused FLIP measurement). - Shared-layout records (`createFlipScope`) for ids that never return are now evicted on the next write, so long-lived scopes over feeds or virtual lists no longer grow without bound. +- `wheelable()` now releases its `will-change` layer hint when the attachment is removed mid-gesture. diff --git a/src/lib/gestures/wheel.svelte.test.ts b/src/lib/gestures/wheel.svelte.test.ts index aa56bd5..4ef50b7 100644 --- a/src/lib/gestures/wheel.svelte.test.ts +++ b/src/lib/gestures/wheel.svelte.test.ts @@ -103,4 +103,12 @@ describe('wheelable()', () => { await expect.poll(() => el.style.getPropertyValue('will-change'), { timeout: 3000 }).toBe(''); cleanup?.(); }); + + it('releases the compositor layer when removed mid-gesture', () => { + const el = mount(); + const cleanup = wheelable({ speed: 0.01, endDelay: 1000 })(el); + el.dispatchEvent(wheel(-100)); + cleanup?.(); + expect(el.style.getPropertyValue('will-change')).toBe(''); + }); }); diff --git a/src/lib/gestures/wheel.ts b/src/lib/gestures/wheel.ts index b4a2ded..5b4c1c5 100644 --- a/src/lib/gestures/wheel.ts +++ b/src/lib/gestures/wheel.ts @@ -167,6 +167,7 @@ export const wheelable = (options: WheelableOptions = {}): Attachment Date: Fri, 16 Oct 2026 18:04:57 -0400 Subject: [PATCH 07/23] fix(timeline): size entries by all iterations Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/animate/timeline/timeline-internals.test.ts | 8 ++++++++ src/lib/animate/timeline/timeline-internals.ts | 8 ++++++-- src/lib/animate/timeline/timeline.ts | 2 ++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 822af76..d331bdc 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -9,3 +9,4 @@ Bug fixes from a performance / DX review: - Cancelling an `animate()` controller more than once, or after it finished, no longer releases a newer animation's transform ownership (which dropped its layer hint and confused FLIP measurement). - Shared-layout records (`createFlipScope`) for ids that never return are now evicted on the next write, so long-lived scopes over feeds or virtual lists no longer grow without bound. - `wheelable()` now releases its `will-change` layer hint when the attachment is removed mid-gesture. +- Timeline positioning now counts `iterations`: an entry with `iterations: 3` occupies three cycles before the next appended entry starts. Entries positioned after an `iterations: Infinity` entry are never reached and are skipped. diff --git a/src/lib/animate/timeline/timeline-internals.test.ts b/src/lib/animate/timeline/timeline-internals.test.ts index 4e78fa7..6c3672d 100644 --- a/src/lib/animate/timeline/timeline-internals.test.ts +++ b/src/lib/animate/timeline/timeline-internals.test.ts @@ -24,6 +24,14 @@ describe('computeAnimateDuration()', () => { expect(computeAnimateDuration(el, props, { duration: 200, delay: 50 })).toBe(250); }); + it('spans every iteration', () => { + const props: AnimateProps = { x: [0, 10] }; + expect(computeAnimateDuration(el, props, { duration: 100, iterations: 3 })).toBe(300); + expect(computeAnimateDuration(el, props, { duration: 100, iterations: Infinity })).toBe( + Infinity + ); + }); + it('returns 0 for empty props', () => { expect(computeAnimateDuration(el, {}, {})).toBe(0); }); diff --git a/src/lib/animate/timeline/timeline-internals.ts b/src/lib/animate/timeline/timeline-internals.ts index cfb8dfb..547a36a 100644 --- a/src/lib/animate/timeline/timeline-internals.ts +++ b/src/lib/animate/timeline/timeline-internals.ts @@ -51,7 +51,7 @@ export type Entry = AnimateEntry | SetEntry | CallEntry; /** * Compute the total time an `animate()` call will occupy — the largest - * `(delay + duration)` across its props. Spring durations are auto-sized + * `(delay + duration × iterations)` across its props. Spring durations are auto-sized * via the same `resolveTiming()` the runtime uses, so the value is exact. */ export const computeAnimateDuration = ( @@ -59,11 +59,15 @@ export const computeAnimateDuration = ( props: AnimateProps, defaults: AnimateDefaults ): number => { + // Every prop shares the effect-level iteration count; `Infinity` propagates, + // so a looping entry pushes later relative positions out to infinity too. + const iterations = defaults.iterations ?? 1; let max = 0; for (const key of Object.keys(props)) { const config = normalizeInput(props[key]!, defaults); const t = resolveTiming(config, element); - max = Math.max(max, t.duration + t.delay); + // `duration > 0` guard: 0 × Infinity would poison the max with NaN. + max = Math.max(max, t.delay + (t.duration > 0 ? t.duration * iterations : 0)); } return max; }; diff --git a/src/lib/animate/timeline/timeline.ts b/src/lib/animate/timeline/timeline.ts index 5a7263a..27a0100 100644 --- a/src/lib/animate/timeline/timeline.ts +++ b/src/lib/animate/timeline/timeline.ts @@ -174,6 +174,8 @@ export const timeline = (defaults: TimelineDefaults = {}): Timeline => { if (!isBrowser()) return; for (const entry of entries) { + // Positioned after an infinitely looping entry: it would never be reached. + if (!Number.isFinite(entry.start)) continue; if (entry.kind === 'animate') { controllers.push( animate( From 4d03968903c5c211e6729b24ab09ea21c02b51e8 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:06:17 -0400 Subject: [PATCH 08/23] fix(scroll): gate inView on intersectionRatio, not any overlap Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/scroll/in-view.svelte.test.ts | 33 +++++++++++++++++++++++++++ src/lib/scroll/in-view.ts | 11 ++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index d331bdc..a0d77a9 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -10,3 +10,4 @@ Bug fixes from a performance / DX review: - Shared-layout records (`createFlipScope`) for ids that never return are now evicted on the next write, so long-lived scopes over feeds or virtual lists no longer grow without bound. - `wheelable()` now releases its `will-change` layer hint when the attachment is removed mid-gesture. - Timeline positioning now counts `iterations`: an entry with `iterations: 3` occupies three cycles before the next appended entry starts. Entries positioned after an `iterations: Infinity` entry are never reached and are skipped. +- `inView({ amount })` now enforces the visibility ratio in every engine. Spec-conformant browsers (Firefox) report `isIntersecting` at any overlap, so `onEnter` used to fire, and `onLeave` was skipped, below the requested amount. diff --git a/src/lib/scroll/in-view.svelte.test.ts b/src/lib/scroll/in-view.svelte.test.ts index 96e9f02..b5d42f9 100644 --- a/src/lib/scroll/in-view.svelte.test.ts +++ b/src/lib/scroll/in-view.svelte.test.ts @@ -37,4 +37,37 @@ describe('inView()', () => { await vi.waitFor(() => expect(onEnter).toHaveBeenCalledOnce()); cleanup?.(); }); + + it('gates on the requested amount, not on any overlap', () => { + // Spec-conformant engines (Firefox) report `isIntersecting: true` at any + // overlap, even below the threshold; Chromium doesn't. Feed spec entries. + let report!: (ratio: number) => void; + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(cb: IntersectionObserverCallback) { + report = (ratio) => + cb( + [ + { isIntersecting: ratio > 0, intersectionRatio: ratio } as IntersectionObserverEntry + ], + this as unknown as IntersectionObserver + ); + } + observe() {} + disconnect() {} + } + ); + const onEnter = vi.fn(); + const onLeave = vi.fn(); + const cleanup = inView({ amount: 0.5, onEnter, onLeave })(mount()); + report(0.2); + expect(onEnter).not.toHaveBeenCalled(); + report(0.6); + expect(onEnter).toHaveBeenCalledOnce(); + report(0.3); + expect(onLeave).toHaveBeenCalledOnce(); + cleanup?.(); + vi.unstubAllGlobals(); + }); }); diff --git a/src/lib/scroll/in-view.ts b/src/lib/scroll/in-view.ts index d9ee761..c004aef 100644 --- a/src/lib/scroll/in-view.ts +++ b/src/lib/scroll/in-view.ts @@ -29,7 +29,8 @@ export interface InViewOptions { once?: boolean; /** * Visibility ratio `0…1` that counts as "in view", forwarded to the - * observer's `threshold`. Default 0 (any pixel visible). + * observer's `threshold`. With an array, the smallest value is the enter / + * leave line. Default 0 (any pixel visible). */ amount?: number | number[]; /** Margin around the root box (CSS `rootMargin`), e.g. `"-10% 0px"`. */ @@ -49,12 +50,16 @@ export const inView = (options: InViewOptions = {}): Attachment = return; } + // `isIntersecting` is true at any overlap, so gate on the ratio too — with + // an array, crossing its smallest threshold is what counts as "in view". + const minAmount = Math.min(...[amount].flat()); let inside = false; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { - if (entry.isIntersecting === inside) continue; - inside = entry.isIntersecting; + const visible = entry.isIntersecting && entry.intersectionRatio >= minAmount; + if (visible === inside) continue; + inside = visible; if (inside) { onEnter?.(element); if (once) observer.disconnect(); From 9f91c339ad43ee11d2a7af2da28be96617df2c47 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:06:29 -0400 Subject: [PATCH 09/23] fix: require svelte 5.29 for attachments; correct README shorthands Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 2 ++ README.md | 7 +++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index a0d77a9..c88f69b 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -11,3 +11,5 @@ Bug fixes from a performance / DX review: - `wheelable()` now releases its `will-change` layer hint when the attachment is removed mid-gesture. - Timeline positioning now counts `iterations`: an entry with `iterations: 3` occupies three cycles before the next appended entry starts. Entries positioned after an `iterations: Infinity` entry are never reached and are skipped. - `inView({ amount })` now enforces the visibility ratio in every engine. Spec-conformant browsers (Firefox) report `isIntersecting` at any overlap, so `onEnter` used to fire, and `onLeave` was skipped, below the requested amount. +- The `svelte` peer range is now `^5.29.0`, the first release with attachments (`{@attach}`, `svelte/attachments`), which the public API depends on. +- README: removed the `skewX` / `skewY` shorthands, which were never implemented (there is no individual CSS `skew` property to compose through), and listed the missing `z`. diff --git a/README.md b/README.md index 969b6cf..edd3c67 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Spring-powered WAAPI animation utilities and FLIP layout transitions for Svelte npm install @ixirjs/pulse ``` -Svelte 5 is a required peer dependency: +Svelte 5.29+ (for `{@attach}`) is a required peer dependency: ```sh -npm install svelte@^5 +npm install svelte@^5.29 ``` ## `animate(element, props, defaults?)` @@ -84,12 +84,11 @@ The following shorthands animate independent transform components without clobbe | -------- | ----------------------- | | `x` | `--motion-x` (px) | | `y` | `--motion-y` (px) | +| `z` | `--motion-z` (px) | | `scale` | `--motion-scale` | | `scaleX` | `--motion-scale-x` | | `scaleY` | `--motion-scale-y` | | `rotate` | `--motion-rotate` (deg) | -| `skewX` | `--motion-skew-x` (deg) | -| `skewY` | `--motion-skew-y` (deg) | ## `flip(options?)` — Svelte 5 attachment diff --git a/package.json b/package.json index a34ac26..7d99418 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ } }, "peerDependencies": { - "svelte": "^5.0.0" + "svelte": "^5.29.0" }, "devDependencies": { "@changesets/cli": "^2.31.0", From f72e69268e21f74fa4ba2411fc0f650fb83da430 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:07:23 -0400 Subject: [PATCH 10/23] perf(easing): mark preset factories pure so unused easings tree-shake Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + .size-limit.json | 6 ++++++ src/lib/easing/css.ts | 20 ++++++++++++++++---- src/lib/easing/primitive.ts | 36 ++++++++++++++++++------------------ 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index c88f69b..1cc7d92 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -13,3 +13,4 @@ Bug fixes from a performance / DX review: - `inView({ amount })` now enforces the visibility ratio in every engine. Spec-conformant browsers (Firefox) report `isIntersecting` at any overlap, so `onEnter` used to fire, and `onLeave` was skipped, below the requested amount. - The `svelte` peer range is now `^5.29.0`, the first release with attachments (`{@attach}`, `svelte/attachments`), which the public API depends on. - README: removed the `skewX` / `skewY` shorthands, which were never implemented (there is no individual CSS `skew` property to compose through), and listed the missing `z`. +- Easing presets are annotated `/* @__PURE__ */`, so importing a single easing no longer pulls in every preset (`{ easeOut }`: 614 B → 353 B brotli). diff --git a/.size-limit.json b/.size-limit.json index 5fb81b2..de827ec 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -13,5 +13,11 @@ "name": "flip only", "path": "./dist/flip/index.js", "limit": "9 kB" + }, + { + "name": "single easing (named import)", + "path": "./dist/index.js", + "import": "{ easeOut }", + "limit": "500 B" } ] diff --git a/src/lib/easing/css.ts b/src/lib/easing/css.ts index 2852e69..bc4a096 100644 --- a/src/lib/easing/css.ts +++ b/src/lib/easing/css.ts @@ -15,13 +15,25 @@ export const cssKeywordOf = (fn: EasingFn): string | undefined => (fn as { _cssKeyword?: string })._cssKeyword; /** Equivalent to CSS `ease`. */ -export const ease: EasingFn = keyword('ease', cubicBezier(0.25, 0.1, 0.25, 1)); +export const ease: EasingFn = /* @__PURE__ */ keyword( + 'ease', + /* @__PURE__ */ cubicBezier(0.25, 0.1, 0.25, 1) +); /** Equivalent to CSS `ease-in`. */ -export const easeIn: EasingFn = keyword('ease-in', cubicBezier(0.42, 0, 1, 1)); +export const easeIn: EasingFn = /* @__PURE__ */ keyword( + 'ease-in', + /* @__PURE__ */ cubicBezier(0.42, 0, 1, 1) +); /** Equivalent to CSS `ease-out`. */ -export const easeOut: EasingFn = keyword('ease-out', cubicBezier(0, 0, 0.58, 1)); +export const easeOut: EasingFn = /* @__PURE__ */ keyword( + 'ease-out', + /* @__PURE__ */ cubicBezier(0, 0, 0.58, 1) +); /** Equivalent to CSS `ease-in-out`. */ -export const easeInOut: EasingFn = keyword('ease-in-out', cubicBezier(0.42, 0, 0.58, 1)); +export const easeInOut: EasingFn = /* @__PURE__ */ keyword( + 'ease-in-out', + /* @__PURE__ */ cubicBezier(0.42, 0, 0.58, 1) +); /** * Map the CSS easing keyword strings (`"ease"`, `"ease-in-out"`, …) to their diff --git a/src/lib/easing/primitive.ts b/src/lib/easing/primitive.ts index f22e8eb..33bafd3 100644 --- a/src/lib/easing/primitive.ts +++ b/src/lib/easing/primitive.ts @@ -25,25 +25,25 @@ const withEndpoints = (t) => t === 0 ? 0 : t === 1 ? 1 : fn(t); -export const quadIn: EasingFn = pow(2); -export const quadOut: EasingFn = powOut(2); -export const quadInOut: EasingFn = powInOut(2); +export const quadIn: EasingFn = /* @__PURE__ */ pow(2); +export const quadOut: EasingFn = /* @__PURE__ */ powOut(2); +export const quadInOut: EasingFn = /* @__PURE__ */ powInOut(2); -export const cubicIn: EasingFn = pow(3); -export const cubicOut: EasingFn = powOut(3); -export const cubicInOut: EasingFn = powInOut(3); +export const cubicIn: EasingFn = /* @__PURE__ */ pow(3); +export const cubicOut: EasingFn = /* @__PURE__ */ powOut(3); +export const cubicInOut: EasingFn = /* @__PURE__ */ powInOut(3); -export const quartIn: EasingFn = pow(4); -export const quartOut: EasingFn = powOut(4); -export const quartInOut: EasingFn = powInOut(4); +export const quartIn: EasingFn = /* @__PURE__ */ pow(4); +export const quartOut: EasingFn = /* @__PURE__ */ powOut(4); +export const quartInOut: EasingFn = /* @__PURE__ */ powInOut(4); -export const quintIn: EasingFn = pow(5); -export const quintOut: EasingFn = powOut(5); -export const quintInOut: EasingFn = powInOut(5); +export const quintIn: EasingFn = /* @__PURE__ */ pow(5); +export const quintOut: EasingFn = /* @__PURE__ */ powOut(5); +export const quintInOut: EasingFn = /* @__PURE__ */ powInOut(5); -export const expoIn: EasingFn = withEndpoints((t) => 2 ** (10 * t - 10)); -export const expoOut: EasingFn = withEndpoints((t) => 1 - 2 ** (-10 * t)); -export const expoInOut: EasingFn = withEndpoints((t) => +export const expoIn: EasingFn = /* @__PURE__ */ withEndpoints((t) => 2 ** (10 * t - 10)); +export const expoOut: EasingFn = /* @__PURE__ */ withEndpoints((t) => 1 - 2 ** (-10 * t)); +export const expoInOut: EasingFn = /* @__PURE__ */ withEndpoints((t) => t < 0.5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (-20 * t + 10)) / 2 ); @@ -70,13 +70,13 @@ export const backInOut: EasingFn = (t) => const ELASTIC_C4 = (2 * Math.PI) / 3; const ELASTIC_C5 = (2 * Math.PI) / 4.5; -export const elasticIn: EasingFn = withEndpoints( +export const elasticIn: EasingFn = /* @__PURE__ */ withEndpoints( (t) => -(2 ** (10 * t - 10)) * Math.sin((t * 10 - 10.75) * ELASTIC_C4) ); -export const elasticOut: EasingFn = withEndpoints( +export const elasticOut: EasingFn = /* @__PURE__ */ withEndpoints( (t) => 2 ** (-10 * t) * Math.sin((t * 10 - 0.75) * ELASTIC_C4) + 1 ); -export const elasticInOut: EasingFn = withEndpoints((t) => +export const elasticInOut: EasingFn = /* @__PURE__ */ withEndpoints((t) => t < 0.5 ? -(2 ** (20 * t - 10) * Math.sin((20 * t - 11.125) * ELASTIC_C5)) / 2 : (2 ** (-20 * t + 10) * Math.sin((20 * t - 11.125) * ELASTIC_C5)) / 2 + 1 From 6fdc36d74d8f72bb82311f2ef9eabeb4c95e04f9 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:08:16 -0400 Subject: [PATCH 11/23] fix(animate): measure size keywords in the element's box-sizing Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/animate/core/animate.svelte.test.ts | 14 +++++++++++ src/lib/animate/keyframes/keyword.ts | 28 +++++++++------------ src/lib/animate/properties/properties.ts | 4 +-- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 1cc7d92..af8b22f 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -14,3 +14,4 @@ Bug fixes from a performance / DX review: - The `svelte` peer range is now `^5.29.0`, the first release with attachments (`{@attach}`, `svelte/attachments`), which the public API depends on. - README: removed the `skewX` / `skewY` shorthands, which were never implemented (there is no individual CSS `skew` property to compose through), and listed the missing `z`. - Easing presets are annotated `/* @__PURE__ */`, so importing a single easing no longer pulls in every preset (`{ easeOut }`: 614 B → 353 B brotli). +- `width` / `height` keyword endpoints (`'auto'`, `'fit-content'`, …) are now measured in the element's own `box-sizing` and ignore transforms. Before, a `content-box` element with padding or a border overshot to its border-box size, then snapped back when the keyword was restored. diff --git a/src/lib/animate/core/animate.svelte.test.ts b/src/lib/animate/core/animate.svelte.test.ts index 85aefc3..81ffc18 100644 --- a/src/lib/animate/core/animate.svelte.test.ts +++ b/src/lib/animate/core/animate.svelte.test.ts @@ -101,3 +101,17 @@ describe('animate() teardown', () => { expect(hasActiveTransforms(node)).toBe(false); }); }); + +describe('animate() size keywords', () => { + it("resolves 'auto' in the element's box-sizing, not its border box", () => { + const parent = mount(); + parent.style.width = '200px'; + const child = document.createElement('div'); + child.style.cssText = 'box-sizing:content-box;padding:10px;border:2px solid;'; + parent.appendChild(child); + const ctrl = animate(child, { width: [50, 'auto'] }, { duration: 1000 }); + const frames = ctrl.animations[0]!.effect!.getKeyframes(); + expect(frames.at(-1)!.width).toBe('176px'); // 200 - 2×10 padding - 2×2 border + ctrl.cancel(); + }); +}); diff --git a/src/lib/animate/keyframes/keyword.ts b/src/lib/animate/keyframes/keyword.ts index f9f948e..aaf17de 100644 --- a/src/lib/animate/keyframes/keyword.ts +++ b/src/lib/animate/keyframes/keyword.ts @@ -39,12 +39,12 @@ export const isAutoKeyword = (value: AnimatableValue | undefined): value is stri * resolved pixel value so it can be used as a concrete keyframe endpoint. * The element's prior inline value is restored before returning. * - * Measurement strategies: - * 1. `width` / `height` — `getBoundingClientRect()` always resolves to px. - * 2. Properties with `sizeDimension` (e.g. `max-width`, `min-height`) — try - * `getComputedStyle` first; fall back to the bounding rect if the browser - * preserves the keyword rather than resolving it. - * 3. All others — `getComputedStyle` only, no bounding-rect fallback. + * `getComputedStyle` resolves sizes to the used px value in the element's own + * `box-sizing`, untouched by transforms — unlike `getBoundingClientRect()`, + * which is the transformed border box. Properties with `sizeDimension` (`width`, + * `max-width`, …) fall back to the rect only when the browser preserves the + * keyword instead of resolving it (e.g. `width: auto` on an inline element). + * All others fall back to `0px`. */ export const measureKeywordValue = ( element: MotionElement, @@ -58,17 +58,13 @@ export const measureKeywordValue = ( style.setProperty(def.css, keyword); let measured: string; - if (def.css === 'width' || def.css === 'height') { - measured = `${element.getBoundingClientRect()[def.css]}px`; + const css = window.getComputedStyle(element).getPropertyValue(def.css).trim(); + if (css && css !== keyword) { + measured = css; + } else if (def.sizeDimension) { + measured = `${element.getBoundingClientRect()[def.sizeDimension]}px`; } else { - const css = window.getComputedStyle(element).getPropertyValue(def.css).trim(); - if (css && css !== keyword) { - measured = css; - } else if (def.sizeDimension) { - measured = `${element.getBoundingClientRect()[def.sizeDimension]}px`; - } else { - measured = '0px'; - } + measured = '0px'; } restoreStyleProp(style, def.css, saved); diff --git a/src/lib/animate/properties/properties.ts b/src/lib/animate/properties/properties.ts index ac5bd99..4bf325f 100644 --- a/src/lib/animate/properties/properties.ts +++ b/src/lib/animate/properties/properties.ts @@ -100,8 +100,8 @@ export const PROPERTY_REGISTRY: Readonly> = { // Common CSS shorthands with sensible default units. opacity: { css: 'opacity', unit: '', initial: '1' }, - width: { css: 'width', unit: 'px', initial: 'auto', measurable: true }, - height: { css: 'height', unit: 'px', initial: 'auto', measurable: true }, + width: { css: 'width', unit: 'px', initial: 'auto', measurable: true, sizeDimension: 'width' }, + height: { css: 'height', unit: 'px', initial: 'auto', measurable: true, sizeDimension: 'height' }, top: { css: 'top', unit: 'px', initial: 'auto', measurable: true }, left: { css: 'left', unit: 'px', initial: 'auto', measurable: true }, right: { css: 'right', unit: 'px', initial: 'auto', measurable: true }, From 42dbc0f0d44c2330daa5028a151f152615ee77dc Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:08:47 -0400 Subject: [PATCH 12/23] fix(scroll): measure progress from the resting box, not the driven transform Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/scroll/scroll.svelte.test.ts | 38 ++++++++++++++++++++++++++++ src/lib/scroll/scroll.ts | 6 ++++- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/lib/scroll/scroll.svelte.test.ts diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index af8b22f..ad148cb 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -15,3 +15,4 @@ Bug fixes from a performance / DX review: - README: removed the `skewX` / `skewY` shorthands, which were never implemented (there is no individual CSS `skew` property to compose through), and listed the missing `z`. - Easing presets are annotated `/* @__PURE__ */`, so importing a single easing no longer pulls in every preset (`{ easeOut }`: 614 B → 353 B brotli). - `width` / `height` keyword endpoints (`'auto'`, `'fit-content'`, …) are now measured in the element's own `box-sizing` and ignore transforms. Before, a `content-box` element with padding or a border overshot to its border-box size, then snapped back when the keyword was restored. +- `scroll()` measures progress from the element's resting layout box. Before, a driven transform (for example `y: [400, 0]`) moved the rect it measured, so progress drifted without any scrolling. diff --git a/src/lib/scroll/scroll.svelte.test.ts b/src/lib/scroll/scroll.svelte.test.ts new file mode 100644 index 0000000..7dfbac9 --- /dev/null +++ b/src/lib/scroll/scroll.svelte.test.ts @@ -0,0 +1,38 @@ +/** + * scroll() attachment in a real browser. Runs in the `client` project. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { animate } from '../animate/index'; +import { scroll } from './scroll'; + +let node: HTMLElement | null = null; + +const mount = (): HTMLElement => { + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;top:200px;left:0;width:50px;height:50px'; + document.body.appendChild(el); + node = el; + return el; +}; + +afterEach(() => { + node?.remove(); + node = null; +}); + +describe('scroll()', () => { + it('measures progress from the resting box, not the transform it drives', () => { + const el = mount(); + const plain: number[] = []; + scroll({ onProgress: (p) => plain.push(p) })(el)?.(); + + const driven: number[] = []; + const cleanup = scroll({ + animation: (e) => animate(e, { y: [400, 0] }, { duration: 1000 }), + onProgress: (p) => driven.push(p) + })(el); + expect(driven[0]).toBeCloseTo(plain[0]!, 5); + cleanup?.(); + }); +}); diff --git a/src/lib/scroll/scroll.ts b/src/lib/scroll/scroll.ts index 258c119..70a1627 100644 --- a/src/lib/scroll/scroll.ts +++ b/src/lib/scroll/scroll.ts @@ -27,6 +27,7 @@ import type { Attachment } from 'svelte/attachments'; import { isBrowser } from '../shared/browser.js'; import { createFrameBatch } from '../shared/frame-batch.js'; import { listen } from '../shared/listen.js'; +import { measureWithoutAncestorTransforms } from '../animate/properties/transform-tracker.js'; import type { AnimationController, MotionElement } from '../animate/index.js'; import { coverProgress, containProgress, pageProgress } from './progress.js'; @@ -76,7 +77,10 @@ const readState = ( container: Element | null, axis: ScrollAxis ): ScrollState => { - const rect = element.getBoundingClientRect(); + // Resting layout box: the driven animation (or an animating ancestor) moves + // the visual rect, which would feed the animation's own output back into + // its progress and make it drift without any scroll. + const rect = measureWithoutAncestorTransforms(element); if (container) { const cRect = container.getBoundingClientRect(); return axis === 'y' From bbc29e66539bb0fa2ae52ec10b9242d609428bdf Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:09:40 -0400 Subject: [PATCH 13/23] fix(flip): honor cubic-bezier() easing strings Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/flip/options.test.ts | 10 +++++++++- src/lib/flip/options.ts | 28 ++++++++++++++++++++++++---- src/lib/flip/types.ts | 5 ++++- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index ad148cb..4a13d45 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -16,3 +16,4 @@ Bug fixes from a performance / DX review: - Easing presets are annotated `/* @__PURE__ */`, so importing a single easing no longer pulls in every preset (`{ easeOut }`: 614 B → 353 B brotli). - `width` / `height` keyword endpoints (`'auto'`, `'fit-content'`, …) are now measured in the element's own `box-sizing` and ignore transforms. Before, a `content-box` element with padding or a border overshot to its border-box size, then snapped back when the keyword was restored. - `scroll()` measures progress from the element's resting layout box. Before, a driven transform (for example `y: [400, 0]`) moved the rect it measured, so progress drifted without any scrolling. +- `flip({ easing })` now accepts `cubic-bezier(…)` strings. They used to fall back to `cubicOut` silently. diff --git a/src/lib/flip/options.test.ts b/src/lib/flip/options.test.ts index c62ec3f..3b1f41a 100644 --- a/src/lib/flip/options.test.ts +++ b/src/lib/flip/options.test.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest'; -import { easeOut } from '$lib/easing'; +import { cssKeywordOf, easeIn, easeOut } from '$lib/easing'; import { DEFAULT_DELAY, readOptions, @@ -142,4 +142,12 @@ describe('resolveEasing()', () => { it('unknown CSS string falls back to DEFAULT_EASING', () => { expect(resolveEasing('my-custom-unknown-easing')).toBe(DEFAULT_EASING); }); + + it('resolves cubic-bezier() strings instead of falling back', () => { + const fn = resolveEasing('cubic-bezier(0.42, 0, 1, 1)'); + expect(fn(0.5)).toBeCloseTo(easeIn(0.5), 5); + expect(cssKeywordOf(fn)).toBe('cubic-bezier(0.42, 0, 1, 1)'); + expect(resolveEasing('cubic-bezier(0.42, 0, 1, 1)')).toBe(fn); + expect(resolveEasing('cubic-bezier(0.42, 0, 1)')).toBe(DEFAULT_EASING); + }); }); diff --git a/src/lib/flip/options.ts b/src/lib/flip/options.ts index 1ccd1ab..f9b84f9 100644 --- a/src/lib/flip/options.ts +++ b/src/lib/flip/options.ts @@ -3,7 +3,7 @@ * animator. All helpers are pure — no DOM access, no side effects. */ -import { CSS_EASINGS } from '../easing/index.js'; +import { CSS_EASINGS, cubicBezier } from '../easing/index.js'; import type { EasingFn } from '../shared/types.js'; import { DEFAULT_EASING } from '../animate/keyframes/easing-utils.js'; import { FLIP_DEFAULT_DURATION, diagonal } from './geometry.js'; @@ -47,11 +47,31 @@ export const resolveOpacity = ( * Coerce the user-supplied easing into an `EasingFn` so we can hand it * straight to the `animate()` runtime (which only accepts functions). * - * Named CSS string easings ("ease", "ease-out", …) are mapped to their - * cubic-bezier equivalents. Unknown strings fall back to {@link DEFAULT_EASING}. + * Named CSS string easings ("ease", "ease-out", …) and `cubic-bezier(…)` are + * mapped to equivalent functions. Other strings (`steps()`, `linear(…)`) fall + * back to {@link DEFAULT_EASING}. */ export const resolveEasing = (easing: FlipEasing | undefined): EasingFn => { if (easing == null) return DEFAULT_EASING; if (typeof easing === 'function') return easing; - return CSS_EASINGS[easing] ?? DEFAULT_EASING; + return CSS_EASINGS[easing] ?? parseCubicBezier(easing) ?? DEFAULT_EASING; +}; + +const CUBIC_BEZIER_RE = /^cubic-bezier\(([^)]*)\)$/; +// Memoized so repeated reflows reuse one function (and its cached CSS form). +const parsedCurves = new Map(); + +const parseCubicBezier = (easing: string): EasingFn | undefined => { + const cached = parsedCurves.get(easing); + if (cached) return cached; + const match = CUBIC_BEZIER_RE.exec(easing.trim()); + const p = match?.[1]!.split(',').map(Number); + if (p?.length !== 4 || !p.every(Number.isFinite)) return undefined; + // Tag with the original curve, as `easing/css.ts` does for its keywords, so + // WAAPI gets the exact curve instead of a resampled `linear(…)`. + const fn = Object.assign(cubicBezier(p[0]!, p[1]!, p[2]!, p[3]!), { + _cssKeyword: `cubic-bezier(${p.join(', ')})` + }); + parsedCurves.set(easing, fn); + return fn; }; diff --git a/src/lib/flip/types.ts b/src/lib/flip/types.ts index e79e859..34f1dc4 100644 --- a/src/lib/flip/types.ts +++ b/src/lib/flip/types.ts @@ -32,7 +32,10 @@ export interface FlipOpacity { export interface FlipOptions { /** Duration in ms, or a function of diagonal distance. */ duration?: FlipDuration; - /** Easing function or CSS easing string. Default: `cubicOut`. */ + /** + * Easing function, CSS keyword (`ease-out`, …) or `cubic-bezier(…)` string. + * Other CSS strings (`steps()`, `linear(…)`) fall back to the default. Default: `cubicOut`. + */ easing?: FlipEasing; /** Animation delay in ms. */ delay?: number; From ad4d23ac58ef887fba997a7e86ec1a06db1d0166 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:10:50 -0400 Subject: [PATCH 14/23] fix(animate): settle on the actual terminal state, honoring direction and fill Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/animate/core/animate.svelte.test.ts | 21 +++++++++++++ src/lib/animate/core/controller.ts | 34 +++++++++++++++------ 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 4a13d45..844c76e 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -17,3 +17,4 @@ Bug fixes from a performance / DX review: - `width` / `height` keyword endpoints (`'auto'`, `'fit-content'`, …) are now measured in the element's own `box-sizing` and ignore transforms. Before, a `content-box` element with padding or a border overshot to its border-box size, then snapped back when the keyword was restored. - `scroll()` measures progress from the element's resting layout box. Before, a driven transform (for example `y: [400, 0]`) moved the rect it measured, so progress drifted without any scrolling. - `flip({ easing })` now accepts `cubic-bezier(…)` strings. They used to fall back to `cubicOut` silently. +- `animate()` now settles where the animation actually ended. `direction: 'reverse'`, an even number of `alternate` iterations, reverse playback and fractional iterations no longer jump to the last keyframe on finish, and `fill: 'none'` / `'backwards'` no longer leave the end state written inline. diff --git a/src/lib/animate/core/animate.svelte.test.ts b/src/lib/animate/core/animate.svelte.test.ts index 81ffc18..f28c7fa 100644 --- a/src/lib/animate/core/animate.svelte.test.ts +++ b/src/lib/animate/core/animate.svelte.test.ts @@ -115,3 +115,24 @@ describe('animate() size keywords', () => { ctrl.cancel(); }); }); + +describe('animate() terminal state', () => { + it('settles where a reversed run ends, not on the last keyframe', async () => { + const node = mount(); + await animate(node, { opacity: [0, 1] }, { duration: 60, direction: 'reverse' }).finished; + expect(node.style.opacity).toBe('0'); + }); + + it('settles at the start after an even number of alternating iterations', async () => { + const node = mount(); + await animate(node, { x: [0, 100] }, { duration: 40, iterations: 2, direction: 'alternate' }) + .finished; + expect(getComputedStyle(node).getPropertyValue('--motion-x').trim()).toBe('0px'); + }); + + it("leaves no end state behind with fill: 'none'", async () => { + const node = mount(); + await animate(node, { opacity: [0, 0.5] }, { duration: 60, fill: 'none' }).finished; + expect(node.style.opacity).toBe(''); + }); +}); diff --git a/src/lib/animate/core/controller.ts b/src/lib/animate/core/controller.ts index 6f99636..c11963e 100644 --- a/src/lib/animate/core/controller.ts +++ b/src/lib/animate/core/controller.ts @@ -120,15 +120,31 @@ export const createController = ({ }; let finalized = false; - const finalize = (): void => { + /** + * Settle on where the animation actually ended. `terminalProgress` is the + * primary effect's progress at its end: `1` for a normal run, `0` (or a + * fraction) after `reverse`, an even `alternate` count, a negative rate or + * fractional iterations, and `null` when the fill doesn't hold the end state. + */ + const finalize = (terminalProgress: number | null): void => { if (finalized) return; finalized = true; stopRaf(); - // We don't call commitStyles() because the applyFinalStyles() call below - // writes the same end-state we'd commit (and avoids an extra forced style - // resolution per animation). - cancelAnimations(); - applyFinalStyles(element, finalStyles, restorations); + if (terminalProgress === 1) { + // The common case: the end state is the last keyframe we already + // computed, so write it directly instead of resolving styles per + // animation (which commitStyles() would do). + cancelAnimations(); + applyFinalStyles(element, finalStyles, restorations); + } else if (terminalProgress !== null && isBrowser()) { + // Ended somewhere else: freeze what is on screen, like stop(). + demoteFoldedTransforms(element); + commitComputedStyles(element, finalStyles); + cancelAnimations(); + } else { + // fill 'none' / 'backwards': back to the element's own styles. + cancelAnimations(); + } onTeardown?.(); }; @@ -144,12 +160,12 @@ export const createController = ({ // Capture the terminal timing before finalize() cancels the effects. // This is 1 for a normal one-shot animation, but correctly preserves // alternate/reverse iteration direction. - const terminalProgress = primaryAnimation?.effect?.getComputedTiming().progress ?? 1; - finalize(); + const terminalProgress = primaryAnimation?.effect?.getComputedTiming().progress ?? null; + finalize(terminalProgress); // The last rAF can run just before WAAPI reaches its terminal time. // Publish the terminal sample explicitly so onUpdate has the same // end-state guarantee as the animation controller itself. - defaults.onUpdate?.(terminalProgress, element); + defaults.onUpdate?.(terminalProgress ?? 1, element); defaults.onEnd?.(element, { finished: true }); }, (err) => { From 63e09daea39322fe35bc64f81bef8b38eaf40081 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:11:44 -0400 Subject: [PATCH 15/23] feat(presence,gradient): honor prefers-reduced-motion Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-behavior.md | 7 +++++++ src/lib/gradient/gradient.svelte.test.ts | 11 ++++++++++- src/lib/gradient/gradient.ts | 19 ++++++++++++------- src/lib/presence/transitions.svelte.test.ts | 16 ++++++++++++++-- src/lib/presence/transitions.ts | 4 ++++ 5 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 .changeset/review-behavior.md diff --git a/.changeset/review-behavior.md b/.changeset/review-behavior.md new file mode 100644 index 0000000..a14a723 --- /dev/null +++ b/.changeset/review-behavior.md @@ -0,0 +1,7 @@ +--- +'@ixirjs/pulse': minor +--- + +Behavior changes from a performance / DX review: + +- Presence transitions (`fade`, `fly`, `scale`, `size`) and `animateGradient()` now honor `prefers-reduced-motion` like the rest of the library: they skip the delay and the tween. Each gains a `respectReducedMotion` option (default `true`); pass `false` to opt out. diff --git a/src/lib/gradient/gradient.svelte.test.ts b/src/lib/gradient/gradient.svelte.test.ts index e74ed07..b5cf001 100644 --- a/src/lib/gradient/gradient.svelte.test.ts +++ b/src/lib/gradient/gradient.svelte.test.ts @@ -6,7 +6,7 @@ * Runs in the `client` project. */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { animateGradient } from './gradient'; let mounted: HTMLElement[] = []; @@ -60,4 +60,13 @@ describe('animateGradient()', () => { expect(el.style.backgroundImage).toBe(normalized(target)); ctrl.cancel(); }); + + it('snaps to target under reduced motion', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList); + const el = mount(); + const target = 'linear-gradient(90deg, #0000ff, #ff0000)'; + animateGradient(el, 'linear-gradient(90deg, #ff0000, #0000ff)', target, { duration: 200 }); + expect(el.style.backgroundImage).toBe(normalized(target)); + vi.restoreAllMocks(); + }); }); diff --git a/src/lib/gradient/gradient.ts b/src/lib/gradient/gradient.ts index 1c360c9..013916e 100644 --- a/src/lib/gradient/gradient.ts +++ b/src/lib/gradient/gradient.ts @@ -25,7 +25,7 @@ * ``` */ -import { isBrowser } from '../shared/browser.js'; +import { isBrowser, shouldReduceMotion } from '../shared/browser.js'; import { frameTween } from '../shared/frame-tween.js'; import { easeOut } from '../easing/index.js'; import type { EasingFn } from '../shared/types.js'; @@ -46,6 +46,8 @@ export interface GradientOptions { delay?: number; /** CSS property to write. Default `'background-image'`. */ property?: string; + /** Honor `prefers-reduced-motion` (snap to target). Default true. */ + respectReducedMotion?: boolean; /** Called once when the tween settles. */ onComplete?: () => void; } @@ -97,21 +99,24 @@ export const animateGradient = ( easing = easeOut, delay = 0, property = 'background-image', + respectReducedMotion, onComplete } = options; if (!isBrowser()) return { finished: Promise.resolve(), cancel: () => {} }; - const a = resolve(from); - const b = resolve(to); const write = (value: string): void => element.style.setProperty(property, value); - - // Mismatched stop counts can't be interpolated stop-for-stop: snap to target. - if (a.colors.length !== b.colors.length) { + const snap = (): GradientController => { write(to); onComplete?.(); return { finished: Promise.resolve(), cancel: () => {} }; - } + }; + + if (shouldReduceMotion(respectReducedMotion)) return snap(); + const a = resolve(from); + const b = resolve(to); + // Mismatched stop counts can't be interpolated stop-for-stop: snap to target. + if (a.colors.length !== b.colors.length) return snap(); const tween = frameTween({ duration, diff --git a/src/lib/presence/transitions.svelte.test.ts b/src/lib/presence/transitions.svelte.test.ts index 6439d69..3e230fc 100644 --- a/src/lib/presence/transitions.svelte.test.ts +++ b/src/lib/presence/transitions.svelte.test.ts @@ -3,8 +3,8 @@ * Runs in the `client` project. */ -import { afterEach, describe, expect, it } from 'vitest'; -import { fly, scale, size } from './transitions'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fade, fly, scale, size } from './transitions'; let node: HTMLElement | null = null; @@ -151,3 +151,15 @@ describe('scale() with width/height', () => { expect(at0).toContain('width: 0px'); }); }); + +describe('reduced motion', () => { + it('skips the delay and tween when the user prefers reduced motion', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList); + const node = document.createElement('div'); + expect(fade(node, { delay: 100, duration: 300 })).toMatchObject({ delay: 0, duration: 0 }); + expect(fade(node, { duration: 300, respectReducedMotion: false })).toMatchObject({ + duration: 300 + }); + vi.restoreAllMocks(); + }); +}); diff --git a/src/lib/presence/transitions.ts b/src/lib/presence/transitions.ts index 316f318..bfb86c3 100644 --- a/src/lib/presence/transitions.ts +++ b/src/lib/presence/transitions.ts @@ -18,6 +18,7 @@ import type { TransitionConfig } from 'svelte/transition'; import { easeOut, springEasing } from '../easing/index.js'; +import { shouldReduceMotion } from '../shared/browser.js'; import { restoreStyleProp, saveStyleProp } from '../shared/inline-style.js'; import type { EasingFn, SpringOptions } from '../shared/types.js'; @@ -30,6 +31,8 @@ export interface PresenceParams { easing?: EasingFn; /** Spring physics for the curve + auto-sized duration. `true` = defaults. */ spring?: SpringOptions | boolean; + /** Honor `prefers-reduced-motion` (no delay, no tween). Default true. */ + respectReducedMotion?: boolean; } const DEFAULT_DURATION = 400; @@ -53,6 +56,7 @@ const config = ( params: PresenceParams, css: (t: number, u: number) => string ): TransitionConfig => { + if (shouldReduceMotion(params.respectReducedMotion)) return { delay: 0, duration: 0, css }; const { duration, easing } = resolveTiming(params); return { delay: params.delay ?? 0, duration, easing, css }; }; From b7ac81a68a08319a55a7c2f2775d79521ebdcc12 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:12:44 -0400 Subject: [PATCH 16/23] test(animate): type the keyframe read-back as KeyframeEffect Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- src/lib/animate/core/animate.svelte.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/animate/core/animate.svelte.test.ts b/src/lib/animate/core/animate.svelte.test.ts index f28c7fa..1747d46 100644 --- a/src/lib/animate/core/animate.svelte.test.ts +++ b/src/lib/animate/core/animate.svelte.test.ts @@ -110,7 +110,7 @@ describe('animate() size keywords', () => { child.style.cssText = 'box-sizing:content-box;padding:10px;border:2px solid;'; parent.appendChild(child); const ctrl = animate(child, { width: [50, 'auto'] }, { duration: 1000 }); - const frames = ctrl.animations[0]!.effect!.getKeyframes(); + const frames = (ctrl.animations[0]!.effect as KeyframeEffect).getKeyframes(); expect(frames.at(-1)!.width).toBe('176px'); // 200 - 2×10 padding - 2×2 border ctrl.cancel(); }); From 2708c2de331c6974544c24b85ce50e3b1b940734 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:13:01 -0400 Subject: [PATCH 17/23] feat(animate): reject prop inputs without a target, naming the prop Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-behavior.md | 1 + src/lib/animate/core/animate.ts | 2 +- src/lib/animate/keyframes/keyframes.ts | 2 +- src/lib/animate/keyframes/normalize.test.ts | 8 +++++++ src/lib/animate/keyframes/normalize.ts | 23 ++++++++++++++++++- .../animate/timeline/timeline-internals.ts | 2 +- 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.changeset/review-behavior.md b/.changeset/review-behavior.md index a14a723..456717d 100644 --- a/.changeset/review-behavior.md +++ b/.changeset/review-behavior.md @@ -5,3 +5,4 @@ Behavior changes from a performance / DX review: - Presence transitions (`fade`, `fly`, `scale`, `size`) and `animateGradient()` now honor `prefers-reduced-motion` like the rest of the library: they skip the delay and the tween. Each gains a `respectReducedMotion` option (default `true`); pass `false` to opt out. +- `animate()`, `timeline().add()` and keyframe building now throw a `TypeError` naming the property when an input has no target: `[]`, `[100]`, `{ values: [] }`, or a timing-only object such as `{ duration: 200 }`. These inputs used to produce keyframes the browser silently ignored. diff --git a/src/lib/animate/core/animate.ts b/src/lib/animate/core/animate.ts index 8d36acc..ab95b4c 100644 --- a/src/lib/animate/core/animate.ts +++ b/src/lib/animate/core/animate.ts @@ -187,7 +187,7 @@ const applyEndStateImmediately = ( for (const key of Object.keys(props)) { const def = resolveProp(key); if (def.transform) needsTransform = true; - const { to } = normalizeInput(props[key]!, defaults); + const { to } = normalizeInput(props[key]!, defaults, key); // `to` mirrors the last stop of a `values` sequence, so this lands on the // correct end-state for multi-keyframe props too. if (to != null) style.setProperty(def.css, formatValue(to, def)); diff --git a/src/lib/animate/keyframes/keyframes.ts b/src/lib/animate/keyframes/keyframes.ts index bc58cc9..6990bff 100644 --- a/src/lib/animate/keyframes/keyframes.ts +++ b/src/lib/animate/keyframes/keyframes.ts @@ -135,7 +135,7 @@ export const buildKeyframes = ( transformBits |= VAR_BIT[def.css] ?? 0; } - const config = normalizeInput(props[key]!, defaults); + const config = normalizeInput(props[key]!, defaults, key); const timing = resolveTiming(config, element); // Resolve the keyframe stops: a multi-stop `values` sequence, or the diff --git a/src/lib/animate/keyframes/normalize.test.ts b/src/lib/animate/keyframes/normalize.test.ts index e818276..5dd0c9f 100644 --- a/src/lib/animate/keyframes/normalize.test.ts +++ b/src/lib/animate/keyframes/normalize.test.ts @@ -35,6 +35,14 @@ describe('normalizeInput() — shorthands', () => { expect(result.to).toBe(100); expect(result.duration).toBe(500); }); + + it('throws a TypeError naming the prop when there is no target', () => { + const bad = [[], [100], { values: [] }, { duration: 200 }, null]; + for (const input of bad) { + // @ts-expect-error — deliberately invalid input + expect(() => normalizeInput(input, {}, 'x')).toThrow(/"x"/); + } + }); }); describe('normalizeInput() — defaults inheritance', () => { diff --git a/src/lib/animate/keyframes/normalize.ts b/src/lib/animate/keyframes/normalize.ts index c7c0916..80c00de 100644 --- a/src/lib/animate/keyframes/normalize.ts +++ b/src/lib/animate/keyframes/normalize.ts @@ -65,7 +65,28 @@ const resolveDurationMs = ( return duration; }; -export const normalizeInput = (input: PropInput, defaults: AnimateDefaults): PropConfig => { +/** + * Normalize a prop input into a `PropConfig`. Throws a `TypeError` naming + * `key` when the input has no target — `[]`, `[100]`, `{ values: [] }`, or a + * timing-only object like `{ duration: 200 }` — instead of emitting keyframes + * the browser silently ignores. + */ +export const normalizeInput = ( + input: PropInput, + defaults: AnimateDefaults, + key = 'prop' +): PropConfig => { + const config = toConfig(input, defaults); + const { to } = config; + if (to == null || (typeof to !== 'number' && typeof to !== 'string')) { + throw new TypeError( + `[animate] "${key}": expected a target value, a [from, to] pair, ≥2 keyframes, or { to } / { values }` + ); + } + return config; +}; + +const toConfig = (input: PropInput, defaults: AnimateDefaults): PropConfig => { const { duration, easing, spring, delay } = defaults; if (Array.isArray(input)) { return { ...tupleToConfig(input), duration, easing, spring, delay }; diff --git a/src/lib/animate/timeline/timeline-internals.ts b/src/lib/animate/timeline/timeline-internals.ts index 547a36a..f2afb6d 100644 --- a/src/lib/animate/timeline/timeline-internals.ts +++ b/src/lib/animate/timeline/timeline-internals.ts @@ -64,7 +64,7 @@ export const computeAnimateDuration = ( const iterations = defaults.iterations ?? 1; let max = 0; for (const key of Object.keys(props)) { - const config = normalizeInput(props[key]!, defaults); + const config = normalizeInput(props[key]!, defaults, key); const t = resolveTiming(config, element); // `duration > 0` guard: 0 × Infinity would poison the max with NaN. max = Math.max(max, t.delay + (t.duration > 0 ? t.duration * iterations : 0)); From 74288828edb7cc82c3d2e6794c4d730ea1d7c279 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:13:49 -0400 Subject: [PATCH 18/23] fix(spring): sampled spring velocity is progress/s, like the live spring Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-behavior.md | 1 + README.md | 2 ++ src/lib/shared/spring-core.test.ts | 7 +++++++ src/lib/shared/spring-core.ts | 2 +- src/lib/shared/types.ts | 7 ++++++- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.changeset/review-behavior.md b/.changeset/review-behavior.md index 456717d..d743bab 100644 --- a/.changeset/review-behavior.md +++ b/.changeset/review-behavior.md @@ -6,3 +6,4 @@ Behavior changes from a performance / DX review: - Presence transitions (`fade`, `fly`, `scale`, `size`) and `animateGradient()` now honor `prefers-reduced-motion` like the rest of the library: they skip the delay and the tween. Each gains a `respectReducedMotion` option (default `true`); pass `false` to opt out. - `animate()`, `timeline().add()` and keyframe building now throw a `TypeError` naming the property when an input has no target: `[]`, `[100]`, `{ values: [] }`, or a timing-only object such as `{ duration: 200 }`. These inputs used to produce keyframes the browser silently ignored. +- `velocity` in sampled springs (`spring()`, `springEasing()`, `animate(…, { spring })`) is now in progress per second, consistent with the live spring and its own docs. It was divided by 1000 internally, so any value you passed is now 1000× stronger: divide existing values by 1000 to keep the old motion. diff --git a/README.md b/README.md index edd3c67..bbbdcf7 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ const { samples, duration } = spring({ stiffness: 200, damping: 20 }); | `restDelta` | `0.001` | | `restSpeed` | `0.001` | +`velocity` is in progress per second over the normalized 0 → 1 travel: divide a px/s release velocity by the distance. + ## `springEasing(options?)` Returns an `EasingFn` usable anywhere `easing` is accepted: diff --git a/src/lib/shared/spring-core.test.ts b/src/lib/shared/spring-core.test.ts index 4518bfb..8e610c3 100644 --- a/src/lib/shared/spring-core.test.ts +++ b/src/lib/shared/spring-core.test.ts @@ -154,6 +154,13 @@ describe('spring()', () => { expect(tight.duration).toBeGreaterThanOrEqual(loose.duration); }); + it('treats velocity as progress / second, matching the live spring', () => { + // One semi-implicit Euler step at 60fps from rest at 0 toward 1. + const dt = 1 / 60; + const v1 = 10 + (170 * 1 - 26 * 10) * dt; + expect(spring({ velocity: 10 }).samples[1]).toBeCloseTo(v1 * dt, 6); + }); + it('all samples are finite numbers', () => { const { samples } = spring({ stiffness: 300, damping: 18, velocity: 200 }); for (const s of samples) { diff --git a/src/lib/shared/spring-core.ts b/src/lib/shared/spring-core.ts index d307055..ce7a2a7 100644 --- a/src/lib/shared/spring-core.ts +++ b/src/lib/shared/spring-core.ts @@ -105,7 +105,7 @@ const simulateSpring = (options: SpringOptions): SpringSamples => { const target = 1; let pos = 0; - let vel = velocity / 1000; + let vel = velocity; // progress / s — dtSec is already in seconds const samples: number[] = [0]; let restingFrames = 0; diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts index 4320ea8..f89c288 100644 --- a/src/lib/shared/types.ts +++ b/src/lib/shared/types.ts @@ -17,7 +17,12 @@ export interface SpringOptions { damping?: number; /** Mass of the body (m). Default: 1. */ mass?: number; - /** Initial velocity in target-units / second. Default: 0. */ + /** + * Initial velocity in target-units / second. Default: 0. + * Sampled springs (`spring:` on `animate()`, `springEasing()`) run over a + * normalized 0 → 1 travel, so there it is progress / second: divide a px/s + * release velocity by the distance travelled. + */ velocity?: number; /** Position delta below which the spring is considered at rest. Default: 0.001. */ restDelta?: number; From 569a309b1f5b661ef40a7527a68858652f24a58f Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:15:31 -0400 Subject: [PATCH 19/23] feat(timeline): drive call/set entries from the timeline clock Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-behavior.md | 1 + .../animate/timeline/timeline.svelte.test.ts | 43 +++++++++++++ src/lib/animate/timeline/timeline.ts | 60 ++++++++++++------- 3 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 src/lib/animate/timeline/timeline.svelte.test.ts diff --git a/.changeset/review-behavior.md b/.changeset/review-behavior.md index d743bab..4d769d4 100644 --- a/.changeset/review-behavior.md +++ b/.changeset/review-behavior.md @@ -7,3 +7,4 @@ Behavior changes from a performance / DX review: - Presence transitions (`fade`, `fly`, `scale`, `size`) and `animateGradient()` now honor `prefers-reduced-motion` like the rest of the library: they skip the delay and the tween. Each gains a `respectReducedMotion` option (default `true`); pass `false` to opt out. - `animate()`, `timeline().add()` and keyframe building now throw a `TypeError` naming the property when an input has no target: `[]`, `[100]`, `{ values: [] }`, or a timing-only object such as `{ duration: 200 }`. These inputs used to produce keyframes the browser silently ignored. - `velocity` in sampled springs (`spring()`, `springEasing()`, `animate(…, { spring })`) is now in progress per second, consistent with the live spring and its own docs. It was divided by 1000 internally, so any value you passed is now 1000× stronger: divide existing values by 1000 to keep the old motion. +- Timeline `.call()` and `.set()` entries now follow the timeline clock instead of `setTimeout`. `pause()`, `seek()`, `setPlaybackRate()` and `cancel()` apply to them, an entry reversed before it is reached is skipped, and `finished` waits for them. diff --git a/src/lib/animate/timeline/timeline.svelte.test.ts b/src/lib/animate/timeline/timeline.svelte.test.ts new file mode 100644 index 0000000..049a231 --- /dev/null +++ b/src/lib/animate/timeline/timeline.svelte.test.ts @@ -0,0 +1,43 @@ +/** + * timeline() playback in a real browser: call/set entries follow the + * timeline clock. Runs in the `client` project. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { timeline } from './timeline'; + +const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +describe('timeline() call/set entries', () => { + it('hold while paused and fire on resume', async () => { + const fn = vi.fn(); + const tl = timeline().call(fn, 40); + tl.play(); + tl.pause(); + await wait(100); + expect(fn).not.toHaveBeenCalled(); + tl.play(); + await vi.waitFor(() => expect(fn).toHaveBeenCalledOnce()); + }); + + it('are included in finished', async () => { + const fn = vi.fn(); + await timeline().call(fn, 40).finished; + expect(fn).toHaveBeenCalledOnce(); + }); + + it('are skipped when reversed before being reached', async () => { + const fn = vi.fn(); + const tl = timeline({ paused: true }).call(fn, 100).play(); + tl.reverse(); + await tl.finished; + await wait(150); + expect(fn).not.toHaveBeenCalled(); + }); + + it('do not leak into animations', () => { + const tl = timeline({ paused: true }).call(() => {}, 40); + expect(tl.animations).toHaveLength(0); + tl.cancel(); + }); +}); diff --git a/src/lib/animate/timeline/timeline.ts b/src/lib/animate/timeline/timeline.ts index 27a0100..35daf76 100644 --- a/src/lib/animate/timeline/timeline.ts +++ b/src/lib/animate/timeline/timeline.ts @@ -34,6 +34,7 @@ */ import { animate } from '../core/animate.js'; +import { createController } from '../core/controller.js'; import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types.js'; import { type Anchor, type TimelinePosition, resolvePosition } from './timeline-position.js'; import { @@ -42,7 +43,7 @@ import { computeAnimateDuration, offsetProps } from './timeline-internals.js'; -import { isBrowser } from '../../shared/browser.js'; +import { isBrowser, shouldReduceMotion } from '../../shared/browser.js'; // --------------------------------------------------------------------------- // Public types @@ -78,7 +79,11 @@ export interface Timeline { */ set(element: MotionElement, props: AnimateProps, position?: TimelinePosition): Timeline; - /** Fire a callback at the given position. */ + /** + * Fire a callback at the given position. Like `set()`, it follows the + * timeline clock: pause / seek / playback rate apply, it is skipped when + * reversed before being reached, and `finished` waits for it. + */ call(callback: () => void, position?: TimelinePosition): Timeline; /** Declare a named position for later reference. */ @@ -125,6 +130,7 @@ export interface Timeline { // --------------------------------------------------------------------------- const EMPTY_ANIMATIONS: readonly Animation[] = Object.freeze([]); +const NOOP = (): void => {}; export const timeline = (defaults: TimelineDefaults = {}): Timeline => { const { paused = false, ...animateDefaults } = defaults; @@ -135,7 +141,6 @@ export const timeline = (defaults: TimelineDefaults = {}): Timeline => { // Materialization state. let materialized = false; const controllers: AnimationController[] = []; - let timeoutHandles: ReturnType[] = []; let cachedAnimations: readonly Animation[] | undefined; let cancelled = false; @@ -161,11 +166,30 @@ export const timeline = (defaults: TimelineDefaults = {}): Timeline => { // Materialization // ------------------------------------------------------------------------- - // Fire synchronously when startMs <= 0 (matches animate()'s reduced-motion - // fast-path), otherwise schedule via setTimeout and track the handle. + // Fire synchronously at time 0, or when reduced motion collapses every + // animate() entry to its end state. Otherwise ride an empty, target-less + // "marker" animation delayed to `startMs`: it lives in `controllers`, so + // pause / seek / reverse / playback rate / cancel and `finished` all reach + // it the same way they reach the real animations. const scheduleAt = (startMs: number, fn: () => void): void => { - if (startMs <= 0) fn(); - else timeoutHandles.push(setTimeout(fn, startMs)); + if (startMs <= 0 || shouldReduceMotion(animateDefaults.respectReducedMotion)) { + fn(); + return; + } + const marker = new Animation(new KeyframeEffect(null, null, { delay: startMs })); + const ctrl = createController({ + element: document.documentElement, + animations: [marker], + defaults: {}, + finalStyles: [], + restorations: [] + }); + marker.play(); + // Played back to 0 (reversed before reaching it) or cancelled: don't fire. + ctrl.finished.then(() => { + if (ctrl.playbackRate > 0) fn(); + }, NOOP); + controllers.push(ctrl); }; const materialize = (): void => { @@ -173,17 +197,18 @@ export const timeline = (defaults: TimelineDefaults = {}): Timeline => { materialized = true; if (!isBrowser()) return; + const animations: Animation[] = []; for (const entry of entries) { // Positioned after an infinitely looping entry: it would never be reached. if (!Number.isFinite(entry.start)) continue; if (entry.kind === 'animate') { - controllers.push( - animate( - entry.element, - offsetProps(entry.props, entry.options, entry.start), - entry.options - ) + const ctrl = animate( + entry.element, + offsetProps(entry.props, entry.options, entry.start), + entry.options ); + controllers.push(ctrl); + animations.push(...ctrl.animations); } else if (entry.kind === 'set') { scheduleAt(entry.start, () => applySetEntry(entry)); } else { @@ -197,7 +222,8 @@ export const timeline = (defaults: TimelineDefaults = {}): Timeline => { } } - cachedAnimations = controllers.flatMap((c) => c.animations); + // Real animations only — the call/set markers are an implementation detail. + cachedAnimations = animations; // Apply any pre-materialization rate through the same controller setter the // live setPlaybackRate() uses, so playback-rate changes have one code path. @@ -313,15 +339,9 @@ export const timeline = (defaults: TimelineDefaults = {}): Timeline => { const reverse: Timeline['reverse'] = () => materializeThen((c) => c.reverse()); - const clearTimeouts = (): void => { - for (const h of timeoutHandles) clearTimeout(h); - timeoutHandles = []; - }; - const shutdown = (op: (c: AnimationController) => void): void => { cancelled = true; forEachCtrl(op); - clearTimeouts(); }; const cancel: Timeline['cancel'] = () => shutdown((c) => c.cancel()); From 81ff12eabd10e7a596a033740511b5814702edb6 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:16:27 -0400 Subject: [PATCH 20/23] perf(flip,scroll): read-then-write phases in the shared frame batch Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + src/lib/flip/attachment.svelte.ts | 11 +++++++---- src/lib/scroll/scroll.ts | 13 +++++++++---- src/lib/shared/frame-batch.test.ts | 18 ++++++++++++++++++ src/lib/shared/frame-batch.ts | 23 ++++++++++++++++++----- 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index 844c76e..b226876 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -18,3 +18,4 @@ Bug fixes from a performance / DX review: - `scroll()` measures progress from the element's resting layout box. Before, a driven transform (for example `y: [400, 0]`) moved the rect it measured, so progress drifted without any scrolling. - `flip({ easing })` now accepts `cubic-bezier(…)` strings. They used to fall back to `cubicOut` silently. - `animate()` now settles where the animation actually ended. `direction: 'reverse'`, an even number of `alternate` iterations, reverse playback and fractional iterations no longer jump to the last keyframe on finish, and `fill: 'none'` / `'backwards'` no longer leave the end state written inline. +- FLIP and `scroll()` attachments that share a frame now measure everything first and animate afterwards, so N attachments cost one layout per frame instead of N forced reflows. diff --git a/src/lib/flip/attachment.svelte.ts b/src/lib/flip/attachment.svelte.ts index 03a0c1a..d9d9531 100644 --- a/src/lib/flip/attachment.svelte.ts +++ b/src/lib/flip/attachment.svelte.ts @@ -41,9 +41,11 @@ export const createFlipAttachment = ( const run = (from: FlipRect, to: FlipRect): void => slot.run({ element, from, to, options }); // ----------------------------------------------------------------- - // Reflow detection — re-measure and run if the element moved. + // Reflow detection — re-measure and run if the element moved. Split + // into a measure step that returns the animation to start, so a batched + // flush can measure every element before any of them animates. // ----------------------------------------------------------------- - const reflow = (): void => { + const measureReflow = (): (() => void) | undefined => { if (!prevRect) return; const next = measure(element); // Count only cycles that would actually animate — observers deliver a @@ -70,8 +72,9 @@ export const createFlipAttachment = ( ? carryVisualOffset(prevRect, next, measureVisual(element)) : prevRect; prevRect = next; - run(from, next); + return () => run(from, next); }; + const reflow = (): void => measureReflow()?.(); // The first scheduled pass after mount only re-baselines: layout is not // settled when the attachment runs (siblings still mounting, fonts, images @@ -84,7 +87,7 @@ export const createFlipAttachment = ( prevRect = measure(element); return; } - reflow(); + return measureReflow(); }); const observers = createObserverManager(); const attrs = createDynamicAttrs(element); diff --git a/src/lib/scroll/scroll.ts b/src/lib/scroll/scroll.ts index 70a1627..ac6fa89 100644 --- a/src/lib/scroll/scroll.ts +++ b/src/lib/scroll/scroll.ts @@ -140,14 +140,19 @@ export const scroll = (options: ScrollOptions = {}): Attachment = const duration = controller ? totalDuration(controller) : 0; const scrollSource: EventTarget = container ?? window; - const update = (): void => { + // Measure, then return the writes: in a shared flush every attachment + // reads its geometry before any of them seeks or runs onProgress. + const measureProgress = (): (() => void) => { const progress = progressFor(range, readState(element, container, axis)); - controller?.seek(progress * duration); - onProgress?.(progress, element); + return () => { + controller?.seek(progress * duration); + onProgress?.(progress, element); + }; }; + const update = (): void => measureProgress()(); // Scroll attachments remain independently owned, but their geometry work // shares one rAF when several receive the same scroll/resize burst. - const batch = createFrameBatch(update); + const batch = createFrameBatch(measureProgress); update(); const unlistenScroll = listen(scrollSource, { scroll: batch.schedule }, { passive: true }); diff --git a/src/lib/shared/frame-batch.test.ts b/src/lib/shared/frame-batch.test.ts index 7627f2b..9f5bcb9 100644 --- a/src/lib/shared/frame-batch.test.ts +++ b/src/lib/shared/frame-batch.test.ts @@ -52,4 +52,22 @@ describe('createFrameBatch()', () => { expect(second).toHaveBeenCalledOnce(); expect(cancel).not.toHaveBeenCalled(); }); + + it('runs every read before any returned write', () => { + let frame: FrameRequestCallback | undefined; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frame = callback; + return 1; + }); + const order: string[] = []; + const subscriber = (name: string) => + createFrameBatch(() => { + order.push(`read ${name}`); + return () => order.push(`write ${name}`); + }); + subscriber('a').schedule(); + subscriber('b').schedule(); + frame!(0); + expect(order).toEqual(['read a', 'read b', 'write a', 'write b']); + }); }); diff --git a/src/lib/shared/frame-batch.ts b/src/lib/shared/frame-batch.ts index ee1227c..ac51569 100644 --- a/src/lib/shared/frame-batch.ts +++ b/src/lib/shared/frame-batch.ts @@ -4,8 +4,16 @@ * A batch preserves each subscriber's callback while ensuring a burst of * schedules across attachments produces one browser frame request. Subscribers * may safely remove themselves during a flush. + * + * The flush is two-phase: every callback runs first (the read phase), and any + * function a callback returns runs after all of them (the write phase). A + * subscriber that measures then animates should return its writes, so N + * subscribers cost one layout instead of N forced reflows. */ +/** Read-phase callback; may return the writes to apply once every read is done. */ +export type FrameBatchCallback = () => void | (() => void); + export interface FrameBatch { /** Queue this subscriber once for the next animation frame. */ schedule(): void; @@ -13,18 +21,23 @@ export interface FrameBatch { cancel(): void; } -const pending = new Set<() => void>(); +const pending = new Set(); let frame: number | null = null; const flush = (): void => { frame = null; const callbacks = [...pending]; pending.clear(); - for (const callback of callbacks) callback(); + const writes: Array<() => void> = []; + for (const callback of callbacks) { + const write = callback(); + if (write) writes.push(write); + } + for (const write of writes) write(); }; /** Create a cancellable subscriber to the shared visual-update frame. */ -export const createFrameBatch = (callback: () => void): FrameBatch => { +export const createFrameBatch = (callback: FrameBatchCallback): FrameBatch => { let queued = false; const schedule = (): void => { @@ -34,9 +47,9 @@ export const createFrameBatch = (callback: () => void): FrameBatch => { if (frame == null) frame = requestAnimationFrame(flush); }; - const run = (): void => { + const run: FrameBatchCallback = () => { queued = false; - callback(); + return callback(); }; const cancel = (): void => { From 5ead19a45dae97ddb0af0d2bf93a1f388d63f2f8 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:16:56 -0400 Subject: [PATCH 21/23] style: prettier Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- src/lib/animate/core/animate.ts | 7 ++++++- src/lib/animate/timeline/timeline-internals.ts | 7 ++++++- src/lib/animate/timeline/timeline.ts | 7 ++++++- src/lib/variants/variants.svelte.ts | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/lib/animate/core/animate.ts b/src/lib/animate/core/animate.ts index ab95b4c..f4c4dba 100644 --- a/src/lib/animate/core/animate.ts +++ b/src/lib/animate/core/animate.ts @@ -37,7 +37,12 @@ import { wireTransform } from '../properties/transform-setup.js'; import { applyFolds, planFolds } from '../keyframes/fold.js'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types.js'; +import type { + AnimateDefaults, + AnimateProps, + AnimationController, + MotionElement +} from '../types.js'; import { isBrowser, shouldReduceMotion } from '../../shared/browser.js'; import { formatValue, resolveProp } from '../properties/prop-utils.js'; diff --git a/src/lib/animate/timeline/timeline-internals.ts b/src/lib/animate/timeline/timeline-internals.ts index f2afb6d..7f60c20 100644 --- a/src/lib/animate/timeline/timeline-internals.ts +++ b/src/lib/animate/timeline/timeline-internals.ts @@ -13,7 +13,12 @@ import type { MotionElement, PropInput } from '../types.js'; -import { isPropConfig, normalizeInput, resolveTiming, tupleToConfig } from '../keyframes/normalize.js'; +import { + isPropConfig, + normalizeInput, + resolveTiming, + tupleToConfig +} from '../keyframes/normalize.js'; // --------------------------------------------------------------------------- // Entry shapes diff --git a/src/lib/animate/timeline/timeline.ts b/src/lib/animate/timeline/timeline.ts index 35daf76..e098529 100644 --- a/src/lib/animate/timeline/timeline.ts +++ b/src/lib/animate/timeline/timeline.ts @@ -35,7 +35,12 @@ import { animate } from '../core/animate.js'; import { createController } from '../core/controller.js'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types.js'; +import type { + AnimateDefaults, + AnimateProps, + AnimationController, + MotionElement +} from '../types.js'; import { type Anchor, type TimelinePosition, resolvePosition } from './timeline-position.js'; import { type Entry, diff --git a/src/lib/variants/variants.svelte.ts b/src/lib/variants/variants.svelte.ts index ba45591..9ba83f3 100644 --- a/src/lib/variants/variants.svelte.ts +++ b/src/lib/variants/variants.svelte.ts @@ -32,7 +32,12 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; import { isBrowser } from '../shared/browser.js'; import { animate } from '../animate/index.js'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../animate/index.js'; +import type { + AnimateDefaults, + AnimateProps, + AnimationController, + MotionElement +} from '../animate/index.js'; /** Map of state name → target props. */ export type VariantMap = Record; From c197300f441fa10e2d9f5456511521e9dab8014e Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 18:17:17 -0400 Subject: [PATCH 22/23] chore(size): raise the root budget to 20.5 kB for the review fixes Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .size-limit.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.json b/.size-limit.json index de827ec..2d8d402 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,7 +2,7 @@ { "name": "Root public API", "path": "./dist/index.js", - "limit": "20 kB" + "limit": "20.5 kB" }, { "name": "animate only", From 7797d7aef806f56f0ffea8a1a44c848d88d090a0 Mon Sep 17 00:00:00 2001 From: ryu-man Date: Fri, 16 Oct 2026 19:07:49 -0400 Subject: [PATCH 23/23] perf(flip,scroll): suppress shared animating ancestors once per frame Claude-Session: https://claude.ai/code/session_01FLgHpc8ubYkpgfEW7y3eXK --- .changeset/review-fixes.md | 1 + .../transform-tracker.svelte.test.ts | 56 ++++++++++ .../animate/properties/transform-tracker.ts | 105 ++++++++++++------ src/lib/shared/frame-batch.ts | 16 ++- 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/.changeset/review-fixes.md b/.changeset/review-fixes.md index b226876..7915df5 100644 --- a/.changeset/review-fixes.md +++ b/.changeset/review-fixes.md @@ -19,3 +19,4 @@ Bug fixes from a performance / DX review: - `flip({ easing })` now accepts `cubic-bezier(…)` strings. They used to fall back to `cubicOut` silently. - `animate()` now settles where the animation actually ended. `direction: 'reverse'`, an even number of `alternate` iterations, reverse playback and fractional iterations no longer jump to the last keyframe on finish, and `fill: 'none'` / `'backwards'` no longer leave the end state written inline. - FLIP and `scroll()` attachments that share a frame now measure everything first and animate afterwards, so N attachments cost one layout per frame instead of N forced reflows. +- Measurements in a shared frame now suppress an animating ancestor's transform once per frame instead of once per measured child (100 FLIP children under an animating parent: 1 write/restore instead of 100). diff --git a/src/lib/animate/properties/transform-tracker.svelte.test.ts b/src/lib/animate/properties/transform-tracker.svelte.test.ts index 130798d..c161989 100644 --- a/src/lib/animate/properties/transform-tracker.svelte.test.ts +++ b/src/lib/animate/properties/transform-tracker.svelte.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { animate } from '../core/animate'; import { + batchMeasurements, deregisterTransformAnimation, measureWithoutAncestorTransforms, registerFoldedTransforms, @@ -118,3 +120,57 @@ describe('folded transforms', () => { expect(parent.style.getPropertyValue('translate')).toBe(''); }); }); + +describe('batchMeasurements()', () => { + const mountTree = (): { parent: HTMLElement; children: HTMLElement[] } => { + const parent = document.createElement('div'); + parent.style.cssText = 'position:absolute;top:0;left:0;width:300px'; + const children = [0, 1, 2].map(() => { + const c = document.createElement('div'); + c.style.height = '20px'; + return parent.appendChild(c); + }); + document.body.appendChild(parent); + mounted.push(parent); + return { parent, children }; + }; + const paused = (el: HTMLElement, x: [number, number]) => { + const ctrl = animate(el, { x }, { duration: 1000, easing: (t) => t }); + ctrl.pause(); + ctrl.seek(500); + return ctrl; + }; + + it('suppresses a shared animating ancestor once, with identical rects', () => { + const { parent, children } = mountTree(); + const ctrl = paused(parent, [0, 100]); + const before = parent.getAttribute('style'); + const expected = children.map((c) => measureWithoutAncestorTransforms(c)); + + const writes = vi.spyOn(parent.style, 'setProperty'); + measureWithoutAncestorTransforms(children[0]!); + const perMeasure = writes.mock.calls.length; + writes.mockClear(); + + const rects = batchMeasurements(() => children.map((c) => measureWithoutAncestorTransforms(c))); + expect(perMeasure).toBeGreaterThan(0); + expect(writes.mock.calls.length).toBe(perMeasure); // once, not once per child + expect(rects).toEqual(expected); + expect(parent.getAttribute('style')).toBe(before); + ctrl.cancel(); + }); + + it('lifts an already-suppressed element for a visual read', () => { + const { children } = mountTree(); + const child = children[1]!; + const ctrl = paused(child, [0, 100]); + const visual = measureWithoutAncestorTransforms(child, { suppressSelf: false }); + const [rest, live] = batchMeasurements(() => [ + measureWithoutAncestorTransforms(child), + measureWithoutAncestorTransforms(child, { suppressSelf: false }) + ]); + expect(live!.x - rest!.x).toBeCloseTo(50, 0); + expect(live).toEqual(visual); + ctrl.cancel(); + }); +}); diff --git a/src/lib/animate/properties/transform-tracker.ts b/src/lib/animate/properties/transform-tracker.ts index 4840cd7..207c93b 100644 --- a/src/lib/animate/properties/transform-tracker.ts +++ b/src/lib/animate/properties/transform-tracker.ts @@ -256,6 +256,41 @@ export const demoteFoldedTransforms = (element: Element): void => { } }; +type SavedProp = { name: string; value: string; saved: SavedStyleProp }; + +/** + * Open measurement batch: each suppressed node and the props it overrode. While + * set, suppression is shared across measurements and restored once at the end, + * so measuring N children of an animating parent writes the parent once, not N + * times. See {@link batchMeasurements}. + */ +let batch: Map | null = null; + +/** Neutralise `target`'s active motion transform; returns what to restore. */ +const suppressNode = (target: MotionElement): SavedProp[] | undefined => { + const bits = activeTransformBits.get(target); + if (!bits) return; + const props: SavedProp[] = []; + const override = (name: string, value: string): void => { + props.push({ name, value, saved: saveStyleProp(target.style, name) }); + target.style.setProperty(name, value, 'important'); + }; + forEachBit(bits, (i) => override(...MOTION_TRANSFORM_IDENTITIES[i]!)); + // A folded animation drives `translate`/`scale`/`rotate` itself, so + // forcing the vars to identity no longer reaches it. Reinstating the + // template with `!important` does: the template reads the vars we just + // neutralised, so the element measures at rest exactly as on the + // variable path. + for (const { targets } of foldedTransforms.get(target) ?? []) { + for (const [name, template] of targets) override(name, template); + } + return props.length > 0 ? props : undefined; +}; + +const restoreProps = (node: MotionElement, props: SavedProp[]): void => { + for (const { name, saved } of props) restoreStyleProp(node.style, name, saved); +}; + /** * Like `element.getBoundingClientRect()` but walks up the ancestor chain * and temporarily suppresses the motion CSS vars on every ancestor that @@ -264,7 +299,8 @@ export const demoteFoldedTransforms = (element: Element): void => { * Setting the vars with `!important` beats the WAAPI animation layer in the * CSS cascade, so the measurement reflects the element's "at rest" position * even when a parent is mid-scale/translate. All inline style changes are - * fully restored before returning — no repaint ever sees the intermediate state. + * fully restored before returning — no repaint ever sees the intermediate state + * (inside {@link batchMeasurements}, when the batch closes). * * By default the element's own in-flight transform is suppressed too, so the * rect reflects its resting layout box. Pass `suppressSelf: false` to keep the @@ -272,55 +308,62 @@ export const demoteFoldedTransforms = (element: Element): void => { * interrupting an in-flight FLIP so the next run can start where the element is * actually on-screen instead of snapping to its resting box first. */ -type SavedProp = { name: string; saved: SavedStyleProp }; -type Suppressed = { node: MotionElement; props: SavedProp[] }; - export const measureWithoutAncestorTransforms = ( el: Element, { suppressSelf = true }: { suppressSelf?: boolean } = {} ): DOMRect => { - const suppressed: Suppressed[] = []; - - const suppressNode = (target: MotionElement): void => { - const bits = activeTransformBits.get(target); - if (!bits) return; - const props: SavedProp[] = []; - forEachBit(bits, (i) => { - const [name, identity] = MOTION_TRANSFORM_IDENTITIES[i]!; - props.push({ name, saved: saveStyleProp(target.style, name) }); - target.style.setProperty(name, identity, 'important'); - }); - // A folded animation drives `translate`/`scale`/`rotate` itself, so - // forcing the vars to identity no longer reaches it. Reinstating the - // template with `!important` does: the template reads the vars we just - // neutralised, so the element measures at rest exactly as on the - // variable path. - for (const { targets } of foldedTransforms.get(target) ?? []) { - for (const [name, template] of targets) { - props.push({ name, saved: saveStyleProp(target.style, name) }); - target.style.setProperty(name, template, 'important'); - } - } - if (props.length > 0) suppressed.push({ node: target, props }); + const local: Array<[MotionElement, SavedProp[]]> = []; + const suppress = (node: MotionElement): void => { + if (batch?.has(node)) return; + const props = suppressNode(node); + if (!props) return; + if (batch) batch.set(node, props); + else local.push([node, props]); }; + // In a batch the element may already be suppressed — as an earlier + // measurement's subject or ancestor. A visual read needs its own transform + // back, so lift that suppression for the duration of this read. + const lifted = !suppressSelf && isMutableElement(el) ? batch?.get(el) : undefined; + if (lifted) restoreProps(el as MotionElement, lifted); + // Suppress the element itself first so getBoundingClientRect() reflects the // resting position even when the element is mid-animation (e.g. during the // backward-fill phase of a delayed FLIP animation). Skipped when the caller // wants the element's live visual rect (transform included). - if (suppressSelf && isMutableElement(el)) suppressNode(el); + if (suppressSelf && isMutableElement(el)) suppress(el); let node: Element | null = el.parentElement; while (node) { - if (isMutableElement(node)) suppressNode(node); + if (isMutableElement(node)) suppress(node); node = node.parentElement; } const rect = el.getBoundingClientRect(); - for (const { node, props } of suppressed) { - for (const { name, saved } of props) restoreStyleProp(node.style, name, saved); + for (const [n, props] of local) restoreProps(n, props); + if (lifted) { + for (const { name, value } of lifted) + (el as MotionElement).style.setProperty(name, value, 'important'); } return rect; }; + +/** + * Run `fn` with measurement suppression shared across every + * {@link measureWithoutAncestorTransforms} call inside it, restoring all + * overridden styles once when it returns. `fn` must only read: an animation + * started inside would see the suppressed styles. Nested calls join the outer + * batch. + */ +export const batchMeasurements = (fn: () => T): T => { + if (batch) return fn(); + const open = (batch = new Map()); + try { + return fn(); + } finally { + batch = null; + for (const [n, props] of open) restoreProps(n, props); + } +}; diff --git a/src/lib/shared/frame-batch.ts b/src/lib/shared/frame-batch.ts index ac51569..e7d6e5b 100644 --- a/src/lib/shared/frame-batch.ts +++ b/src/lib/shared/frame-batch.ts @@ -8,9 +8,13 @@ * The flush is two-phase: every callback runs first (the read phase), and any * function a callback returns runs after all of them (the write phase). A * subscriber that measures then animates should return its writes, so N - * subscribers cost one layout instead of N forced reflows. + * subscribers cost one layout instead of N forced reflows. The read phase also + * runs as one measurement batch, so an animating ancestor shared by many + * measured elements is suppressed and restored once per frame, not per element. */ +import { batchMeasurements } from '../animate/properties/transform-tracker.js'; + /** Read-phase callback; may return the writes to apply once every read is done. */ export type FrameBatchCallback = () => void | (() => void); @@ -29,10 +33,12 @@ const flush = (): void => { const callbacks = [...pending]; pending.clear(); const writes: Array<() => void> = []; - for (const callback of callbacks) { - const write = callback(); - if (write) writes.push(write); - } + batchMeasurements(() => { + for (const callback of callbacks) { + const write = callback(); + if (write) writes.push(write); + } + }); for (const write of writes) write(); };