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..2064edfb7ab6a 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 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/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 4de3fa57df8de..1d3535ac1ca66 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,31 @@ 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('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);
+ expect(curve.at(0)).toBe(1);
+ });
+});
diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts
index 8b97de255de90..bd2cfdb68a938 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), `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)
@@ -39,6 +40,7 @@
*/
import type {Animator, MotionCurve, MotionProperty} from './animator';
+import {cubicBezier, type BezierPoints} from './bezier';
import {
SETTLING_EPSILON,
solveSpring,
@@ -69,6 +71,73 @@ export function progressCurve(
};
}
+/**
+ * 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 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 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 (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);
+ },
+ };
+}
+
+/**
+ * `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
new file mode 100644
index 0000000000000..4a26cf964db2f
--- /dev/null
+++ b/packages/core/src/motion/rectMorph.test.ts
@@ -0,0 +1,754 @@
+// 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 {cubicBezier} from './bezier';
+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 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 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, {velocity: 6});
+ 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 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.
+ 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('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]});
+ 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..d7564fb169638
--- /dev/null
+++ b/packages/core/src/motion/rectMorph.ts
@@ -0,0 +1,481 @@
+// 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 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
+ * - /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';
+
+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. 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}
+ | {readonly spring: SpringDescription};
+
+/**
+ * How a present or a dismissal plays.
+ */
+export interface RectMorphPlayOptions {
+ /**
+ * 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;
+ /**
+ * 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);
+ }
+ };
+
+ // At rest the element needs no compositing layer, so set the values 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);
+ };
+
+ // 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 =
+ 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 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,
+ 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`. 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 | undefined,
+ ) =>
+ 'spring' in motion
+ ? progressCurve(resolveMotion(on, motion.spring), {velocity})
+ : timedProgressCurve(motion.duration, motion.easing, {velocity});
+
+ const curvesFor = (
+ on: Element,
+ path: MorphPath,
+ motion: RectMorphMotion,
+ play: RectMorphPlayOptions,
+ velocity: number | undefined,
+ ) => {
+ 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;
+ 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',
+ );
+ // 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 ??
+ (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.
+ 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. 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();
+ if (given == null) {
+ 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();
+ }
+ },
+ };
+}