Skip to content

chore(sandbox): preview only, do not merge, the ContextMenu lift on and off - #11198

Draft
vjeux wants to merge 141 commits into
meta:mainfrom
vjeux:preview/contextmenu-lift-shapes
Draft

vjeux wants to merge 141 commits into
meta:mainfrom
vjeux:preview/contextmenu-lift-shapes

Conversation

@vjeux

@vjeux vjeux commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Preview only: #8201's head d096b78 plus one sandbox page, /sandbox/pages/context-menu-lift-shapes/, for a check on a real iPhone. Do not merge; it closes after the check.

The page lists message rows, each with the same ContextMenu. ?lift=L0 keeps the lift as #8201 has it, without the lift module's uncalled dismissSurface, which changes nothing a person sees. ?lift=L2 turns the lift off for the page, so the menu opens where the finger landed, as at #8133's head. A preview-only check in the touch layer reads the page's switch.

Testing

At 61de1b5: Prettier and ESLint pass on the changed files, core's source typecheck passes, and the sandbox build exports and verifies the page (845 routes).

The lift's browser spec in Chromium: 15 passed, 1 failed. The failing case, "the menu grows out of the press into its box", checks the close through dismissSurface, which this preview removes. Three other cases call that close as one step, which is now a no-op.

vjeux and others added 30 commits November 3, 2026 06:35
progressCurve, mapProgress, mapRange and presentedProgressVelocity in
motion/progress.ts, internal, one velocity unit (progress per second).
Rebuilt on main from the pull request's own change, without the open
dependency chain it no longer needs.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…ressCurve

CodeBunny on meta#9755: springProgress solved the same spring as progressCurve.
It now reads progressCurve (remaining = 1 - progress), so one implementation
remains. Same curve, same settle epsilon (0.001) and duration.
…shows at each progress

lerpPath, edgeGrowPath, each path's restartFrom and morphCurves in
motion/morphPath.ts, internal. Pure geometry over a progress curve: the
caller chooses the timing. Rebuilt on the progress module from the pull
request's own change, without the open chain it no longer needs.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
# Conflicts:
#	packages/core/src/motion/README.md
… 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>
…opy dismissal

CodeBunny on meta#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>
…er and the morph

CodeBunny on meta#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>
CodeBunny on meta#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>
The type's doc, the example and the changeset name no rule of their own
about which input plays which timing: each play takes the timing the
adopting component's motion record names (AST-094 FR45).

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
A finger held on the trigger opens the menu on UIKit's timeline: the
hold commits at 400 ms and the menu shows at 735 ms. One outside-press
watch closes the menu for every pointer, and a touch closes it as it
begins to pan.

It carries this pull request's own diff from 6d7fceb, replayed on
main after meta#9814's squash, and folds these 12 commits (the history of
6d7fceb has each one):

- chore(motion): ContextMenu's touch timeline and press curve, measured
  on iOS 26.2
- test(ContextMenu): a held finger opens the menu on iOS 26's timeline,
  and a touch outside closes it as it pans
- feat(ContextMenu): open from a held finger on iOS 26's timeline, with
  no page lock
- docs(ContextMenu): FR11 cites AST-094 FR45 for the hold's measured
  moments
- test(ContextMenu): the long-press a11y contract follows the touch
  model
- docs(ContextMenu): the touch changeset and docs name their condition,
  not every touch device
- test(Layer): the outside-press watch closes a layer at the press for a
  mouse or a pen
- feat(Layer): the outside-press watch closes a layer at the press for a
  mouse or a pen
- refactor(ContextMenu): one outside-press watch closes the menu for
  every pointer, and its mousedown Effect goes
- docs(ContextMenu): split long file-header sentences
- test(Indicator): the ContextMenu box-hold case pins the popover menu
- test(ContextMenu): the long-press spec note keeps to 20 words
The hold's timer only sets ContextMenu's state, so React mounts and
shows the menu in its next scheduled task. A WebKit CI run showed it
at 758 ms, past the 735 ms proof's 20 ms. The proof now also wants the
menu to show inside the timer task that decides it, and that task at
the deadline counted from the touch. The page's stamps record the
first show only.
The hold's timer opened the menu with a plain state update, which waits
for React's next scheduled task: the menu showed 9 ms after the
deadline in Chromium and 17 ms in the WebKit CI container. UIKit shows
the menu at its moment. The open now commits with flushSync, in the
timer task, as a lift's open commits in the lift's dispatch.
The menu's first show mounts its layer, a render of 6 ms in Chromium
and 17 to 21 ms in the WebKit CI container inside the task that shows
the menu at 735 ms. UIKit prepares a context menu as the interaction
begins and only presents it at its moment. The proofs want the menu
mounted, closed and out of the accessibility tree, at the commit; the
same element shown at 735 ms or at the lift; and nothing left mounted
by a move past the tolerance or a cancel.
A lazy context layer gains prepare(), which mounts its content without
showing it, and releasePrepared(), which unmounts that content unless
the layer has shown since. Both are internal to core's layer hook. At
the hold's commit, ContextMenu prepares its layer, so the show at
735 ms or at the lift only reveals it. A press that ends with no menu
releases it. component:ContextMenu PR1 names the commit-time mount.
On main, ContextMenu's mouse and pen dismissal listens on the page's
document. A menu rendered into a same-origin iframe or a Document
Picture-in-Picture window then closes on a press in the page and stays
open on one in its own window. The shared outside-press watch listens
in the trigger's own document, as `family:overlay-dismissal` FR8 has
every member work in the document it renders in. With main's
ContextMenu.tsx, this case fails.
PR1 listed one mousedown listener and the adaptive query. While the
menu is open, the outside-press watch adds pointer listeners to the
trigger's document and shadow roots, plus a mousedown listener where a
mouse or a pen closes it at the press. With presentation omitted,
ContextMenu subscribes to the coarse-pointer query while mounted.
Under a finger, a block trigger grows from the onset, 150 ms after the
finger lands, toward 1.0735 times its size on springs.contextMenuLift,
and returns on springs.contextMenuDismiss.

It carries this pull request's own diff from 63c7a6c, replayed on
main after meta#9814's squash, and folds these 27 commits (the history of
63c7a6c has each one):

- test(ContextMenu): a held finger grows the trigger on the theme's
  motion tokens, and the timeline's other proofs
- feat(ContextMenu): a held finger grows the trigger on the theme's
  motion tokens
- test(ContextMenu): a trigger gets its size back when its press
  feedback goes while the menu it opened is up
- fix(ContextMenu): the press feedback attaches through useAttachment,
  and a departing feedback gives the trigger its size back
- refactor(ContextMenu): the touch layer's recognizer carries its
  transition listener, and the press recognizer drops onPressStart
- docs(ContextMenu): FR11's growth cites AST-094 FR45
- test(ContextMenu): the trigger returns on the theme's exit duration
  and easing when the menu closes
- fix(ContextMenu): the trigger returns on the theme's exit duration and
  easing when the menu closes
- docs(ContextMenu): FR11 keeps the measured basis for the hold's
  moments, and the tests that verify them
- docs(ContextMenu): the block-trigger guidance says the trigger grows
  while the menu is up
- test(ContextMenu): the growth starts 150 ms after the finger lands,
  wherever the press machine marks the press
- fix(ContextMenu): the growth starts 150 ms after the finger lands,
  wherever the press machine marks the press
- test(ContextMenu): the trigger's growth plays springs.contextMenuLift,
  and its returns springs.contextMenuDismiss
- fix(ContextMenu): the trigger's growth plays springs.contextMenuLift,
  and its returns springs.contextMenuDismiss
- docs(ContextMenu): FR11 names the block trigger, and the tests, story
  and comments say the trigger springs back
- refactor(ContextMenu): the press feedback stops returning the
  pressable marker, and two comments say what the code does
- test(ContextMenu): a right-click in a scaled container gives the
  anchor no scale of its own
- fix(ContextMenu): the anchor cancels only the press feedback's own
  growth
- fix(ContextMenu): the press feedback imports only its two springs
- test(ContextMenu): a drift the recognizer still follows grows at onset
- fix(ContextMenu): keep the growth's onset while the recognizer follows
- test(ContextMenu): a finger grows at its own onset, marked or not
- fix(ContextMenu): a finger arms the growth from its own landing
- docs(ContextMenu): the growth starts 150 ms after the finger lands
- refactor(ContextMenu): the recognizer times a finger's onset
- refactor(ContextMenu): drop a comment that restates followsFingerRef
- refactor(ContextMenu): the onset ref comment keeps only its constraint
main's hold now rejects a hold that names no judged motion. The curve
and storyboard proofs judge the trigger's scale, so they say so.
A touch that reaches the page 90 ms after its timestamp waits for
Chromium's timer hold, so the growth starts about 50 ms late, and its
back-dated curve skips its first frames. The proof wants the frame
before the growth at rest, the first grown frame no further along
than the spring one frame in, and that frame within two frames of the
onset.
Chromium holds every timer for about 100 ms after it handles a
touchstart, so a touch that reached the page late grew late, and its
back-dated curve skipped its first frames. The first frame at or after
the onset now starts the spring from rest. The motion engine is built
in the first frame after the finger lands, not in its touchstart
handler. A lift, a move past the tolerance, a cancel and the menu's
display stop the frame watch.
The proof read "the menu shows in the lift's task" as "within one
frame of the lift". Merged with main, the menu's render in that task
takes 17 ms in the WebKit CI container, so the time proxy failed while
the menu still showed before any frame. A page flag now records
whether the menu shows while the lift's listeners run.
Merged with main, the lazy-sheet bundle proof pins that a menu with an
on-demand sheet keeps the animator on demand. ContextMenu's touch
growth plays on the animator from the first touch, so the animator
loads with the menu. The proof now expects that for ContextMenu, and
`rideKeyboard` still loads only with the sheet.
A WebKit CI run showed the menu at 890 ms, inside the timer task that
decided it, so that task itself started late. The proof now logs the
page's frames. A deciding task that starts within a frame of its due
time keeps the 20 ms bound. A later start that a frame gap over 50 ms
covers fails with FrameStarvationError, its numbers named; any other
late start fails bare. The bound does not move.
Where a coarse pointer's held finger opens the menu, the same finger
slides onto an item, which lights under it, and its lift selects it. A
presentation="popover" or fine-pointer long press hands nothing over.

It carries this pull request's own diff from bc4efd0, replayed on
main after meta#9814's squash, and folds these 11 commits (the history of
bc4efd0 has each one):

- test(useMenuPress): a pointer that went down elsewhere is the menu's
  opening press
- feat(useMenuPress): a pointer that went down elsewhere is the menu's
  opening press
- test(ContextMenu): the finger a long press opened the menu under
  slides onto an item and its lift selects it
- feat(ContextMenu): the finger a long press opened the menu under
  slides onto an item, and its lift selects it
- docs(ContextMenu): FR12 records the slide onto an item after a long
  press opens the menu
- docs(ContextMenu): the slide's release note and docs say the menu
  opens at the finger
- docs(ContextMenu): FR12's slide follows a long press that opens the
  menu at the finger
- docs(ContextMenu): FR5 points the slide to FR13
- docs(core): useMenuPress's @output keeps main's sentence
- docs(ContextMenu): the slide changeset names where the slide applies
- docs(ContextMenu): the slide changeset covers adaptive popovers too
The 735 ms proof wants the menu to show inside the timer task that
decides it. Under Reduce Motion the menu opens on the same timer, so
its proof wants the same: the show inside that task, which starts at
the deadline counted from the touch.
vjeux added 19 commits November 10, 2026 13:55
It brings meta#9816's set-height fix for the inner visual box, its trigger
attribute test, and meta#8125's fix for a late drift, in which the gesture
arena keeps each move's own time.
It brings meta#9816's set-height fix and trigger attribute test, and
meta#8125's arena-side fix for a late drift with its two cases. The
conflicts keep both sides' long-press cases, and the touch layer
keeps no pointer follow, which meta#8125's fix replaces.
A lifted trigger's tint swallows a tap while the lift still grows. The
long-press a11y case's outside tap now waits until no animation runs
on the lift, so it lands on a settled tint and closes the menu.
The layout effect that picks the menu's side beside the lift reads the
shown menu's height from the browser's layout. Its note now says so.
A late move whose coalesced samples cross 11.5 px before the menu's
moment ends the hold, as the same samples as separate moves do. A
finger that shifts slightly just before 735 ms, with its move on time,
gets the menu at 735 ms. A late timer after a recent move still waits.
The test harness's finger moves can carry coalesced samples.
The gesture arena now keeps each coalesced sample of a move at the
time it came with, as the touch's inputSamples, and no other
recognizer reads them. The press follows the finger through each of
them: a drift past 11.5 px before the menu's moment ends the hold, and
a sample from the moment on shows a waiting menu. The menu waits at
its moment only when the page is behind its input: a recent move from
before the moment, or the moment's timer, ran more than two frames
late. The wait runs on the arena's clock, like the hold's deadlines.
An on-time finger gets the menu at 735 ms.
It brings meta#8125's late-input fix: the gesture arena keeps each
coalesced sample's own time, the press judges each sample at it, and
the menu waits at its moment only when the page is behind.
It brings meta#8125's late-input fix through meta#9816: the gesture arena
keeps each coalesced sample's own time, the press judges each sample
at it, and the menu waits at its moment only when the page is behind.
A finger that crosses 11.5 px at 650 ms while the page is blocked past
the menu's moment, until 760, 800 or 900 ms, ends the hold when its
coalesced move arrives, as separate moves do. A still finger behind a
block gets its menu once no move comes. Every case fakes the page's
clock from 0, and the on-time browser case checks that the moment's
own timer task shows the menu.
When the menu's timer runs more than a frame late, the page was
blocked, and the finger's moves from before the moment may still be
queued. The menu then waits up to 50 ms, counted from when the page
runs again, so those moves are judged at their own times first. On an
idle page the timer runs on time, and nothing waits. The record's FR16
gap line states the shift and its 50 ms bound.
It brings meta#8125's fix for a page blocked past the menu's moment: a
late menu timer waits up to 50 ms for the moves queued behind the
block, so they are judged at their own times first.
The press's units drive the page's time with the gesture arena's own
injected clock, the test harness's manual clock, instead of faking
performance.now(). A move can reach the page after its own time, and a
blocked page runs the menu's timer late on that clock. A blocked main
thread until 900 ms now shows the still finger's menu at 950 ms.
The press measured how late a move or its timer ran with
performance.now(). It now reads the gesture arena's injected clock,
whose new pageTime() returns the clock's own reading. In a browser
that clock is performance.now(), so nothing changes there; the units
drive it with the test harness's manual clock.
It brings meta#8125's final head: the press reads the page's time from
the gesture arena's injected clock, which is performance.now() in a
browser, so nothing changes there.
It brings meta#8125's final head through meta#9816: a late menu timer waits
up to 50 ms for the moves queued behind a block, and the press reads
the page's time from the gesture arena's injected clock.
The menu's entrance plays as a transform on its layer. A slow first
frame, such as WebKit's on a cold shader cache, can hold that transform
at its start when the case first reads the menu. The case now reads
the menu's laid-out position, which an anchor that moved the menu
still changes and the entrance does not.
It brings meta#8125's final head through the stack, with its late-timer
wait and the injected clock. The one conflict keeps this pull
request's removal of meta#8133's block-trigger anchor case, whose inline
twin it keeps.
The menu's entrance plays as a transform on its layer. A slow first
frame, such as WebKit's on a cold shader cache, can hold that transform
at its start when the inline trigger's case first reads the menu. The
case now reads the menu's laid-out position, which an anchor that moved
the menu still changes and the entrance does not.
A sandbox page lists message rows with the same ContextMenu. Its
?lift=L2 switch turns the lift off for the page, through a preview-only
check in the touch layer. The lift module drops its uncalled
dismissSurface. Not for merge.
@vercel

vercel Bot commented Nov 11, 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 11, 2026 9:13am UTC

Request Review

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

astracat-bot Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 11, 2026, 9:28 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 11, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 11, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This preview is not ready to merge: an engineering owner's spec decision is pending, and the changes below block it either way.

Spec decision: this head proposes ContextMenu record requirements the current specs do not state (FR12, FR13, DD2, DEC-3 and the PR1 rewrite). Approval waits on an engineering owner recording that decision with /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or on the same decision made on #8201.

Blocking:

  • ContextMenuTouchLayer.tsx reads globalThis.__astryxPreviewLiftShape, so any page that sets it to 'L2' turns the lift off for every ContextMenu in its window; keep this switch out of packages/core.
  • Removing dismissSurface leaves the lift module without the exit on the layer runtime's exit copy that architecture:layer-runtime INV15 requires, and liftFromTrigger.interaction.browser.spec.ts:443 fails while three more cases close through a no-op dismissMenu.
  • .changeset/lift-module.md and liftFromTrigger/README.md still say the surface shrinks back on the exit copy, which this head no longer does.
  • packages/core/src/motion/uikit/contextMenu.ts is a new internal shared module (imported by ContextMenu.tsx, ContextMenuLift.ts, ContextMenuTouchLayer.tsx, contextMenuPress.ts and contextMenuPressFeedback.ts) that no current record names: it needs an owner API decision that says why existing modules are not enough.
  • ContextMenu.presentation default 'popover' → unset (ContextMenu) is outside this pull request's scope (no component scope): name it in the scope and get an owner decision, or split it into its own pull request.
  • Opening the lifted menu lays the page out two or three times in one task (liftFromTrigger.ts:899, then menu.offsetHeight in ContextMenu.tsx:849-865); let anchor positioning choose above or below instead of measuring.
  • At 15,435 authored lines for a 218-line preview, the scope is too broad; review the lift through #8201's stack and keep this pull request to the sandbox page.

CI has not run on this head yet.

Automated review (maintainer link)

}

/** Preview only: the page's `?lift=L2` switch turns the lift off. */
function isLiftOffForPreview(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This global lives in @astryxdesign/core, so any page that sets globalThis.__astryxPreviewLiftShape = 'L2' turns the lift off for every ContextMenu in its window, against ContextMenu FR5 and AST-094 FR33. Keep the preview switch in the sandbox page, for example by rendering the L2 rows with triggerAs="span" and a block triggerXstyle, which takes the existing no-lift branch.

);
},
// Preview only: the lift has no surface dismissal to call.
dismissMenu: () => {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With dismissMenu a no-op, the cases at liftFromTrigger.interaction.browser.spec.ts:389, :573 and :645 no longer exercise the close they name, and :443 fails. Keep dismissSurface, or drop those calls and the failing case with it.

).toEqual([]);
});

test('the menu grows out of the press into its box and keeps nothing inline at rest; its close shrinks the exit copy back into the press', async ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case waits for the exit copy that dismissSurface placed; with dismissSurface removed it fails at this head, and INV15's exit on the runtime's exit copy has no proof left.

Comment thread .changeset/lift-module.md
'@astryxdesign/core': patch
---

[chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, three elements while lifted, and no shadow. A zoom from a thumbnail lifts a copy of the thumbnail's picture into the viewer instead, while the thumbnail hides in place. Not exported from the package entry point; ContextMenu's touch lift uses it, and nothing else does yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The head no longer shrinks a surface back on the layer runtime's exit copy (dismissSurface is removed), so this release note announces an exit that does not ship. The README says the same at lines 6, 33-35 and 70.

* Every leaf has one or more `@source`. A GUESS value also has the `@measure`
* task that confirms or replaces it. uikit.test.ts enforces both.
*/
export const contextMenu = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

packages/core/src/motion/uikit/contextMenu.ts is a new internal shared module (imported by ContextMenu.tsx, ContextMenuLift.ts, ContextMenuTouchLayer.tsx, contextMenuPress.ts and contextMenuPressFeedback.ts) that no current record names: it needs an owner API decision that says why existing modules are not enough.

isDisabled = false,
onOpenChange,
presentation = 'popover',
presentation,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ContextMenu.presentation default 'popover' → unset is a public default change (BREAKING by the API rules) riding in a chore(sandbox) pull request with no component scope: name ContextMenu in the scope and get an owner decision, or leave the move to #8201's stack.

);
// Syncs the menu's side with the browser's layout of the shown menu: its
// height picks below or above the lift, before the frame paints.
useIsomorphicLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This effect reads menu.offsetHeight after the commit writes the new placement, in the same task where presentSurface already forced a layout after the lift's writes, and reads again if the side flips. Anchor positioning can pick above or below (position-try-fallbacks: flip-block via stylex.positionTry) without measuring (INV18).

This branch was successfully deployed

1 active deployment
Preview — 61de1b5d Deployed Nov 11, 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 needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants