Skip to content

feat(core): the rect morph's paths, the rect a grow-out presentation shows at each progress - #8909

Draft
vjeux wants to merge 1675 commits into
meta:mainfrom
vjeux:uikit/f1-morph-path
Draft

vjeux wants to merge 1675 commits into
meta:mainfrom
vjeux:uikit/f1-morph-path

Conversation

@vjeux

@vjeux vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Depends on #8340 (the animator) and #8497 (the progress module), on #8360. Review only this PR's change: vjeux/astryx@e0536fa...62e949d.

Why

Five touch presentations grow a surface out of what the finger touched and shrink it back: menus (#8080), Popover (#8187), the lifted preview (#8201), Lightbox's zoom (#8231) and a sub-menu's parent card (#8220). Each needs the same geometry: the rect the surface shows at each point of its motion, and how an interrupted motion continues.

What led to it

This comes from the work to make Astryx's touch components behave as native iOS ones do: a popover grows out of the point of its edge facing its source, widening before it lengthens, and a menu grows along a straight line out of its control. #8329, stacked on this, plays the geometry.

Options and tradeoffs

  • Each presentation computes its own rects: five copies of the same geometry.
  • Animate width, height, top and left: simple rects, but layout and paint on every frame on a phone.
  • A path names the rect at each progress, and translate and scale about the top-left corner put the element there: compositor properties only, at the cost of a transform computed per frame.

Why this choice

A transform about the top-left corner fits any path, including UIKit's edge growth; a computed origin fits only paths with a fixed point. Restarting an interrupted motion in the path's own law keeps a turning popover's edge growth, where a straight restart would bend it.

What changes

  • lerpPath: a straight line between two rects, with an optional companion opacity.
  • edgeGrowPath: UIKit's popover growth (iOS 26.2). At progress p its scale is s = s₀ + (1 − s₀)·p across the axis away from the source and s·(L₀ + (1 − L₀)·p) along it, and its opacity is p.
  • Each path's restartFrom: the path from the rect on screen to either end, in its own law.
  • morphCurves: the x, y, scaleX, scaleY and opacity curves that put the element on the path's rect as a progress curve moves.
  • All in motion/morphPath.ts, internal to core; no component uses them yet.

How to check it

  1. Run pnpm exec vitest run packages/core/src/motion/morphPath.test.ts.
  2. Both laws land exactly on their source at 0 and on the box at 1, so a path that ends on its box ends on identity.
  3. A restarted edge growth continues from the scale and length on screen.

Evidence

  • morphPath.test.ts covers both laws, restarts, both scale modes and the curves, and each presentation's model within 0.5 px at every progress.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

cixzhang and others added 30 commits October 26, 2026 15:12
…ose finds it

The unmount kept the node of the first attach(null) in the task, so after
attach(null), attach(next), attach(null) it landed a waiting Reduce Motion
move on the node already replaced, and next stayed at its dip's opacity.
A dispose right after attach(null) landed on no element, leaving the
detached node at opacity 0. The latest detached node is now kept while its
unmount is pending; the unmount and dispose both land there. Two tests
fail on the previous head.
…that Effect

The isEnabled sync's silence covered recognizer listeners but not
usePressTracker: a press-tracked control inside an element whose double tap a
rerender turns off heard touchUpInside (and its un-highlight) from that
layout Effect (react-component-runtime INV2). Silencing it would leave the
control highlighted, so the tracker's callbacks that arrive during a sync
run in a microtask just after it, in order: the press still ends and
un-highlights, outside the Effect.

Tests, failing before (the events arrived inside the Effect): a press
waiting on an ancestor double tap, alone and with a single tap waiting on
it, reports nothing from the Effect that turns the double tap off, then
touchUpInside and the un-highlight just after.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ile it ran

A page opened after startCompositorTrace and closed before stop() was still
credited: its frames are in the browser-wide record, and neither check saw
it. The record now watches for new pages from start to stop, in the page's
own context and through the browser's target discovery in every context,
and stop() throws if one opened. Negative controls: a page opened and
closed mid-record, and one in another context, both make stop() refuse;
alone, it records.
…rns to joins that dip

Under Reduce Motion a call that names an opacity while an earlier dip's
move still waits to land took the dip branch. So a call naming the opacity
the dip already returns to, with no travel of its own, opened a second dip
from where the first had got to: mid fade out the element faded out twice,
and in the dip's own task the browser got a second, identical fade. A swipe
row's release does exactly that: its move's layer plays the travel as a
dip, then its state layer lands opacity 1 on the same element in the same
task. Such a call now joins the waiting dip: the planner returns joins, the
fade goes on as it was, the call takes the landing over and settles when
the dip ends, and the call it replaces lands with it, each value keeping
the call whose move it is. A call that names travel, or another opacity,
plans as before. Four tests (the planner's, mid dip, in the dip's task,
and a release played then landed) fail on the previous code.
…aiting dip returns to joins that dip; the internal changesets stop counting their users) into the presented start
@eslint-react/naming-convention-ref-name: the test component added in
07b3383 held its refs as outer and inner, two lint errors that failed
CI's ESLint step. Renamed outerRef and innerRef; no other change.
Inside a trace, waitForAnimation and watch.started() read the element through
handles, which find no element rather than wait for one, so a wait on an
element the page removed rejected with "no animation started on the
element" instead of "the element is not in the document", unlike the
untraced path. At its deadline, a traced wait with no element now rejects
as missing. Traced twin of the missing-element self-test; it fails on the
previous code.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…med or not

The planner's comment on the cross-fade's length said it is not a theme
role and no theme lengthens it. Under spec:AST-094 DEC-11 no motion role is
themeable: the fade's length is no role at all, and nothing lengthens it.
A promotion keeps the stylesheet's will-change reasons beside its own, and
read them with getComputedStyle as it started: in a drag's first move, that
forces a style recalculation inside the input handler. The stylesheet's
value is now read in a task of its own after the element is attached, and
again after each demotion, so a promotion uses what was read. One that
comes before that read (an element attached in this task that moves at
once) still reads it then. A test that counts style reads in the promoting
task fails on the previous code; one for the element that moves at once
passes on both.
…orts after that Effect

react-component-runtime INV2 keeps caller callbacks out of Effects, and the
isEnabled sync and a replaced node's detach did it by silencing every
listener for the arena pass they start. A single tap waiting on a double
tap recognizes in that pass when the double tap is turned off or its node
replaced, so its caller never heard the tap. The hook cases expect that
caller to hear it once, just after the Effect, reading the recognized
state; the arena case expects GestureArena.deferResolution to fail the
stopped recognizer at once and resolve the recognition waiting on it in a
pass a microtask later. Fail on this head.
… edges only in their edge zone, an off-zone edge pan decides at 10 px) into the experimental touch layer
…aims an axis only in its edge zone; the promotion's style read in its own task; the planner's DEC-11 line) into the presented start
The isEnabled sync and a replaced node's detach silenced every listener for
the arena pass they start (react-component-runtime INV2), so a single tap
waiting on a double tap they stopped recognized in that pass and was never
reported. Delaying the listener call alone would hand it a recognizer the
arena has already reset to possible.

GestureArena.deferResolution(body) runs code outside input that stops
recognizers without resolving what the stops unblock (the recognitions
waiting on them, held touches, resets); a pass a microtask later, at the
last event's time, resolves it, or the next input does first. Both hook
syncs run inside it, reaching the arena through recognizerInternals: the
stopped recognizer's own cancel stays silent, and a recognizer that
required it to fail recognizes outside the Effect, where its listener
reads the recognized state. The README and the isEnabled option say so.
…rred unmount runs

It read will-change right after attach(null), before the unmount's
microtask, so it passed while that microtask could still promote the
element and leave timers behind. It now awaits the microtask, then checks
will-change and that no timer is left. It fails on meta#8521's earlier head
(97436b2), before the unmount cleared what the landing rebuilt.
…ith one passive listener and press-time document listeners

family:action-groups FR5 (and the AST-094 FR19/FR24 exception proposed with
it): the behavior applies to every pointer, mouse and pen included, so a
group must hear a press where the primary pointer is fine, where the host
otherwise registers nothing. attach() takes { actionGroup: true }: under a
fine pointer the group element gets one passive pointerdown listener; a
press on it adds document pointermove, pointerup and pointercancel
listeners (and window blur, plus the click listener for the click the lift
owes), and they go at the release. Only the group's own recognizers and
consumer take that press. Under a coarse pointer the host's usual listeners
serve the group, as every other recognizer.

Tests (failing first): idle under a fine pointer, only the group's one
passive pointerdown and nothing for a recognizer that is not a group; a
press adds the document listeners and the release removes them; a
recognizer that is not a group on the press path takes no part; detach
removes the group listener, and a coarse pointer keeps the usual
listeners.
…tion) as a dependency

The action group's press filter reads acceptsTouchInput through internals(),
as the rest of the gesture host does now that the getter is protected.
…on) into the host line

The host keeps both fine-pointer exceptions: perEventPointer (this PR's
press-only tracker) and actionGroup (meta#8309's). Conflicts were the two
option docs, useGestureRecognizer's option read beside meta#8309's deliver
queue, and two test files' neighbouring additions.
…an isEnabled sync) into the arena fixes

This PR keeps usePressTracker in its own module, so meta#8309's deliver queue
(a press tracker's callbacks wait for a microtask while a gesture hook's
isEnabled sync runs) moves into usePressTracker.ts, and the sync depth both
hooks read moves into useAttachment.ts (syncingGestures,
isSyncingGestures). Gestures, utils and hooks 1432/1432; tsc clean.
vjeux added 12 commits October 29, 2026 10:17
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
Takes meta#8360's head, which removes a document's or shadow root's
touch-action rules with its last marked element and names the build
changeset's treeshakeCompensation default, in place of 1e1431f.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
Folds this pull request onto the canonical gesture foundation, meta#8360's
head, which carries meta#8076, and onto main, in place of the old meta#8076 and
harness copies it carried. The one conflict is the motion README's Files
table: meta#8360's table, with this pull request's progress.ts row added
after presentation.ts.
…task ended at once, however late the first read

WebKit under load from other tasks: the call's task ends at once, another
task holds the main thread, and the browser commits the animation after
it, dated at the call. Nothing in the call's task delayed it, but the
first read still moved its start to the read, about 95 ms late on a
loaded box, so whatever chains on its end (the second half of a Reduce
Motion cross-fade, a cancel fading on the previous fade's end) ran that
much late and the cross-fade missed design:reduced-motion DR2's 200 ms.
Fails now: the start moves to 1100.
e7b0676 moved a call's start from rest to its first read whenever
WebKit had dated it more than two frames before that read. Under load
from other tasks the first read comes late with nothing in the call's
task delaying it, so the start moved about 95 ms on a loaded box, and
whatever chained on the call's end ran that late: the second half of a
Reduce Motion cross-fade, so SearchField's cancel missed DR2's 200 ms.
The call's task now records when it ended, at its first microtask (or a
read in those microtasks is that time), and only a call whose own task
ran past the call by more than two frames, the block e7b0676 fixed,
moves its start; a read other tasks delay keeps the browser's start.
…s its start past the block

Core Animation dates an animation at the commit, when the calling task
ends, however short the block before it. A 20 ms block in the call's task,
with WebKit's start dated at the call, left the start at the call: the
two-frame bound skipped it. Fails now.
… task, with no threshold

Core Animation dates an animation at the commit, when the calling task
ends, whatever the block's length. The call's task already records when
it ended, at its first microtask, so a start the browser dated before that
(WebKit's last frame time before the task) now moves to it: a task that
ended at once leaves the start at the call, a blocked one moves it past
the block, however short, and a read other tasks delay changes nothing.
The two-frame bound, which told a read's own frame from a block before the
task's end was known, goes, and the start no longer moves to the read.
The base the rect morph stacks on: the animator and the progress module,
merged with no conflict.
…shows at each progress

Menus, Popover, the lifted preview, Lightbox's zoom and a sub-menu's
parent card grow a surface out of what the finger touched and shrink it
back, and each needs the same geometry. morphPath.ts names it: lerpPath,
a straight line between two rects with an optional companion opacity;
edgeGrowPath, UIKit's popover growth out of a point on its edge (iOS
26.2); each path's restartFrom, how an interrupted motion continues in
its own law; and morphCurves, the translate, scale and opacity curves
that put an element laid out in the path's box on its rect. travelAt is
exported for the rect morph's player. Internal to core; the tests check
both laws, their restarts and the curves at every 1/120 s, and that each
presentation's model is the same rect at every progress within 0.5 px.
@vercel

vercel Bot commented Oct 29, 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 Oct 30, 2026 12:33am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 30, 2026, 12:51 AM UTC

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

@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.

Requesting changes: the morph-path module looks correct and fits the shared rectangle morph that architecture:layer-runtime INV15 describes, but this branch's diff against main is 28,591 authored lines, 27,886 of them from the still-open #8340, #8497 and #8360. Please land those first, then rebase so this PR contains only its own 708-line commit (morphPath.ts needs only #8497's progress.ts). Separately, the headers in morphPath.ts (lines 15, 24 and 221) and morphPath.test.ts (line 12) point at rectMorph.ts, rectMorph.test.ts and "law M1", none of which exist at this head.

[Automated review]

Comment thread packages/core/src/motion/morphPath.ts Outdated
* of "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); the rect
* morph (rectMorph.ts) plays 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.

rectMorph.ts does not exist at this head; it arrives with #8329. Either describe the player without naming the file, or add the reference together with #8329. The same applies to line 221 here and to morphPath.test.ts line 12.

Comment thread packages/core/src/motion/morphPath.ts Outdated
* axis to the rect (`'axes'`) or both by the rect's width (`'uniform'`, which
* keeps the element's aspect), and a companion opacity follows a progress of
* its own or the same one. Only `translate`, `scale` and `opacity` move, on
* the compositor (law M1).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No document at this head defines "law M1". Cite spec:AST-094 FR5 (touch motion animates only transform, translate, scale, rotate or opacity) instead.

…this head

The headers of morphPath.ts and its test named rectMorph.ts and
rectMorph.test.ts, which arrive with the pull request stacked on this
one, and cited 'law M1', which no document defines. They now describe the
player without naming its file and cite spec:AST-094 FR5, which keeps
touch motion to translate, scale and opacity on the compositor.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
Takes meta#8909's header fix: the paths' headers name nothing missing at that
head and cite spec:AST-094 FR5.

@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.

Requesting changes: the morph-path headers now name nothing missing at this head, and the module still fits the shared rectangle morph that architecture:layer-runtime INV15 describes, but the diff against main is still 28,591 authored lines, 27,886 of them from the open drafts #8340, #8497 and #8360. Please land those first, or at least #8497's progress module (the only part morphPath.ts imports), then rebase so this PR contains only its own 708 lines. A small follow-up for the carried stack: animator.interaction.browser.spec.ts line 9 still cites the undefined "law M1".

[Automated review]

* esbuild onto a blank fixture page; the interaction harness's screencast,
* busy-main-thread and trace instruments; Chromium and WebKit on the phone
* profile
* @output Proof that the animator's motion runs on the compositor (law M1):

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 header still cites "law M1", which no document at this head defines; morphPath.ts now cites spec:AST-094 FR5 for the same rule. This file arrives with the carried stack, so the fix belongs in its own pull request.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
Takes meta#8223's head, which carries meta#8187, meta#8342 and meta#8587 on main, and
meta#8329's, which carries meta#8909, meta#8340 and meta#8497 on meta#8360. They merge with no
conflict, and nothing in this pull request's own files needed resolving.
@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 31, 2026

This branch was successfully deployed

1 active deployment
Preview — d1b883ec Deployed Oct 30, 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