From 131ca142ce0e251357ca615c2b40dca5794f321c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 08:23:51 -0800 Subject: [PATCH 1/4] feat(core): one rect morph for touch presentations that grow out of a source rect createRectMorph in motion/rectMorph.ts, internal: present along a path and dismiss back onto a copy or the element, each continuing the other from the rect on screen. Rebuilt on main from the pull request's own change and ported to main's engine (createMotionAnimator). Timing follows the owner's rule for touch motion: present and dismiss take a motion, either a theme duration and easing (a tap, a key, the program) or a spring (a gesture's release, from its velocity). timedProgressCurve joins the progress helpers. architecture:react-component-runtime INV12 records the implementation gap: the size-change glide moves onto the morph next. Co-authored-by: Cindy Zhang --- .changeset/motion-rect-morph.md | 7 + docs/architecture/react-component-runtime.md | 4 +- packages/core/src/motion/README.md | 1 + .../core/src/motion/__tests__/motionBundle.ts | 2 + .../rectMorph.interaction.browser.spec.ts | 444 +++++++++++ packages/core/src/motion/progress.test.ts | 20 + packages/core/src/motion/progress.ts | 31 +- packages/core/src/motion/rectMorph.test.ts | 695 ++++++++++++++++++ packages/core/src/motion/rectMorph.ts | 501 +++++++++++++ 9 files changed, 1703 insertions(+), 2 deletions(-) create mode 100644 .changeset/motion-rect-morph.md create mode 100644 packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts create mode 100644 packages/core/src/motion/rectMorph.test.ts create mode 100644 packages/core/src/motion/rectMorph.ts diff --git a/.changeset/motion-rect-morph.md b/.changeset/motion-rect-morph.md new file mode 100644 index 0000000000000..0192c46f8c2cd --- /dev/null +++ b/.changeset/motion-rect-morph.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. A motion a tap or a key starts plays a theme duration and easing; a gesture's release plays a spring from its velocity. Reduced Motion is left to the animator. `timedProgressCurve` joins the progress helpers. Not exported from the package entry point; no component uses it yet. + +@vjeux diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 56838fde56f98..37e9e4bc3fbcc 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -198,7 +198,9 @@ those contracts. change and moves shifted content with a compositor-only translate played through the shared rectangle morph (`architecture:layer-runtime` INV15; `spec:AST-094` FR5) once Core has that morph; until then, the module plays - its own compositor translate. It never animates height. Its walk visits only + its own compositor translate. (Implementation gap: Core has the shared + rectangle morph, but the size-change glide still plays its own compositor + translate; it moves onto the morph next.) It never animates height. Its walk visits only the changed element's ancestors up to the area and the element siblings on either side at each of their levels, through `parentElement`, `nextElementSibling` and `previousElementSibling`, descending only into an diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index e0c672a1b1326..89ae755675ad3 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -39,6 +39,7 @@ roles through `resolveMotion`. | `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). | | `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. | | `morphPath.ts` | The rect an element shows at each progress of a grow-out presentation: a straight line, a popover’s edge growth, and the curves that put it there. | +| `rectMorph.ts` | Grow out of a source rect and shrink back into it along a path: present and dismiss, each continuing the other from the rect on screen. | | `progress.ts` | A spring's progress, one curve mapped from another (companions, clamped ranges), the velocity that continues a motion. | | `presentation.ts` | Where each moving element is drawn relative to where the engine hit-tests it, reported by the animator while translate runs; read by the gesture host at touch-down. | | `reducedMotionPlan.ts` | The animator's Reduce Motion planner: what one call becomes, a cross-fade whose travel lands while transparent or at once. | diff --git a/packages/core/src/motion/__tests__/motionBundle.ts b/packages/core/src/motion/__tests__/motionBundle.ts index 874fb5e2a8d38..0b50a74857f91 100644 --- a/packages/core/src/motion/__tests__/motionBundle.ts +++ b/packages/core/src/motion/__tests__/motionBundle.ts @@ -9,6 +9,8 @@ */ export {createMotionAnimator} from '../animator'; +export {progressCurve} from '../progress'; +export {createRectMorph, edgeGrowPath, lerpPath} from '../rectMorph'; export {solveSpring, toSpring} from '../spring'; export {registerSpringsTable, springs} from '../uikit/springs'; export {motionRoleOf} from '../roles'; diff --git a/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts new file mode 100644 index 0000000000000..f4305542366ce --- /dev/null +++ b/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts @@ -0,0 +1,444 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rectMorph.interaction.browser.spec.ts + * @input The rect morph and the motion source bundled with esbuild onto a + * blank fixture page; the interaction harness's busy-main-thread, + * screencast and trace instruments; Chromium and WebKit on the phone + * profile + * @output Proof that the engine shows the morph's path: seeked frame by + * frame, a present and a dismissal put the element's box on screen on the + * path's rect within 0.5 px (a straight line with a fade, a popover's edge + * growth); the morph keeps moving through a 1 s main-thread block; no + * Layout or Paint runs while it animates (Chromium trace) + * @position Browser proof for rectMorph.ts, run by `pnpm test:interaction`; + * jsdom runs no animations + */ + +import {fileURLToPath} from 'node:url'; +import { + blockMainThread, + busyWindowProof, + countBetween, + markElement, + startScreencast, + traceRendering, + trackMarker, +} from '@astryxdesign/interaction-harness'; +import {expect, test, type Page} from '@playwright/test'; +import {build} from 'esbuild'; +// The page's `Motion` global (the bundle) is declared beside the animator's +// proofs, in animator.interaction.browser.spec.ts. + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [fileURLToPath(new URL('./motionBundle.ts', import.meta.url))], + bundle: true, + write: false, + format: 'iife', + globalName: 'Motion', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +/** The surface at rest, and the 22 px square on its control it grows from. */ +const BOX = {x: 40, y: 204, width: 320, height: 253}; +const SQUARE = {x: 179, y: 160, width: 22, height: 22}; +/** UIKit's popover numbers (#8187): 1 % scale, 100 px of length. */ +const POPOVER = {origin: {x: 160, y: 0}, collapsedScale: 0.01}; +const SPRING = {response: 0.45, dampingRatio: 0.8}; +/** A slow spring for the block: well over a px per frame throughout. */ +const SLOW = {response: 2, dampingRatio: 1}; +const MARKER_INSET = 8; + +// A real navigation (not setContent): the page keeps its own window. +async function open(page: Page) { + const html = ` + +
+`; + await page.route('http://motion.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://motion.test/'); +} + +type PathKind = 'line' | 'edge'; + +/** + * Plays a present (or, after it, a dismissal on a copy) of `kind`, pauses + * the engine, seeks it every 1/60 s, and returns the worst gap between the + * box on screen and the path's rect, px, and between the opacity on screen + * and the path's. + */ +async function seekedGap( + page: Page, + kind: PathKind, + phase: 'present' | 'dismiss', +) { + return page.evaluate( + async ({kind, phase, box, square, popover, spring}) => { + const surface = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(surface); + const path = + kind === 'line' + ? Motion.lerpPath(square, box, {opacity: [0, 1]}) + : Motion.edgeGrowPath({ + box, + origin: popover.origin, + axis: 'y', + collapsedScale: popover.collapsedScale, + collapsedLength: 100 / box.height, + }); + let target = surface; + let handle = morph.present(path, {spring: spring}); + if (phase === 'dismiss') { + await handle.finished; + target = surface.cloneNode(true) as HTMLElement; + target.id = 'copy'; + surface.after(target); + handle = morph.dismiss(path, {spring: spring}, {target}); + } + await handle.started; + const animations = target.getAnimations(); + const progress = Motion.progressCurve(spring); + for (const animation of animations) { + animation.pause(); + } + let worstRect = 0; + let worstOpacity = 0; + const step = 1000 / 60; + for (let t = 0; t <= progress.duration * 1000 + step; t += step) { + for (const animation of animations) { + animation.currentTime = t; + } + const p = progress.at(t / 1000); + // The dismissal runs the path backward, rest to source. + const at = phase === 'present' ? p : 1 - p; + // Past the source on an overshoot the scale crosses 0 (the copy is + // transparent there): the box on screen is the flipped rect. + const raw = path.rectAt(at); + const expected = { + x: Math.min(raw.x, raw.x + raw.width), + y: Math.min(raw.y, raw.y + raw.height), + width: Math.abs(raw.width), + height: Math.abs(raw.height), + }; + const rect = target.getBoundingClientRect(); + worstRect = Math.max( + worstRect, + Math.abs(rect.x - expected.x), + Math.abs(rect.y - expected.y), + Math.abs(rect.width - expected.width), + Math.abs(rect.height - expected.height), + ); + const opacity = Number.parseFloat(getComputedStyle(target).opacity); + const expectedOpacity = Math.min( + 1, + Math.max(0, path.opacityAt?.(at) ?? 1), + ); + worstOpacity = Math.max( + worstOpacity, + Math.abs(opacity - expectedOpacity), + ); + } + return { + animations: animations.length, + worstRect, + worstOpacity, + origin: getComputedStyle(target).transformOrigin, + }; + }, + {kind, phase, box: BOX, square: SQUARE, popover: POPOVER, spring: SPRING}, + ); +} + +for (const kind of ['line', 'edge'] as const) { + for (const phase of ['present', 'dismiss'] as const) { + test(`the engine shows the path\u2019s rect at every frame: ${kind === 'line' ? 'a straight line with a fade' : 'a popover\u2019s edge growth'}, ${phase}`, async ({ + page, + }) => { + await open(page); + const result = await seekedGap(page, kind, phase); + test.info().annotations.push({ + type: 'seeked gap', + description: JSON.stringify(result), + }); + // translate, scale and opacity, one animation each. + expect(result.animations).toBe(3); + expect(result.origin).toBe('0px 0px'); + expect(result.worstRect).toBeLessThanOrEqual(0.5); + expect(result.worstOpacity).toBeLessThanOrEqual(0.005); + }); + } +} + +// FR6 is verified in a browser too: an interrupted motion continues from the +// box on screen. Two readings: reads on either side of the switch in one +// task, and the composited frames across it. + +// One task, two reads either side of the switch: nothing is presented in +// between, so the second read is the new motion's first value. The morph +// reads the document timeline here, the clock the compositor presents on, +// so both reads see one instant whatever the frame timing; on its default +// clock the new motion's start would sit up to the time since the last frame +// ahead of the first read. +async function readAcrossSwitch(page: Page, restart: boolean) { + return page.evaluate( + async ({box, square, spring, restart}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element, { + now: () => Number(document.timeline.currentTime), + }); + const path = Motion.lerpPath(square, box); + const read = () => { + const r = element.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; + }; + await morph.present(path, {spring: spring}).started; + // Partway up the present, with the box well away from both ends. + await new Promise(resolve => setTimeout(resolve, 160)); + // Read and switch inside a frame callback: there the timeline's time is + // the frame's, the time the browser samples the running animation at. + // In a timer task WebKit can hand out a timeline time a frame stale + // while the box is sampled fresher, which a loaded page shows as a + // step of a few px. + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + await frame(); + const previous = read(); + const previousAt = Number(document.timeline.currentTime); + await frame(); + const before = read(); + const at = Number(document.timeline.currentTime); + if (restart) { + // What a morph that did not continue would do: start the path over. + morph.reset(); + morph.present(path, {spring: spring}); + } else { + // On the element itself: no copy, so the switch alone moves the box. + morph.dismiss(path, {spring: spring}, {target: element}); + } + const after = read(); + const change = (a: typeof before, b: typeof before) => + Math.max( + Math.abs(a.x - b.x), + Math.abs(a.y - b.y), + Math.abs(a.width - b.width), + Math.abs(a.height - b.height), + ); + return { + before, + after, + step: change(after, before), + // The reads share a clock that WebKit counts in whole milliseconds, + // so they can only agree to the box's travel in one of them, + // measured over the frame before. + clockTick: change(before, previous) / Math.max(1, at - previousAt), + }; + }, + {box: BOX, square: SQUARE, spring: SLOW, restart}, + ); +} + +// The composited frames across a switch, on the morph's default clock. The +// box rises 240 px along a path with no fade, so the marker is tracked in +// every frame; the dismissal (or the restart) takes over 600 ms in, near the +// middle of the travel. The screencast starts once the present has started: +// before that the box sits at rest, and its first step onto the path is not +// the switch. +type Switching = { + morph: ReturnType; + path: ReturnType; + from: {x: number; y: number; width: number; height: number}; +}; +async function trackAcrossSwitch(page: Page, restart: boolean) { + const surface = page.locator('#surface'); + await markElement(surface, {inset: MARKER_INSET}); + await page.evaluate( + async ({box, spring}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element); + const from = { + x: box.x, + y: box.y + 240, + width: box.width * 0.8, + height: box.height * 0.8, + }; + const path = Motion.lerpPath(from, box); + await morph.present(path, {spring: spring}).started; + (window as unknown as {switching: Switching}).switching = { + morph, + path, + from, + }; + }, + {box: BOX, spring: SLOW}, + ); + const cast = await startScreencast(page); + const geometry = await page.evaluate( + async ({box, spring, restart}) => { + const element = document.getElementById('surface') as HTMLElement; + const {morph, path, from} = (window as unknown as {switching: Switching}) + .switching; + await new Promise(resolve => setTimeout(resolve, 600)); + const at = element.getBoundingClientRect().y; + if (restart) { + morph.reset(); + morph.present(path, {spring: spring}); + } else { + morph.dismiss(path, {spring: spring}, {target: element}); + } + await new Promise(resolve => setTimeout(resolve, 600)); + // The smallest move a discontinuity would make from here: back to the + // path's start (a restart), or to the box at rest (a frame presented + // without the morph's transform). + return {jump: Math.min(Math.abs(at - from.y), Math.abs(at - box.y))}; + }, + {box: BOX, spring: SLOW, restart}, + ); + await page.waitForTimeout(100); + const seen = (await trackMarker(page, await cast.stop())).filter( + frame => frame.box != null, + ); + // A loaded page can open the screencast on the last frame composited before + // the present's first, with the box at rest; its step onto the path is not + // the switch. The present starts 240 px below rest, so the frames from the + // first one off the rest position on are the present's. + const onPath = seen.findIndex( + frame => (frame.box?.y ?? 0) > BOX.y + MARKER_INSET + 1, + ); + const tracked = onPath < 0 ? seen : seen.slice(onPath); + let rising = 0; + let falling = 0; + let worstStep = 0; + for (let i = 1; i < tracked.length; i++) { + const dy = tracked[i].box!.y - tracked[i - 1].box!.y; + if (dy < -0.5) { + rising++; + } + if (dy > 0.5) { + falling++; + } + worstStep = Math.max(worstStep, Math.abs(dy)); + } + return {tracked: tracked.length, rising, falling, worstStep, ...geometry}; +} + +const SUBPIXEL = 0.5; + +test('a dismissal mid-present starts from the box: reads either side of the switch, in one task', async ({ + page, +}) => { + await open(page); + const reading = await readAcrossSwitch(page, false); + test + .info() + .annotations.push({type: 'switch', description: JSON.stringify(reading)}); + expect(reading.step).toBeLessThanOrEqual(SUBPIXEL + reading.clockTick); +}); + +test('on screen, the box keeps moving through the switch without a jump', async ({ + page, +}) => { + await open(page); + const reading = await trackAcrossSwitch(page, false); + test + .info() + .annotations.push({type: 'track', description: JSON.stringify(reading)}); + // Frames on both sides of the switch: the box was tracked rising under the + // present and falling under the dismissal. + expect(reading.rising).toBeGreaterThanOrEqual(5); + expect(reading.falling).toBeGreaterThanOrEqual(5); + // A frame's own travel is a few px; a discontinuity moves the box by at + // least `jump`. Half of it separates the two. + expect(reading.worstStep).toBeLessThan(reading.jump / 2); +}); + +test('a restart fails both readings, so they have teeth', async ({page}) => { + await open(page); + const read = await readAcrossSwitch(page, true); + await open(page); + const track = await trackAcrossSwitch(page, true); + test.info().annotations.push({ + type: 'restart', + description: JSON.stringify({read, track}), + }); + expect(read.step).toBeGreaterThan(SUBPIXEL + read.clockTick); + expect(track.worstStep).toBeGreaterThanOrEqual(track.jump / 2); +}); + +test('a present keeps growing every frame through a 1 s main-thread block', async ({ + page, +}) => { + await open(page); + const surface = page.locator('#surface'); + await markElement(surface, {inset: MARKER_INSET}); + const cast = await startScreencast(page); + await page.evaluate( + async ({box, spring}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element); + // From 80 % of its size, 240 px lower (on screen the whole way): the + // marker in the box's corner rises the whole way, over a px a frame. + const from = { + x: box.x, + y: box.y + 240, + width: box.width * 0.8, + height: box.height * 0.8, + }; + await morph.present(Motion.lerpPath(from, box), {spring: spring}).started; + }, + {box: BOX, spring: SLOW}, + ); + const busy = await blockMainThread(page, 1000, { + during: surface, + minProgressMs: 40, + }); + await page.waitForTimeout(200); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, busy, {axis: 'y', settledWithin: 2}); + test + .info() + .annotations.push({type: 'busy window', description: proof.summary}); + expect(busy.to - busy.from).toBeGreaterThanOrEqual(1000); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); +}); + +test('no Layout or Paint runs while it animates', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'tracing is a Chromium protocol'); + await open(page); + let start = 0; + const trace = await traceRendering(page, async () => { + start = await page.evaluate( + ({box, square, spring}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element); + morph.present(Motion.lerpPath(square, box, {opacity: [0, 1]}), { + spring: spring, + }); + return performance.timeOrigin + performance.now(); + }, + {box: BOX, square: SQUARE, spring: SLOW}, + ); + await page.waitForTimeout(1900); + }); + // After the frame that starts the animation. + const counts = countBetween(trace, start + 50, trace.window.to); + test.info().annotations.push({ + type: 'rendering work while animating', + description: JSON.stringify(counts), + }); + expect(counts.Layout ?? 0).toBe(0); + expect(counts.Paint ?? 0).toBe(0); +}); diff --git a/packages/core/src/motion/progress.test.ts b/packages/core/src/motion/progress.test.ts index 4de3fa57df8de..a56ce3a5b74bf 100644 --- a/packages/core/src/motion/progress.test.ts +++ b/packages/core/src/motion/progress.test.ts @@ -7,6 +7,7 @@ import { mapRange, presentedProgressVelocity, progressCurve, + timedProgressCurve, type ProgressChannel, } from './progress'; import {solveSpring, toSpring} from './spring'; @@ -170,3 +171,22 @@ describe('presentedProgressVelocity', () => { expect(times).toEqual([1234, 1234]); }); }); + +describe('timedProgressCurve', () => { + it('runs from 0 to 1 over the duration on the easing, then holds 1', () => { + const curve = timedProgressCurve(300, [0.25, 0.1, 0.25, 1]); + expect(curve.duration).toBeCloseTo(0.3, 9); + expect(curve.at(0)).toBeCloseTo(0, 9); + expect(curve.at(0.15)).toBeGreaterThan(0.5); + expect(curve.at(0.3)).toBe(1); + expect(curve.at(1)).toBe(1); + expect(curve.velocity(0.3)).toBe(0); + expect(curve.velocity(0.1)).toBeGreaterThan(0); + }); + + it('lands at once for a zero duration', () => { + const curve = timedProgressCurve(0, [0, 0, 1, 1]); + expect(curve.duration).toBe(0); + expect(curve.at(0)).toBe(1); + }); +}); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts index 8b97de255de90..11aec62e51d24 100644 --- a/packages/core/src/motion/progress.ts +++ b/packages/core/src/motion/progress.ts @@ -4,7 +4,8 @@ * @file progress.ts * @input A spring, a normalized progress, an animator's presented velocities * @output Exports `progressCurve` (a spring's progress from 0 to 1 as a - * MotionCurve), `mapProgress` (one curve as a function of another), + * MotionCurve), `timedProgressCurve` (the same over a duration and + * easing), `mapProgress` (one curve as a function of another), * `mapRange` (the clamped linear map most companions use) and * `presentedProgressVelocity` (the progress velocity that continues what * an animator shows) @@ -39,6 +40,7 @@ */ import type {Animator, MotionCurve, MotionProperty} from './animator'; +import {cubicBezier, type BezierPoints} from './bezier'; import { SETTLING_EPSILON, solveSpring, @@ -69,6 +71,33 @@ export function progressCurve( }; } +/** + * A progress from 0 to 1 over `durationMs`, eased by the cubic-bézier + * `easing`: the curve a timed motion (a theme duration and easing) plays. + * Its velocity is in progress per second. + */ +export function timedProgressCurve( + durationMs: number, + easing: BezierPoints, +): MotionCurve { + const duration = Math.max(0, durationMs) / 1000; + const ease = cubicBezier(easing); + const at = (t: number) => + duration === 0 || t >= duration ? 1 : ease(Math.max(0, t) / duration); + return { + duration, + at, + velocity: t => { + if (duration === 0 || t >= duration) { + return 0; + } + const t0 = Math.max(0, t - STEP); + const t1 = Math.min(duration, t + STEP); + return (at(t1) - at(t0)) / (t1 - t0); + }, + }; +} + /** * The curve of `f(driver(t))`, its velocity by the chain rule: a channel * following a path as its progress moves, or a companion following any diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts new file mode 100644 index 0000000000000..15b836b616039 --- /dev/null +++ b/packages/core/src/motion/rectMorph.test.ts @@ -0,0 +1,695 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rectMorph.test.ts + * @input vitest (jsdom), the rect morph, a recording stand-in for + * Element.animate (jsdom runs no animations), and the models of the + * presentations that move onto the morph, as their pull requests have them + * @output Checks that present and dismiss continue each other from the rect + * on screen with its velocity; the dismissal's target; the inline state + * left behind; Reduced Motion through the animator (the paths and the + * presentations' models are checked in morphPath.test.ts) + * @position Testing; the compositor side is proven in + * __tests__/rectMorph.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {createMotionAnimator} from './animator'; +import {progressCurve, timedProgressCurve} from './progress'; +import { + createRectMorph, + edgeGrowPath, + lerpPath, + type MorphRect, +} from './rectMorph'; +import {timing} from './uikit/timing'; +import {registerSpringsTable, springs} from './uikit/springs'; +import {resolveMotion} from './resolveMotion'; + +interface Recorded { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + cancelled: boolean; + startTime: number | null; + readonly playState: 'idle' | 'running'; + ready: Promise; + cancel(): void; +} + +let clock = 1000; +let recorded: Recorded[] = []; +// The animator's frames: a call reads the browser's start for its curves in +// the frame after it, never in the task that makes them, so a test plays that +// frame before it reads where the element is. +let frameQueue: (() => void)[] = []; +const requestFrame = (callback: () => void) => { + frameQueue.push(callback); +}; +async function playFrame() { + const frames = frameQueue; + frameQueue = []; + for (const callback of frames) { + callback(); + } + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + clock = 1000; + recorded = []; + frameQueue = []; + vi.useFakeTimers(); + HTMLElement.prototype.animate = function (keyframes, options) { + const animation: Recorded = { + element: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + cancelled: false, + // The browser starts it at once: its first frame is committed at the + // call, so the animator's model starts the curve then. + startTime: clock, + get playState() { + return this.cancelled ? 'idle' : 'running'; + }, + ready: new Promise(() => {}), + cancel() { + this.cancelled = true; + }, + }; + recorded.push(animation); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +/** + * The person's settings, through `matchMedia` as the animator reads them: no + * option overrides them. + */ +function stubMedia(reduced: boolean, coarse: boolean) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('prefers-reduced-motion') + ? reduced + : query.includes('pointer: coarse') + ? coarse + : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +} + +const SPRING = {response: 0.4, dampingRatio: 1}; +const BOUNCY = {response: 0.35, dampingRatio: 0.8}; + +function expectRect(actual: MorphRect, expected: MorphRect, tolerance: number) { + const gap = Math.max( + Math.abs(actual.x - expected.x), + Math.abs(actual.y - expected.y), + Math.abs(actual.width - expected.width), + Math.abs(actual.height - expected.height), + ); + expect(gap, JSON.stringify({actual, expected})).toBeLessThanOrEqual( + tolerance, + ); +} + +const SQUARE: MorphRect = {x: 179, y: 160, width: 22, height: 22}; +const BOX: MorphRect = {x: 40, y: 204, width: 320, height: 253}; + +// The popover's model, as #8187 has it, for the present that turns around +// on its edge growth. +/** Popover (#8187, popoverSurfaceMotion.ts): popoverCollapsedState, popoverFrame. */ +const POPOVER = {collapsedScale: 0.01, collapsedLengthPx: 100}; +function popoverModel(surface: MorphRect, source: MorphRect) { + const gaps = { + below: surface.y - (source.y + source.height), + above: source.y - (surface.y + surface.height), + right: surface.x - (source.x + source.width), + left: source.x - (surface.x + surface.width), + }; + let side: keyof typeof gaps = 'below'; + for (const candidate of ['above', 'right', 'left'] as const) { + if (gaps[candidate] > gaps[side]) { + side = candidate; + } + } + const clamp = (v: number, min: number, max: number) => + Math.min(Math.max(v, min), max); + const centerX = clamp( + source.x + source.width / 2 - surface.x, + 0, + surface.width, + ); + const centerY = clamp( + source.y + source.height / 2 - surface.y, + 0, + surface.height, + ); + const vertical = side === 'below' || side === 'above'; + const fullLength = vertical ? surface.height : surface.width; + const axis: 'x' | 'y' = vertical ? 'y' : 'x'; + const state = { + originX: vertical ? centerX : side === 'right' ? 0 : surface.width, + originY: vertical ? (side === 'below' ? 0 : surface.height) : centerY, + axis, + scale: POPOVER.collapsedScale, + length: Math.min(1, POPOVER.collapsedLengthPx / fullLength), + }; + // A frame of (scale, length, opacity), scaled about the origin. + const frameRect = (scale: number, length: number): MorphRect => { + const along = scale * length; + const scaleX = state.axis === 'x' ? along : scale; + const scaleY = state.axis === 'y' ? along : scale; + return { + x: surface.x + state.originX * (1 - scaleX), + y: surface.y + state.originY * (1 - scaleY), + width: surface.width * scaleX, + height: surface.height * scaleY, + }; + }; + return { + state, + rectAt: (p: number) => + frameRect( + state.scale + (1 - state.scale) * p, + state.length + (1 - state.length) * p, + ), + frameRect, + }; +} + +describe('createRectMorph', () => { + const setup = (options: {reducedMotion?: boolean; coarse?: boolean} = {}) => { + const element = document.createElement('div'); + document.body.appendChild(element); + stubMedia(options.reducedMotion ?? false, options.coarse ?? true); + const morph = createRectMorph(element, {now: () => clock, requestFrame}); + return {element, morph}; + }; + const copyOf = (element: HTMLElement) => { + const copy = element.cloneNode(true) as HTMLElement; + copy.setAttribute('data-copy', ''); + element.after(copy); + return copy; + }; + const live = (target: Element, property: string) => + recorded.filter( + animation => + !animation.cancelled && + animation.element === target && + animation.keyframes.some(frame => property in frame), + ); + const settle = async (ms: number) => { + await playFrame(); + clock += ms; + await vi.advanceTimersByTimeAsync(ms); + }; + + it('plays the table value, since a motion role is internal (DEC-11)', () => { + // A registered table's leaf is a motion role, but a role resolves to its + // table value: touch motion stays internal and no `--motion-*` variable + // retunes it. Writing one on the scope therefore changes nothing. + const {element, morph} = setup(); + const scope = document.createElement('div'); + scope.setAttribute('data-astryx-theme', 'brisk'); + document.body.appendChild(scope); + scope.appendChild(element); + registerSpringsTable(); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (node: Element) => + ({ + getPropertyValue: (name: string) => + node === scope && name === '--motion-springs-menu-open' + ? '0.9s 0.25' + : '', + }) as CSSStyleDeclaration, + ); + expect(resolveMotion(element, springs.menuOpen)).toBe(springs.menuOpen); + + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: springs.menuOpen}); + const [presenting] = live(element, 'scale'); + const withVariable = Number(presenting.options.duration); + vi.restoreAllMocks(); + + const plain = setup(); + plain.morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), { + spring: springs.menuOpen, + }); + const [plainPresenting] = live(plain.element, 'scale'); + expect(withVariable).toBeCloseTo( + Number(plainPresenting.options.duration), + 6, + ); + }); + + it('presents the element from its source to rest on the spring', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: BOUNCY}); + await playFrame(); + expect(element.style.getPropertyValue('transform-origin')).toBe('0px 0px'); + expect(live(element, 'scale').length).toBe(1); + expect(live(element, 'translate').length).toBe(1); + const progress = progressCurve(BOUNCY); + const start = clock; + for (const t of [0, 0.03, 0.08, 0.15, 0.3]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(progress.at(t)), 0.01); + } + }); + + it('presents on a theme timing: its duration and easing as given, from rest', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + const easing = [0.2, 0, 0, 1] as const; + // A tap has no velocity: a timed present starts from rest even when + // one is given. + morph.present(path, {duration: 300, easing}, {velocity: 6}); + await playFrame(); + expect(live(element, 'scale').length).toBe(1); + const progress = timedProgressCurve(300, easing); + const start = clock; + for (const t of [0, 0.05, 0.12, 0.2, 0.29]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(progress.at(t)), 0.01); + } + }); + + it('plays on an animator it is handed, and leaves it to its owner when disposed', async () => { + // A zoom stage already drives the photo with its own animator (pinch, + // pan, settle): the morph plays on it, so the two share the channels. + const element = document.createElement('div'); + document.body.appendChild(element); + stubMedia(false, true); + const stage = createMotionAnimator(element, { + now: () => clock, + requestFrame, + }); + const morph = createRectMorph(element, { + animator: stage, + now: () => clock, + requestFrame, + }); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: BOUNCY}); + await playFrame(); + expect(live(element, 'scale')).toHaveLength(1); + clock += 50; + expect(stage.get('scaleX', clock)).toBeCloseTo( + morph.presentedRect()!.width / BOX.width, + 9, + ); + expect(stage.get('scaleX', clock)).not.toBeCloseTo(1, 3); + morph.dispose(); + // Still the stage's: it moves the element after the morph is gone. + stage.set({x: 12}); + expect(stage.get('x')).toBe(12); + }); + + it('holds the element at its first values until two frames present it, when asked for the presented start', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: BOUNCY}, {start: 'presented'}); + expect(live(element, 'scale')).toHaveLength(0); + expectRect(morph.presentedRect()!, path.rectAt(0), 0.01); + await playFrame(); + expect(live(element, 'scale')).toHaveLength(0); + await playFrame(); + expect(live(element, 'scale')).toHaveLength(1); + }); + + it('leaves nothing of its own inline once the present settles', async () => { + const {element, morph} = setup(); + morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + expectRect(morph.presentedRect()!, BOX, 1e-9); + }); + + it('leaves a component\u2019s own opacity alone when its path has none', async () => { + const {element, morph} = setup(); + element.style.setProperty('opacity', '0.5'); + morph.present(lerpPath(SQUARE, BOX), {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + expect(element.style.getPropertyValue('opacity')).toBe('0.5'); + expect(live(element, 'opacity').length).toBe(0); + }); + + it('dismisses on its target from the rect on screen, leaves the element as laid out, then releases the target', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + const copy = copyOf(element); + const dismissing = morph.dismiss(path, {spring: BOUNCY}, {target: copy}); + expect(copy.style.getPropertyValue('transform-origin')).toBe('0px 0px'); + expect(live(copy, 'scale').length).toBe(1); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + // It runs from rest back to the square, fading out. + await playFrame(); + const progress = progressCurve(BOUNCY); + const back = lerpPath(BOX, SQUARE, {opacity: [1, 0]}); + const start = clock; + for (const t of [0, 0.05, 0.1, 0.2]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, back.rectAt(progress.at(t)), 0.01); + } + clock = start; + await settle(progress.duration * 1000 + 50); + expect(await dismissing.finished).toBe(true); + expect(copy.isConnected).toBe(false); + }); + + it('lets the element go of its compositing layer as a dismissal on a copy starts, so no style write reaches it during the dismissal', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + // Dismissed about 100 ms after the present's curve ended, while the + // animator still keeps the element promoted (its promotion linger). + await settle(progressCurve(SPRING).duration * 1000 + 100); + const copy = copyOf(element); + morph.dismiss(path, {spring: BOUNCY}, {target: copy}); + // The dismissal's own task. + await Promise.resolve(); + await Promise.resolve(); + const writes: string[] = []; + const observer = new MutationObserver(records => { + for (const record of records) { + writes.push(record.oldValue ?? ''); + } + }); + observer.observe(element, { + attributes: true, + attributeFilter: ['style'], + attributeOldValue: true, + }); + // The dismissal's whole run, and any linger past it. + for (let ms = 0; ms < 1200; ms += 50) { + await settle(50); + } + writes.push(...observer.takeRecords().map(record => record.oldValue ?? '')); + observer.disconnect(); + expect(writes).toEqual([]); + expect(element.style.getPropertyValue('will-change')).toBe(''); + }); + + it('presents the element again after a dismissal on a copy', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 100); + morph.dismiss(path, {spring: BOUNCY}, {target: copyOf(element)}); + await settle(progressCurve(BOUNCY).duration * 1000 + 50); + const presenting = morph.present(path, {spring: SPRING}); + expect(live(element, 'scale').length).toBe(1); + await settle(progressCurve(SPRING).duration * 1000 + 50); + expect(await presenting.finished).toBe(true); + expect(element.style.getPropertyValue('scale')).toBe(''); + }); + + it('hands its target to the caller\u2019s release, and dismisses the element itself when it is the target', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}); + await settle(1000); + const released: HTMLElement[] = []; + const copy = copyOf(element); + morph.dismiss( + path, + {spring: SPRING}, + { + target: copy, + release: target => released.push(target), + }, + ); + await settle(1000); + expect(released).toEqual([copy]); + expect(copy.isConnected).toBe(true); + + morph.present(path, {spring: SPRING}); + await settle(1000); + morph.dismiss(path, {spring: SPRING}, {target: element}); + expect(live(element, 'scale').length).toBe(1); + await settle(1000); + expect(element.isConnected).toBe(true); + expectRect(morph.presentedRect()!, SQUARE, 0.01); + }); + + it('a second dismissal on the copy in flight keeps the copy and continues from its rect', async () => { + // A dismissal continues the dismissal in flight, so a caller that hands + // the same copy again (a retargeted shrink) keeps it: its release runs + // once, when the dismissal that is still running ends. + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + const released: HTMLElement[] = []; + const release = (target: HTMLElement) => { + released.push(target); + target.remove(); + }; + const copy = copyOf(element); + morph.dismiss(path, {spring: BOUNCY}, {target: copy, release}); + clock += 60; + await vi.advanceTimersByTimeAsync(60); + const onScreen = morph.presentedRect()!; + + const again = morph.dismiss( + path, + {spring: SPRING}, + {target: copy, release}, + ); + expect(copy.isConnected).toBe(true); + expect(released).toEqual([]); + expect(live(copy, 'scale').length).toBe(1); + expectRect(morph.presentedRect()!, onScreen, 0.01); + + await settle(progressCurve(SPRING).duration * 1000 + 50); + expect(await again.finished).toBe(true); + expect(released).toEqual([copy]); + expect(copy.isConnected).toBe(false); + }); + + it('a dismissal during the present starts from the rect on screen, with its velocity', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await playFrame(); + const progress = progressCurve(SPRING); + clock += 60; + await vi.advanceTimersByTimeAsync(60); + const p = progress.at(0.06); + const dp = progress.velocity(0.06); + const onScreen = morph.presentedRect()!; + expectRect(onScreen, path.rectAt(p), 0.01); + const copy = copyOf(element); + morph.dismiss(path, {spring: BOUNCY}, {target: copy}); + // The same rect, now on the copy, and the same velocity: the dismissal's + // path is the rest of the line back to the square, so the growth's + // velocity carries on along it. + expectRect(morph.presentedRect()!, onScreen, 0.01); + await playFrame(); + const before = morph.presentedRect()!; + clock += 0.01; + const after = morph.presentedRect()!; + const expectedWidthSpeed = (BOX.width - SQUARE.width) * dp; // px/s + expect((after.width - before.width) / 0.01e-3).toBeCloseTo( + expectedWidthSpeed, + -1, + ); + }); + + it('a present during the dismissal starts from the copy\u2019s rect with its velocity and takes the copy away', async () => { + const {element, morph} = setup(); + const surface = {x: 41, y: 204, width: 320, height: 253}; + const model = popoverModel(surface, { + x: 141, + y: 160, + width: 120, + height: 44, + }); + const path = edgeGrowPath({ + box: surface, + origin: {x: model.state.originX, y: model.state.originY}, + axis: model.state.axis, + collapsedScale: model.state.scale, + collapsedLength: model.state.length, + }); + morph.present(path, {spring: SPRING}); + await settle(1000); + const copy = copyOf(element); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + await playFrame(); + clock += 80; + await vi.advanceTimersByTimeAsync(80); + const onScreen = morph.presentedRect()!; + const before = {...onScreen}; + clock += 1; + const moving = morph.presentedRect()!; + clock -= 1; + morph.present(path, {spring: SPRING}); + expect(copy.isConnected).toBe(false); + expectRect(morph.presentedRect()!, onScreen, 0.01); + await playFrame(); + // It keeps shrinking for a moment (the dismissal's velocity), then grows. + clock += 1; + const next = morph.presentedRect()!; + expect(Math.sign(next.width - before.width)).toBe( + Math.sign(moving.width - before.width), + ); + await settle(1000); + expectRect(morph.presentedRect()!, surface, 1e-6); + }); + + it('takes a release velocity as given (progress per second)', async () => { + const {morph} = setup(); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}, {velocity: 6}); + await playFrame(); + const before = morph.presentedRect()!; + clock += 0.01; + const after = morph.presentedRect()!; + expect((after.x - before.x) / 0.01e-3).toBeCloseTo( + (BOX.x - SQUARE.x) * 6, + -1, + ); + }); + + it('reset after a settled present writes nothing back inline', async () => { + const {element, morph} = setup(); + morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + morph.reset(); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + }); + + it('reset ends every motion, takes the target away and leaves the element as laid out', () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + const copy = copyOf(element); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + morph.reset(); + expect(copy.isConnected).toBe(false); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + expect(morph.presentedRect()).toBeNull(); + }); + + describe('under Reduced Motion (the animator\u2019s, design:reduced-motion)', () => { + /** When `animation`'s opacity reaches `target` for good, ms after its start. */ + const fadeEndMs = (animation: Recorded, target: number) => { + const duration = Number(animation.options.duration); + let end = 0; + for (const frame of animation.keyframes) { + if (Math.abs(Number(frame.opacity) - target) > 1e-3) { + end = (frame.offset as number) * duration; + } + } + const index = animation.keyframes.findIndex( + frame => (frame.offset as number) * duration > end, + ); + return (animation.keyframes[index].offset as number) * duration; + }; + + it('on a coarse pointer, a present lands at rest and fades in, a dismissal fades out where it stands, in 200 ms', async () => { + const {element, morph} = setup({reducedMotion: true, coarse: true}); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + const [fadeIn] = live(element, 'opacity'); + expect(fadeEndMs(fadeIn, 1)).toBeLessThanOrEqual( + timing.reducedMotionCrossFadeMs + 1, + ); + // Nothing grows: it stands at rest while it fades. + expectRect(morph.presentedRect()!, BOX, 0.01); + await settle(1000); + + const copy = copyOf(element); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + const [fadeOut] = live(copy, 'opacity'); + const fadeMs = fadeEndMs(fadeOut, 0); + expect(fadeMs).toBeLessThanOrEqual(timing.reducedMotionCrossFadeMs + 1); + const start = clock; + for (const t of [0, 50, 100, 150]) { + clock = start + t; + expectRect(morph.presentedRect()!, BOX, 0.01); + } + }); + + it('lands a path with no opacity at rest, instead of starting on its source rect', () => { + // With no opacity of its own the path gives the cross-fade nothing to + // read, so the morph says which way it goes: a present lands at rest + // (design:reduced-motion), it does not dip to the source and grow. + const {element, morph} = setup({reducedMotion: true, coarse: true}); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}); + expectRect(morph.presentedRect()!, BOX, 0.01); + expect(live(element, 'scale').length).toBe(0); + expect(live(element, 'translate').length).toBe(0); + }); + + it('keeps the animator’s dip for an element that only moves, when the caller says so', () => { + // A sub-menu's parent card stays visible and only moves: treated as an + // entrance it would cut out and fade in, so its caller asks for a move, + // and the animator dips it: out, the travel lands, back in. + const {element, morph} = setup({reducedMotion: true, coarse: true}); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}, {crossfadeAs: 'move'}); + const [dip] = live(element, 'opacity'); + expect(dip).toBeDefined(); + expect(Number(dip.keyframes[0].opacity)).toBeCloseTo(1, 6); + expect( + Math.min(...dip.keyframes.map(frame => Number(frame.opacity))), + ).toBeLessThan(0.5); + }); + + it('on any other pointer, it completes at once', () => { + const {element, morph} = setup({reducedMotion: true, coarse: false}); + morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), {spring: SPRING}); + expect(live(element, 'scale').length).toBe(0); + expect(live(element, 'opacity').length).toBe(0); + expectRect(morph.presentedRect()!, BOX, 0.01); + }); + }); +}); diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts new file mode 100644 index 0000000000000..af8464004a3f6 --- /dev/null +++ b/packages/core/src/motion/rectMorph.ts @@ -0,0 +1,501 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file rectMorph.ts + * @input An element laid out at rest in its box, a path it grows along + * (morphPath.ts), its timing (a duration and easing, or a spring), the + * compositor animator and the progress helpers (progress.ts) + * @output Exports `createRectMorph` (present and dismiss, each interrupting + * the other), and re-exports the paths it plays (`lerpPath`, + * `edgeGrowPath`, `morphCurves`) from morphPath.ts + * @position Motion foundation building block, internal to core: the one + * "grow out of a source rect, shrink back into it" that touch + * presentations share: an anchored menu, a popover, a context menu's + * lifted preview, a photo's zoom, a sub-menu's parent card + * + * A path (morphPath.ts) names the rect an element shows at each progress, + * and its curves put the element there with `translate` and `scale` about + * `transform-origin: 0 0` and a companion `opacity`, on the compositor. + * `createRectMorph` plays a path on its element + * (`present`) and back to the source on a target the caller gives + * (`dismiss`): a copy placed where the element stands, since the browser + * hides a closing popover at once, or the element itself. Each interrupts + * the other: the new motion starts from the rect the animator presents at + * that moment, on the path restarted there in its own law (`restartFrom`), + * with the presented velocity projected on it (progress.ts). Once a present settles nothing of the morph's is left inline: iOS + * Safari will not scroll a finger through a popover that keeps a `scale`, + * even at 1. + * + * A present or a dismissal takes its timing as a motion: the theme's + * duration and easing for a motion that a tap, a key or the program starts, + * or a spring for a gesture's release, which starts at the gesture's + * velocity. Only a spring carries a velocity into the motion. + * + * Reduced Motion is the animator's: on a coarse + * pointer a present lands at rest and fades in, a dismissal fades out where + * it stands, opacity only, in 200 ms; on any other pointer the change + * completes at once. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/rectMorph.test.ts + * - /packages/core/src/motion/README.md + * - /packages/core/src/motion/morphPath.ts (the paths it plays) + */ + +import { + createMotionAnimator, + type AnimateOptions, + type Animator, + type AnimatorOptions, + type MotionHandle, + type MotionValues, +} from './animator'; +import { + presentedProgressVelocity, + progressCurve, + timedProgressCurve, + type ProgressChannel, +} from './progress'; +import { + lerpPath, + morphCurves, + travelAt, + type MorphEnd, + type MorphPath, + type MorphRect, + type TravelProperty, +} from './morphPath'; +import type {MotionCurvePoints} from './roles'; +import type {SpringDescription} from './spring'; +import {resolveMotion} from './resolveMotion'; + +// The paths the morph plays, where its callers have always imported them. +export {edgeGrowPath, lerpPath, morphCurves} from './morphPath'; +export type {MorphEnd, MorphPath, MorphRect, MorphScale} from './morphPath'; + +/** The path from what is on screen to `path`'s `end`. */ +function restart( + path: MorphPath, + rect: MorphRect, + opacity: number, + end: MorphEnd, +): MorphPath { + if (path.restartFrom != null) { + return path.restartFrom(rect, opacity, end); + } + const p = end === 'rest' ? 1 : 0; + const {opacityAt} = path; + return lerpPath(rect, path.rectAt(p), { + box: path.box, + scale: path.scale, + ...(opacityAt != null ? {opacity: [opacity, opacityAt(p)] as const} : null), + }); +} + +/** The rect an animator shows for an element laid out in `box`, at `now`. */ +function presentedRectOf( + animator: Animator, + box: MorphRect, + now: number, +): MorphRect { + return { + x: box.x + animator.get('x', now), + y: box.y + animator.get('y', now), + width: box.width * animator.get('scaleX', now), + height: box.height * animator.get('scaleY', now), + }; +} + +/** The travel channels of `path`, weighted in px for the projection. */ +function channelsOf(path: MorphPath): ProgressChannel[] { + const at = (property: TravelProperty) => (p: number) => + travelAt(path, p)[property]; + return [ + {property: 'x', at: at('x')}, + {property: 'y', at: at('y')}, + {property: 'scaleX', at: at('scaleX'), weight: path.box.width}, + {property: 'scaleY', at: at('scaleY'), weight: path.box.height}, + ]; +} + +function startValues(path: MorphPath, opacity?: number): MotionValues { + const values: MotionValues = {...travelAt(path, 0)}; + if (path.opacityAt != null) { + values.opacity = opacity ?? path.opacityAt(0); + } + return values; +} + +/** The morph's own inline properties, left on an element only while it moves. */ +const INLINE_PROPERTIES = ['translate', 'scale', 'transform-origin'] as const; + +/** + * How a present or a dismissal is timed. A motion that a tap, a key or the + * program starts plays the theme's duration (ms) and easing. A gesture's + * release plays a spring, which starts at the gesture's velocity + * (`RectMorphPlayOptions.velocity`). + */ +export type RectMorphMotion = + | {readonly duration: number; readonly easing: MotionCurvePoints} + | {readonly spring: SpringDescription}; + +/** + * How a present or a dismissal plays. + */ +export interface RectMorphPlayOptions { + /** + * Progress per second a spring starts with (a finger's release). By + * default the velocity of what is on screen, projected on the path. A + * timed motion starts from rest. + */ + velocity?: number; + /** + * A timing of its own for the companion opacity (a zoom's closing fade); + * the frame's timing by default. + */ + fade?: RectMorphMotion; + /** For the animator's Reduced Motion: a touch drag's release starts it. */ + touchRelease?: AnimateOptions['touchRelease']; + /** + * For the animator's Reduced Motion: what a cross-fade treats it as. A path + * with no opacity of its own is an entrance on a present and an exit on a + * dismissal by default; `'move'` keeps the animator's own dip for an + * element that stays visible and only moves (a sub-menu's parent card), so + * it fades out, lands and fades back in instead of cutting out. + */ + crossfadeAs?: AnimateOptions['crossfadeAs'] | 'move'; + /** + * When the curves start: `'presented'` holds the element at its first + * values until two frames have presented it, so a surface that appears + * late plays the whole curve (the animator's `start`). + */ + start?: AnimateOptions['start']; +} + +/** + * Where a dismissal plays. + */ +export interface RectMorphDismissOptions extends RectMorphPlayOptions { + /** + * The element the dismissal plays on: a copy of the element laid out in the + * path's box (a closing popover is hidden at once), or the element itself. + */ + target: HTMLElement; + /** + * Takes `target` away once the dismissal ends or a present interrupts it: + * `target.remove()` by default, nothing when it is the element. + */ + release?: (target: HTMLElement) => void; +} + +/** + * The present and the dismissal of one element. + */ +export interface RectMorph { + /** + * Plays `path` on the element from its source to rest on `motion`. While a + * dismissal or a present plays, it starts from the rect on screen instead, + * restarted on `path` toward rest with the presented velocity. + */ + present( + path: MorphPath, + motion: RectMorphMotion, + options?: RectMorphPlayOptions, + ): MotionHandle; + /** + * Plays `path` back to its source on `options.target` on `motion`, from the + * rect the element presents now (or the dismissal in flight) with its + * velocity. The element is left as laid out unless it is the target. + */ + dismiss( + path: MorphPath, + motion: RectMorphMotion, + options: RectMorphDismissOptions, + ): MotionHandle; + /** The rect on screen now (the dismissal's while one plays); null before any. */ + presentedRect(): MorphRect | null; + /** + * Ends every motion, takes a dismissal's target away and leaves the element + * as laid out, with none of the morph's inline properties. + */ + reset(): void; + /** `reset`, then releases the element's animator unless the caller handed it. */ + dispose(): void; +} + +function defaultNow(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +/** + * The rect morph of `element`. + * + * @example + * ``` + * const morph = createRectMorph(menu); + * const path = lerpPath(square, box, {opacity: [0, 1]}); + * // A tap opened the menu: the theme's entrance duration and easing. + * morph.present(path, {duration: enterMs, easing: enterEasing}); + * // …a finger flicked it closed, with a copy placed where the menu stands: + * morph.dismiss(path, {spring: springs.menuClose}, {target: copy, velocity}); + * ``` + * + * An element that already has an animator (a zoom stage's pinch and pan) + * hands it in `animator`: the morph plays on it, so the two share the + * element's channels, and `dispose` leaves it to its owner. + */ +export function createRectMorph( + element: HTMLElement, + { + animator: given, + ...options + }: Pick & {animator?: Animator} = {}, +): RectMorph { + const now = options.now ?? defaultNow; + const animator = given ?? createMotionAnimator(element, options); + /** The box the element's transform is relative to, once it has one. */ + let box: MorphRect | null = null; + let presenting: MotionHandle | null = null; + /** Whether a path has animated the element's opacity. */ + let ownsOpacity = false; + let exiting: { + target: HTMLElement; + animator: Animator; + box: MorphRect; + release: (target: HTMLElement) => void; + } | null = null; + + const clearInline = () => { + for (const property of INLINE_PROPERTIES) { + element.style.removeProperty(property); + } + }; + + // Leaves the element as laid out: identity, and none of the morph's inline + // properties (an opacity only when a path animated it). At rest it needs no + // compositing layer, so the values go in without promoting it. + const restoreElement = () => { + animator.set( + ownsOpacity + ? {x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1} + : {x: 0, y: 0, scaleX: 1, scaleY: 1}, + {promote: false}, + ); + clearInline(); + if (ownsOpacity) { + element.style.removeProperty('opacity'); + ownsOpacity = false; + } + box = null; + }; + + const endExit = () => { + if (exiting == null) { + return; + } + const {target, animator: closing, release} = exiting; + exiting = null; + if (closing !== animator) { + closing.dispose(); + } + release(target); + }; + + // Once a present settles on identity, nothing of the morph's stays inline. + const retire = () => { + const at = now(); + const identity = + Math.abs(animator.get('x', at)) < 1e-6 && + Math.abs(animator.get('y', at)) < 1e-6 && + Math.abs(animator.get('scaleX', at) - 1) < 1e-9 && + Math.abs(animator.get('scaleY', at) - 1) < 1e-9; + if (identity) { + clearInline(); + } + if (ownsOpacity && Math.abs(animator.get('opacity', at) - 1) < 1e-9) { + element.style.removeProperty('opacity'); + ownsOpacity = false; + } + }; + + // A path with no opacity of its own gives the animator's Reduced Motion + // cross-fade nothing to read, and it would start the motion on the source + // rect and dip. Such a path says which way it goes, so the cross-fade + // lands at rest on a present and fades out in place on a dismissal; a caller's own `crossfadeAs` still wins, and + // `'move'` keeps the animator's dip for an element that only moves. + const playOptions = ( + play: RectMorphPlayOptions, + path: MorphPath, + direction: 'entrance' | 'exit' = 'entrance', + ): AnimateOptions => ({ + ...(play.touchRelease != null ? {touchRelease: play.touchRelease} : null), + ...(play.start != null ? {start: play.start} : null), + ...(play.crossfadeAs === 'move' + ? null + : play.crossfadeAs != null + ? {crossfadeAs: play.crossfadeAs} + : path.opacityAt == null + ? {crossfadeAs: direction} + : null), + }); + + // A spring goes through `resolveMotion` on the element that plays it: a + // touch motion role resolves to its table value and reads no style, and a + // caller's bare spring comes back as given. A timed motion is the caller's + // duration and easing, already resolved from the theme. + const progressFor = ( + on: Element, + motion: RectMorphMotion, + velocity: number, + ) => + 'spring' in motion + ? progressCurve(resolveMotion(on, motion.spring), {velocity}) + : timedProgressCurve(motion.duration, motion.easing); + + const curvesFor = ( + on: Element, + path: MorphPath, + motion: RectMorphMotion, + play: RectMorphPlayOptions, + velocity: number, + ) => { + const progress = progressFor(on, motion, velocity); + const fade = + play.fade != null ? progressFor(on, play.fade, velocity) : progress; + return morphCurves(path, progress, fade); + }; + + const present: RectMorph['present'] = (path, motion, play = {}) => { + const at = now(); + const from = + exiting ?? (presenting != null && box != null ? {animator, box} : null); + let start = path; + let velocity = play.velocity ?? 0; + let opacity: number | undefined; + if (from != null) { + opacity = from.animator.get('opacity', at); + start = restart( + path, + presentedRectOf(from.animator, from.box, at), + opacity, + 'rest', + ); + velocity = + play.velocity ?? + presentedProgressVelocity(from.animator, channelsOf(start), at); + } + endExit(); + box = start.box; + ownsOpacity ||= start.opacityAt != null; + // A dismissal on a copy let the element go (dismiss, below). + animator.attach(element); + element.style.setProperty('transform-origin', '0px 0px'); + animator.set(startValues(start, opacity)); + const handle = animator.play( + curvesFor(element, start, motion, play, velocity), + playOptions(play, start), + ); + presenting = handle; + void handle.finished.then(done => { + if (done && presenting === handle) { + presenting = null; + retire(); + } + }); + return handle; + }; + + const dismiss: RectMorph['dismiss'] = (path, motion, play) => { + const {target} = play; + const at = now(); + const from = exiting ?? {animator, box: box ?? path.box}; + const opacity = from.animator.get('opacity', at); + const back = restart( + path, + presentedRectOf(from.animator, from.box, at), + opacity, + 'source', + ); + const velocity = + play.velocity ?? + presentedProgressVelocity(from.animator, channelsOf(back), at); + // The same copy handed again continues on it: it keeps its animator, and + // its release runs once, when the dismissal still running ends. Any other + // target ends the dismissal in flight first. + const continuing = + exiting != null && exiting.target === target ? exiting : null; + if (continuing != null) { + exiting = null; + } else { + endExit(); + } + presenting = null; + const onElement = target === element; + if (onElement) { + // A dismissal on a copy may have let the element go (below). + animator.attach(element); + box = back.box; + ownsOpacity ||= back.opacityAt != null; + } else { + // The element is hidden as it closes: leave it as laid out, and let go + // of the layer its presentation promoted, in this task. Kept, the + // promotion would end its linger after the element's last motion: a + // style write on an element that no longer paints, landing inside the + // dismissal's frames. The next present attaches the element again. + restoreElement(); + animator.attach(null); + } + const closing = onElement + ? animator + : (continuing?.animator ?? createMotionAnimator(target, options)); + target.style.setProperty('transform-origin', '0px 0px'); + // The copy starts as the element shows now, whatever its clone kept. + closing.set(startValues(back, opacity)); + const handle = closing.play( + curvesFor(target, back, motion, play, velocity), + playOptions(play, back, 'exit'), + ); + const current = { + target, + animator: closing, + box: back.box, + release: + play.release ?? + (onElement ? () => {} : (copy: HTMLElement) => copy.remove()), + }; + exiting = current; + void handle.finished.then(() => { + if (exiting === current) { + endExit(); + } + }); + return handle; + }; + + // `set` ends whatever still moves; nothing is frozen inline on the way. + const reset = () => { + endExit(); + presenting = null; + restoreElement(); + }; + + return { + present, + dismiss, + presentedRect() { + if (exiting != null) { + return presentedRectOf(exiting.animator, exiting.box, now()); + } + return box == null ? null : presentedRectOf(animator, box, now()); + }, + reset, + dispose() { + reset(); + if (given == null) { + animator.dispose(); + } + }, + }; +} From f19df271f4898e43f092b0df86bafc5dccb89c04 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 3 Nov 2026 08:58:46 -0800 Subject: [PATCH 2/4] fix(rectMorph): a handed-in animator stays on the element through a copy dismissal CodeBunny on #9769: a dismissal onto a copy detached the animator, and a handed-in one was never attached again, so its owner's writes stopped reaching the element. Only the morph's own animator lets go now. Co-authored-by: Cindy Zhang --- packages/core/src/motion/rectMorph.test.ts | 28 ++++++++++++++++++++++ packages/core/src/motion/rectMorph.ts | 7 ++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts index 15b836b616039..b78f7bb569c82 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -320,6 +320,34 @@ describe('createRectMorph', () => { expect(stage.get('x')).toBe(12); }); + it('leaves a handed-in animator on the element through a dismissal onto a copy', async () => { + const element = document.createElement('div'); + document.body.appendChild(element); + stubMedia(false, true); + const stage = createMotionAnimator(element, { + now: () => clock, + requestFrame, + }); + const morph = createRectMorph(element, { + animator: stage, + now: () => clock, + requestFrame, + }); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await playFrame(); + const copy = element.cloneNode(true) as HTMLElement; + element.after(copy); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + await playFrame(); + clock += 2000; + await vi.advanceTimersByTimeAsync(2000); + // The owner's own write still reaches the element. + stage.set({x: 12}); + expect(element.style.getPropertyValue('translate')).toContain('12px'); + morph.dispose(); + }); + it('holds the element at its first values until two frames present it, when asked for the presented start', async () => { const {element, morph} = setup(); const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index af8464004a3f6..b7370b4bfbfd7 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -443,9 +443,12 @@ export function createRectMorph( // of the layer its presentation promoted, in this task. Kept, the // promotion would end its linger after the element's last motion: a // style write on an element that no longer paints, landing inside the - // dismissal's frames. The next present attaches the element again. + // dismissal's frames. The next present attaches the element again. A + // handed-in animator stays on the element: its owner still drives it. restoreElement(); - animator.attach(null); + if (given == null) { + animator.attach(null); + } } const closing = onElement ? animator From e3a43d0d3fade1adc4cf7cf3c2d4c670a543f06f Mon Sep 17 00:00:00 2001 From: Cindy Zhang <2781099+cixzhang@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:39:01 -0800 Subject: [PATCH 3/4] fix(motion): one timed curve keeps a retargeted velocity, for the pager and the morph CodeBunny on #9769: solveAnimatedPageScroll already played this curve and keeps the velocity of the move it retargets. timedCurve in progress.ts is now that one curve; the pager and the morph's timed motion call it, so a timed present that interrupts a dismissal keeps its velocity. The INV12 note states the system only. File header and comments trimmed. Co-authored-by: Cindy Zhang --- docs/architecture/react-component-runtime.md | 2 +- packages/core/src/motion/paging.ts | 38 ++--------- packages/core/src/motion/progress.test.ts | 9 +++ packages/core/src/motion/progress.ts | 70 +++++++++++++++----- packages/core/src/motion/rectMorph.test.ts | 8 +-- packages/core/src/motion/rectMorph.ts | 48 ++++---------- 6 files changed, 87 insertions(+), 88 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 37e9e4bc3fbcc..2064edfb7ab6a 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -200,7 +200,7 @@ those contracts. `spec:AST-094` FR5) once Core has that morph; until then, the module plays its own compositor translate. (Implementation gap: Core has the shared rectangle morph, but the size-change glide still plays its own compositor - translate; it moves onto the morph next.) It never animates height. Its walk visits only + translate.) It never animates height. Its walk visits only the changed element's ancestors up to the area and the element siblings on either side at each of their levels, through `parentElement`, `nextElementSibling` and `previousElementSibling`, descending only into an diff --git a/packages/core/src/motion/paging.ts b/packages/core/src/motion/paging.ts index 2cd978c7c42af..19b6ccf14c2a5 100644 --- a/packages/core/src/motion/paging.ts +++ b/packages/core/src/motion/paging.ts @@ -14,7 +14,8 @@ */ import type {MotionCurve} from './animator'; -import {cubicBezier, type BezierPoints} from './bezier'; +import type {BezierPoints} from './bezier'; +import {timedCurve} from './progress'; import {paging} from './uikit/paging'; /** @@ -113,10 +114,9 @@ export function solvePaging({ * of the table. * * `velocity` (px/s) is the velocity of a move that this one retargets. The new - * curve starts at it and still ends on `to`, at rest and on time. A new target - * keeps the current velocity, where UIKit drops it. The carry is a Hermite - * term, v × duration × u(1 − u)², less the curve's own starting slope. So a - * move from rest is the curve itself. + * curve starts at it and still ends on `to`, at rest and on time + * (`timedCurve`): a new target keeps the current velocity, where UIKit drops + * it. */ export function solveAnimatedPageScroll({ from, @@ -131,31 +131,5 @@ export function solveAnimatedPageScroll({ curve?: BezierPoints; velocity?: number; }): MotionCurve { - const duration = durationMs / 1000; - const distance = to - from; - const ease = cubicBezier(curve); - const progress = (u: number) => ease(Math.min(1, Math.max(0, u))); - // The carry, in px: the retargeted velocity over the whole duration, less - // what the curve's own starting slope already gives. - const startSlope = progress(1e-4) / 1e-4; - const carry = - velocity == null ? 0 : velocity * duration - distance * startSlope; - const at = (t: number) => { - const u = Math.min(1, Math.max(0, t / duration)); - return from + distance * progress(u) + carry * u * (1 - u) * (1 - u); - }; - return { - duration, - at, - velocity: t => { - if (t <= 0) { - return velocity ?? 0; - } - if (t >= duration) { - return 0; - } - const h = Math.min(1e-4, t / 2, (duration - t) / 2); - return (at(t + h) - at(t - h)) / (2 * h); - }, - }; + return timedCurve({from, to, durationMs, easing: curve, velocity}); } diff --git a/packages/core/src/motion/progress.test.ts b/packages/core/src/motion/progress.test.ts index a56ce3a5b74bf..1d3535ac1ca66 100644 --- a/packages/core/src/motion/progress.test.ts +++ b/packages/core/src/motion/progress.test.ts @@ -184,6 +184,15 @@ describe('timedProgressCurve', () => { expect(curve.velocity(0.1)).toBeGreaterThan(0); }); + it('starts at a retargeted velocity and still lands on time, at rest', () => { + const curve = timedProgressCurve(300, [0.25, 0.1, 0.25, 1], {velocity: 6}); + expect(curve.velocity(0)).toBe(6); + const h = 1e-4; + expect((curve.at(h) - curve.at(0)) / h).toBeCloseTo(6, 1); + expect(curve.at(0.3)).toBeCloseTo(1, 9); + expect(curve.velocity(0.3)).toBe(0); + }); + it('lands at once for a zero duration', () => { const curve = timedProgressCurve(0, [0, 0, 1, 1]); expect(curve.duration).toBe(0); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts index 11aec62e51d24..bd2cfdb68a938 100644 --- a/packages/core/src/motion/progress.ts +++ b/packages/core/src/motion/progress.ts @@ -4,8 +4,8 @@ * @file progress.ts * @input A spring, a normalized progress, an animator's presented velocities * @output Exports `progressCurve` (a spring's progress from 0 to 1 as a - * MotionCurve), `timedProgressCurve` (the same over a duration and - * easing), `mapProgress` (one curve as a function of another), + * MotionCurve), `timedCurve` and `timedProgressCurve` (a duration and + * easing, keeping a retargeted velocity), `mapProgress` (one curve as a function of another), * `mapRange` (the clamped linear map most companions use) and * `presentedProgressVelocity` (the progress velocity that continues what * an animator shows) @@ -72,32 +72,72 @@ export function progressCurve( } /** - * A progress from 0 to 1 over `durationMs`, eased by the cubic-bézier - * `easing`: the curve a timed motion (a theme duration and easing) plays. - * Its velocity is in progress per second. + * A timed motion from `from` to `to` over `durationMs`, eased by the + * cubic-bézier `easing`: the curve a theme duration and easing play. + * + * `velocity` (units per second) is the velocity of a motion this one + * retargets. The curve starts at it and still ends on `to`, at rest and on + * time, so a new target keeps the current velocity. The carry is a Hermite + * term, v × duration × u(1 − u)², less the curve's own starting slope, so a + * motion from rest is the eased curve itself. */ -export function timedProgressCurve( - durationMs: number, - easing: BezierPoints, -): MotionCurve { +export function timedCurve({ + from, + to, + durationMs, + easing, + velocity, +}: { + from: number; + to: number; + durationMs: number; + easing: BezierPoints; + velocity?: number; +}): MotionCurve { const duration = Math.max(0, durationMs) / 1000; + if (duration === 0) { + return {duration, at: () => to, velocity: () => 0}; + } + const distance = to - from; const ease = cubicBezier(easing); - const at = (t: number) => - duration === 0 || t >= duration ? 1 : ease(Math.max(0, t) / duration); + const progress = (u: number) => ease(Math.min(1, Math.max(0, u))); + // The carry: the retargeted velocity over the whole duration, less what + // the curve's own starting slope already gives. + const startSlope = progress(1e-4) / 1e-4; + const carry = + velocity == null ? 0 : velocity * duration - distance * startSlope; + const at = (t: number) => { + const u = Math.min(1, Math.max(0, t / duration)); + return from + distance * progress(u) + carry * u * (1 - u) * (1 - u); + }; return { duration, at, velocity: t => { - if (duration === 0 || t >= duration) { + if (t <= 0) { + return velocity ?? 0; + } + if (t >= duration) { return 0; } - const t0 = Math.max(0, t - STEP); - const t1 = Math.min(duration, t + STEP); - return (at(t1) - at(t0)) / (t1 - t0); + const h = Math.min(1e-4, t / 2, (duration - t) / 2); + return (at(t + h) - at(t - h)) / (2 * h); }, }; } +/** + * `timedCurve` as a progress from 0 to 1, its velocity in progress per + * second: the timed counterpart of `progressCurve`. + */ +export function timedProgressCurve( + durationMs: number, + easing: BezierPoints, + {velocity}: {velocity?: number} = {}, +): MotionCurve { + return timedCurve({from: 0, to: 1, durationMs, easing, velocity}); +} + /** * The curve of `f(driver(t))`, its velocity by the chain rule: a channel * following a path as its progress moves, or a companion following any diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts index b78f7bb569c82..26137a9ce3b93 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -272,16 +272,16 @@ describe('createRectMorph', () => { } }); - it('presents on a theme timing: its duration and easing as given, from rest', async () => { + it('presents on a theme timing: its duration and easing as given, from the velocity it is given', async () => { const {element, morph} = setup(); const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); const easing = [0.2, 0, 0, 1] as const; - // A tap has no velocity: a timed present starts from rest even when - // one is given. + // A timed present keeps the velocity it is given (a new target keeps the + // current velocity) and still lands on time. morph.present(path, {duration: 300, easing}, {velocity: 6}); await playFrame(); expect(live(element, 'scale').length).toBe(1); - const progress = timedProgressCurve(300, easing); + const progress = timedProgressCurve(300, easing, {velocity: 6}); const start = clock; for (const t of [0, 0.05, 0.12, 0.2, 0.29]) { clock = start + t * 1000; diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index b7370b4bfbfd7..b53d97efb6c3f 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -15,29 +15,6 @@ * presentations share: an anchored menu, a popover, a context menu's * lifted preview, a photo's zoom, a sub-menu's parent card * - * A path (morphPath.ts) names the rect an element shows at each progress, - * and its curves put the element there with `translate` and `scale` about - * `transform-origin: 0 0` and a companion `opacity`, on the compositor. - * `createRectMorph` plays a path on its element - * (`present`) and back to the source on a target the caller gives - * (`dismiss`): a copy placed where the element stands, since the browser - * hides a closing popover at once, or the element itself. Each interrupts - * the other: the new motion starts from the rect the animator presents at - * that moment, on the path restarted there in its own law (`restartFrom`), - * with the presented velocity projected on it (progress.ts). Once a present settles nothing of the morph's is left inline: iOS - * Safari will not scroll a finger through a popover that keeps a `scale`, - * even at 1. - * - * A present or a dismissal takes its timing as a motion: the theme's - * duration and easing for a motion that a tap, a key or the program starts, - * or a spring for a gesture's release, which starts at the gesture's - * velocity. Only a spring carries a velocity into the motion. - * - * Reduced Motion is the animator's: on a coarse - * pointer a present lands at rest and fades in, a dismissal fades out where - * it stands, opacity only, in 200 ms; on any other pointer the change - * completes at once. - * * SYNC: When modified, update: * - /packages/core/src/motion/rectMorph.test.ts * - /packages/core/src/motion/README.md @@ -71,7 +48,6 @@ import type {MotionCurvePoints} from './roles'; import type {SpringDescription} from './spring'; import {resolveMotion} from './resolveMotion'; -// The paths the morph plays, where its callers have always imported them. export {edgeGrowPath, lerpPath, morphCurves} from './morphPath'; export type {MorphEnd, MorphPath, MorphRect, MorphScale} from './morphPath'; @@ -134,8 +110,9 @@ const INLINE_PROPERTIES = ['translate', 'scale', 'transform-origin'] as const; /** * How a present or a dismissal is timed. A motion that a tap, a key or the * program starts plays the theme's duration (ms) and easing. A gesture's - * release plays a spring, which starts at the gesture's velocity - * (`RectMorphPlayOptions.velocity`). + * release plays a spring. Either starts at the velocity it is given + * (`RectMorphPlayOptions.velocity`), so a new target keeps the current + * velocity. */ export type RectMorphMotion = | {readonly duration: number; readonly easing: MotionCurvePoints} @@ -146,9 +123,9 @@ export type RectMorphMotion = */ export interface RectMorphPlayOptions { /** - * Progress per second a spring starts with (a finger's release). By - * default the velocity of what is on screen, projected on the path. A - * timed motion starts from rest. + * Progress per second to start with (a finger's release). By default the + * velocity of what is on screen, projected on the path, so a motion that + * interrupts another keeps its velocity. */ velocity?: number; /** @@ -303,7 +280,8 @@ export function createRectMorph( release(target); }; - // Once a present settles on identity, nothing of the morph's stays inline. + // iOS Safari does not scroll a finger through an element that keeps a + // `scale`, even at 1. Clear it once the present settles. const retire = () => { const at = now(); const identity = @@ -320,11 +298,9 @@ export function createRectMorph( } }; - // A path with no opacity of its own gives the animator's Reduced Motion - // cross-fade nothing to read, and it would start the motion on the source - // rect and dip. Such a path says which way it goes, so the cross-fade - // lands at rest on a present and fades out in place on a dismissal; a caller's own `crossfadeAs` still wins, and - // `'move'` keeps the animator's dip for an element that only moves. + // A path with no opacity gives the Reduced Motion cross-fade nothing to + // read. Treat it as an entrance on a present and an exit on a dismissal. A + // caller's `crossfadeAs` wins, and `'move'` keeps the animator's dip. const playOptions = ( play: RectMorphPlayOptions, path: MorphPath, @@ -352,7 +328,7 @@ export function createRectMorph( ) => 'spring' in motion ? progressCurve(resolveMotion(on, motion.spring), {velocity}) - : timedProgressCurve(motion.duration, motion.easing); + : timedProgressCurve(motion.duration, motion.easing, {velocity}); const curvesFor = ( on: Element, From c99210aa453fef362051d82a59894263ad31886e Mon Sep 17 00:00:00 2001 From: Cindy Zhang <2781099+cixzhang@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:06:47 -0800 Subject: [PATCH 4/4] fix(rectMorph): a timed motion from rest plays its easing as given CodeBunny on #9769: with nothing in flight the morph handed timedCurve a velocity of 0, which it treats as a retarget and uses to cancel the easing's starting slope. Only a motion in flight hands its velocity on now; tests check a present from rest and a dismissal from a settled present against the easing itself. Header and comments shortened. Co-authored-by: Cindy Zhang --- packages/core/src/motion/rectMorph.test.ts | 31 ++++++++++++++++++ packages/core/src/motion/rectMorph.ts | 37 +++++++++++----------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts index 26137a9ce3b93..4a26cf964db2f 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -15,6 +15,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {createMotionAnimator} from './animator'; +import {cubicBezier} from './bezier'; import {progressCurve, timedProgressCurve} from './progress'; import { createRectMorph, @@ -289,6 +290,36 @@ describe('createRectMorph', () => { } }); + it('plays a timed present from rest on its easing as given', async () => { + const {morph} = setup(); + const path = lerpPath(SQUARE, BOX); + const easing = [0, 0, 0.58, 1] as const; + const ease = cubicBezier(easing); + morph.present(path, {duration: 250, easing}); + await playFrame(); + const start = clock; + for (const t of [0.02, 0.05, 0.1, 0.2]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(ease(t / 0.25)), 0.01); + } + }); + + it('plays a timed dismissal from a settled present on its easing as given', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}); + await settle(2000); + const easing = [0, 0, 0.58, 1] as const; + const ease = cubicBezier(easing); + morph.dismiss(path, {duration: 250, easing}, {target: copyOf(element)}); + await playFrame(); + const start = clock; + for (const t of [0.02, 0.05, 0.1, 0.2]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(1 - ease(t / 0.25)), 0.01); + } + }); + it('plays on an animator it is handed, and leaves it to its owner when disposed', async () => { // A zoom stage already drives the photo with its own animator (pinch, // pan, settle): the morph plays on it, so the two share the channels. diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index b53d97efb6c3f..d7564fb169638 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -10,10 +10,8 @@ * @output Exports `createRectMorph` (present and dismiss, each interrupting * the other), and re-exports the paths it plays (`lerpPath`, * `edgeGrowPath`, `morphCurves`) from morphPath.ts - * @position Motion foundation building block, internal to core: the one - * "grow out of a source rect, shrink back into it" that touch - * presentations share: an anchored menu, a popover, a context menu's - * lifted preview, a photo's zoom, a sub-menu's parent card + * @position Motion building block, internal to core: grow out of a source + * rect and shrink back into it * * SYNC: When modified, update: * - /packages/core/src/motion/rectMorph.test.ts @@ -250,9 +248,8 @@ export function createRectMorph( } }; - // Leaves the element as laid out: identity, and none of the morph's inline - // properties (an opacity only when a path animated it). At rest it needs no - // compositing layer, so the values go in without promoting it. + // At rest the element needs no compositing layer, so set the values without + // promoting it. const restoreElement = () => { animator.set( ownsOpacity @@ -317,14 +314,14 @@ export function createRectMorph( : null), }); - // A spring goes through `resolveMotion` on the element that plays it: a - // touch motion role resolves to its table value and reads no style, and a - // caller's bare spring comes back as given. A timed motion is the caller's - // duration and easing, already resolved from the theme. + // A spring goes through `resolveMotion`. A motion role resolves to its + // table value, and a bare spring comes back as given. `velocity` is + // undefined when nothing was moving: a timed motion then plays its easing + // as given. const progressFor = ( on: Element, motion: RectMorphMotion, - velocity: number, + velocity: number | undefined, ) => 'spring' in motion ? progressCurve(resolveMotion(on, motion.spring), {velocity}) @@ -335,7 +332,7 @@ export function createRectMorph( path: MorphPath, motion: RectMorphMotion, play: RectMorphPlayOptions, - velocity: number, + velocity: number | undefined, ) => { const progress = progressFor(on, motion, velocity); const fade = @@ -348,7 +345,7 @@ export function createRectMorph( const from = exiting ?? (presenting != null && box != null ? {animator, box} : null); let start = path; - let velocity = play.velocity ?? 0; + let velocity = play.velocity; let opacity: number | undefined; if (from != null) { opacity = from.animator.get('opacity', at); @@ -394,9 +391,14 @@ export function createRectMorph( opacity, 'source', ); + // A settled present is not moving: only a motion in flight hands its + // velocity on. + const inFlight = exiting != null || presenting != null; const velocity = play.velocity ?? - presentedProgressVelocity(from.animator, channelsOf(back), at); + (inFlight + ? presentedProgressVelocity(from.animator, channelsOf(back), at) + : undefined); // The same copy handed again continues on it: it keeps its animator, and // its release runs once, when the dismissal still running ends. Any other // target ends the dismissal in flight first. @@ -416,9 +418,8 @@ export function createRectMorph( ownsOpacity ||= back.opacityAt != null; } else { // The element is hidden as it closes: leave it as laid out, and let go - // of the layer its presentation promoted, in this task. Kept, the - // promotion would end its linger after the element's last motion: a - // style write on an element that no longer paints, landing inside the + // of the layer its presentation promoted, in this task. Otherwise the + // promotion's linger ends later, with a style write inside the // dismissal's frames. The next present attaches the element again. A // handed-in animator stays on the element: its owner still drives it. restoreElement();