Skip to content

feat(core): one rect morph for touch presentations that grow out of a source rect - #9769

Draft
cixzhang wants to merge 4 commits into
motion/morph-pathfrom
motion/rect-morph
Draft

cixzhang wants to merge 4 commits into
motion/morph-pathfrom
motion/rect-morph

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #8329 by @vjeux, who stays the author of this commit. Stacked on #9764 (the morph paths) and based on its branch, so it retargets to main when #9764 merges. #8329's branch carries the open chain under #8909, #8340 and #8360, about 30,000 lines. This pull request is #8329's own change, rebuilt on main and ported to main's engine (createMotionAnimator; #8340 was absorbed into main).

Maintainer impact

Five touch presentations grow a surface out of what the finger touched and shrink it back: menus, Popover, the lifted preview, Lightbox's zoom, and a sub-menu's parent card. Without one module, each would write its own version and get interruptions and Reduced Motion right on its own.

Intended invariant

No behavior change: the module is internal, and no component imports it yet.

  • Timing follows the owner's rule for touch motion, as ruled by Specs & Decisions. present(path, motion) and dismiss(path, motion, {target}) take a RectMorphMotion: either {duration, easing}, the theme's tokens resolved by the caller, for a motion a tap, a key or the program starts, or {spring} for a gesture's release. Either starts at the velocity it is given, by default the presented velocity, so a new target keeps the current velocity (spec:AST-094 FR6). The type steers callers to the rule. The doc example shows a tap-opened menu on the theme's entrance timing.
  • One timed curve. timedCurve in progress.ts is the curve solveAnimatedPageScroll played, moved there with its retargeted velocity. The pager and the morph both call it; the pager's curve is unchanged.
  • architecture:react-component-runtime INV12 says the size-change glide plays through the shared rectangle morph once Core has it. Now that Core has it, INV12 records an implementation gap: the glide still plays its own compositor translate. Moving it onto the morph is a separate, later pull request.

Change

  • motion/rectMorph.ts, internal: createRectMorph presents along a path (lerpPath, edgeGrowPath, or a caller's own) and dismisses back onto a target the caller gives (a copy, or the element). Each interrupts the other from the rect on screen. Nothing stays inline once a present settles, and Reduced Motion is the animator's.
  • motion/progress.ts: timedCurve (moved from paging.ts) and timedProgressCurve, the progress a timed motion plays, keeping a retargeted velocity. motion/paging.ts: solveAnimatedPageScroll calls timedCurve.
  • architecture:react-component-runtime INV12: the implementation-gap sentence above.
  • Tests: the unit suite (35 cases, including a timed present that ignores a given velocity) and the browser interaction spec. A README row and a [chore] changeset.

Evidence

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run packages/core/src/motion packages/core/src/hooks/usePager.test.ts: 994 passed.
  • pnpm exec tsc -p packages/core --noEmit: clean. Strict ESLint on the changed files, pnpm check:knowledge and pnpm check:changesets: pass.
  • The browser interaction spec (rectMorph.interaction.browser.spec.ts) runs in CI's interaction job.

Writing rubric results

Record changed: architecture:react-component-runtime INV12, one implementation-gap sentence.

Item Result
R1 Pass: the sentence states the system now (the glide plays its own translate); no plan or history.
R2 Pass: no research in the record.
R3 Pass: no pull request, issue or commit as evidence.
R4 Pass: no rejected alternative.
R5 Pass: no # number, /pull/ or /issues/.
R6 Pass: no open question.

… 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 <cindyxz@meta.com>
@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 4, 2026 3:14am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 131ca14

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 3, 2026
@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 4, 2026, 3:25 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the rect morph's timing rule, and one fix is needed.

rectMorph.ts documents and implements a rule no current record states (a present or dismissal that a tap, a key or the program starts plays the theme's duration and easing from rest, and only a gesture's release plays a spring), which contradicts AST-094 FR31, where a touch menu grows on springs.menuOpen, and FR6, where a new target keeps the current velocity. An engineering owner either records the rule in AST-094 (amending FR31 and FR6) and approves that edit by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or the morph carries the presented velocity into a timed retarget and its example and changeset follow FR31.

Blocking

  • A dismissal onto a copy calls attach(null) on a handed-in animator (rectMorph.ts:448), and neither reset() nor dispose() attaches it again, so the owner's own set and play stop reaching the element; attach a handed-in animator again and add a test that the owner's write lands after a copy dismissal.

Everything else matches the current records, and the motion suites, the typecheck and the Chromium interaction spec pass.

[Automated review]

Comment thread packages/core/src/motion/rectMorph.ts Outdated
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This detaches the animator even when the caller handed it in (createRectMorph(element, {animator})). reset() and dispose() never attach it again; only the next present does. After a close onto a copy, the owner's stage.set and stage.play stop reaching the element, and after dispose() they never reach it again. That breaks the JSDoc's promise that dispose leaves the animator to its owner. Attach a handed-in animator to the element again when the copy's dismissal ends and in reset(). Then test that the owner's write lands after a copy dismissal and dispose(); the existing test reads only stage.get('x').

Comment thread packages/core/src/motion/rectMorph.ts Outdated
) =>
'spring' in motion
? progressCurve(resolveMotion(on, motion.spring), {velocity})
: timedProgressCurve(motion.duration, motion.easing);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A timed motion drops the velocity that present and dismiss just projected, so a timed present that reverses a running dismissal starts from rest. AST-094 FR6 says a new target MUST keep the current velocity. Whether timed touch motion may start from rest is the open owner decision on this pull request. If the rule is not adopted, carry the projected velocity into the timed curve.

Comment thread packages/core/src/motion/rectMorph.ts Outdated
Comment on lines +31 to +39
* 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): the file header runs 40 lines. These two paragraphs repeat the RectMorphMotion JSDoc and the Reduced Motion behavior the animator owns. Keep @input, @output, @position and SYNC:. Move the iOS quirk next to retire: "iOS Safari does not scroll a finger through an element that keeps a scale, even at 1. Clear it once the present settles."

Comment thread packages/core/src/motion/rectMorph.ts Outdated
import type {SpringDescription} from './spring';
import {resolveMotion} from './resolveMotion';

// The paths the morph plays, where its callers have always imported them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): no caller imports these paths from here yet, so "where its callers have always imported them" does not hold. Delete the comment; the re-export explains itself.

Comment thread packages/core/src/motion/rectMorph.ts Outdated
Comment on lines +323 to +327
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): two sentences carry four topics, and one runs past 25 words. Suggested: "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."

…opy 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 <cindyxz@meta.com>

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the rect morph's timing rule, and two fixes are needed whatever that decision is.

The morph documents and implements a rule no current spec states: a present or dismissal that a tap, a key or the program starts plays the theme's duration and easing from rest, and only a gesture's release plays a spring. That contradicts AST-094 FR31, where a touch menu grows on springs.menuOpen, and FR6, where a new target keeps the current velocity. To settle it, an engineering owner either records the rule in AST-094 (amending FR31 and FR6) and approves that edit by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or the morph carries the presented velocity into a timed retarget and its example and changeset follow FR31.

Blocking

  • timedProgressCurve (packages/core/src/motion/progress.ts:79-99) is a second copy of the timed curve solveAnimatedPageScroll already provides in paging.ts, without that curve's velocity carry, so move that curve with its optional velocity into progress.ts and call it from the pager and the morph.
  • The INV12 edit needs the ## Writing rubric results section in the description and should end at "its own compositor translate.)" instead of the plan "it moves onto the morph next", as the knowledge-contracts writing rules require.

The handed-in animator now stays on the element through a copy dismissal, reset() and dispose(), and the motion suites, the typecheck and the Chromium interaction spec pass.

[Automated review]

Comment thread packages/core/src/motion/progress.ts Outdated
* `easing`: the curve a timed motion (a theme duration and easing) plays.
* Its velocity is in progress per second.
*/
export function timedProgressCurve(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (one shared path). solveAnimatedPageScroll in paging.ts already plays this curve: from 0 to 1 with the same duration and easing the two agree at every sample. It also keeps the velocity of the move it retargets ("A new target keeps the current velocity"), which this copy cannot, so the pager conforms to AST-094 FR6 where the morph drops the velocity. Move that curve, with its optional velocity, into progress.ts as the one timed progress curve and call it from paging.ts and rectMorph.ts.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. The knowledge-contracts writing rules ask a record to describe the system in the present tense, not the project that produced it, so end the note at "its own compositor translate.)" and drop "it moves onto the morph next". The same rules ask the description to report ## Writing rubric results for a record change; it has none yet.

…er 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 <cindyxz@meta.com>
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec e3a43d0

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the rect morph's timing rule, and one fix is needed whatever that decision is.

The RectMorphMotion doc, its example and the changeset say that a present or dismissal a tap, a key or the program starts plays the theme's duration and easing, and only a gesture's release plays a spring; no current spec states that rule, and it contradicts AST-094 FR31, where a menu opened under a finger grows on springs.menuOpen, and FR4, where touch motion reads its named roles. To settle it, an engineering owner either records the rule in AST-094 (amending FR31 and FR4) and approves that edit by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or the doc, the example and the changeset name the presentation's motion roles instead.

Blocking

  • A timed present or dismissal that starts at rest is handed a velocity of 0 (packages/core/src/motion/rectMorph.ts:351 and :397-399), which timedCurve treats as a retarget and uses to cancel the easing's starting slope, so a 250 ms present on the entrance curve is 48 px wide at 50 ms where the easing gives 114 px; pass no velocity when nothing is in flight, and test a present from rest on an easing that does not start flat, such as [0, 0, 0.58, 1], against the easing itself.

A timed retarget now keeps its velocity, the duplicated timed curve is gone, and the motion and pager suites and the typecheck pass.

[Automated review]

Comment thread packages/core/src/motion/rectMorph.ts Outdated
const from =
exiting ?? (presenting != null && box != null ? {animator, box} : null);
let start = path;
let velocity = play.velocity ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. With nothing in flight this passes a velocity of 0, and timedCurve treats any defined velocity as a retarget: it subtracts the easing's starting slope, so a timed present no longer plays the easing it is given. On timing.curves.entrance a 250 ms present from a 22 px square is 48 px wide at 50 ms where the easing gives 114 px. A dismissal from a settled present does the same (lines 397-399 project 0). Leave the velocity undefined when nothing moves, keep the projected one for an interruption, and test a present from rest on [0, 0, 0.58, 1] against the easing itself.

Comment on lines +216 to +217
* // A tap opened the menu: the theme's entrance duration and easing.
* morph.present(path, {duration: enterMs, easing: enterEasing});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending an engineering owner's decision: AST-094 FR31 has a menu opened under a finger grow on springs.menuOpen, and FR4 has touch motion read its roles. This example (a tapped menu on the theme's entrance duration and easing) and the rule at lines 111-115 need either an AST-094 amendment or the menu's role here.

Comment thread packages/core/src/motion/rectMorph.ts Outdated
Comment on lines +13 to +16
* @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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): the header runs 18 lines, past the record's limit of about 12. A one-line @position keeps it short, for example: 'Motion building block, internal to core: grow out of a source rect and shrink back into it.'

Comment thread packages/core/src/motion/rectMorph.ts Outdated
Comment on lines +253 to +255
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): the first sentence restates restoreElement. Keep only the reason: 'At rest the element needs no compositing layer, so set the values without promoting it.'

Comment thread packages/core/src/motion/rectMorph.ts Outdated
Comment on lines +320 to +322
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): the first sentence runs 37 words with three topics. Suggested: 'A spring goes through resolveMotion. A motion role resolves to its table value, and a bare spring comes back as given.'

Comment thread packages/core/src/motion/rectMorph.ts Outdated
Comment on lines +419 to +421
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (INV29): this sentence runs 28 words. Suggested: 'Otherwise the promotion's linger ends later, with a style write inside the dismissal's frames.'

}
});

it('presents on a theme timing: its duration and easing as given, from the velocity it is given', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test recomputes its expectation with timedProgressCurve and uses [0.2, 0, 0, 1], which starts flat, so it passes whether or not the easing's start survives (component-test-sufficiency INV6). Add a present from rest on [0, 0, 0.58, 1] checked against cubicBezier(easing).

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 <cindyxz@meta.com>

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the rect morph's timing rule; nothing else blocks.

  • The RectMorphMotion doc (packages/core/src/motion/rectMorph.ts:109-115), its example (:214-215, a tapped menu on the theme's entrance duration and easing) and the changeset say that a present or dismissal a tap, a key or the program starts plays the theme's duration and easing, and only a gesture's release plays a spring; no current spec states that rule, and it contradicts AST-094 FR31, where a menu opened under a finger grows on springs.menuOpen and shrinks on springs.menuClose, and FR4, where touch motion reads the role named for its use.

To settle it, an engineering owner either records the rule in AST-094 (amending FR31 and FR4) and approves that edit by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or the doc, the example and the changeset name the presentation's motion roles instead. The earlier /approve-spec comments name earlier heads, so the INV12 note needs that same exact-head approval.

A timed motion from rest now plays the easing it is given, and the new tests fail without the fix. Non-blocking: the description's Testing line still says the unit suite has 35 cases including a timed present that ignores a given velocity; rectMorph.test.ts has 24 cases and the one at line 276 proves the opposite.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — c99210aa Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants