Skip to content

feat(ContextMenu): closing shrinks the menu back into the finger as iOS 26 does - #8228

Draft
vjeux wants to merge 1492 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-dismiss
Draft

vjeux wants to merge 1492 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-dismiss

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8213 (with #8485's lift module). Through #8201, it carries the static-import offer on #8125 (16dc55e) as a dependency; if #8125 lands differently, this stack follows her decision. This PR's own change: vjeux/astryx@94744dc...5e40861

User need

Part of the UIKit-parity program. On iOS 26 closing a context menu reverses its opening: the menu shrinks back into the finger and the lifted view settles. With #8213 alone, Astryx's menu vanishes at once, and on main a tap outside also clicks what is under it.

Problem and solution fit

Under a finger (spec:AST-094 FR31), in #8201's lift: the menu's exit copy shrinks into the press as it fades, on springs.menuClose, while the trigger returns and the shadow and tint clear. A close during the growth shrinks from where the menu is, keeping its velocity (FR6). A tap on the tint, Escape and a pick all close it this way, with transform and opacity only, and a press during the close reaches nothing. Reduce Motion fades everything out where it stands.

Current specification

component:ContextMenu (draft); spec:AST-094 FR5–FR7, FR31 and FR35; design:reduced-motion. Owner decision, pending: a touch-opened menu shrinks back into the finger, as UIKit's.

Public delta and caller burden

No API change. ContextMenu JS goes from 79,600 B on main to 120,356 B gzip with this stack. Public-API gaps (spec:AST-107 FR7): the lift, its springs and UIKit tables, the input hold.

Design and alternatives

Shrinking the live menu would keep it in the page while it leaves, focusable and in the way of the next press; a copy can be inert. So the close is the lift module's dismissSurface on the layer runtime's one transient exit copy (architecture:layer-runtime INV15). Opening and closing ContextMenu's touch story in Chromium at 4× CPU slowdown costs less main thread than the clone it replaced (38–54 ms against 51–71 ms opening, 22–41 ms against 38–57 ms closing).

Evidence

iOS 26.2 simulator (time since touch, clicks shown), then Android 15:

main: close
#8228: a tap on the tint closes
#8228: a press during the close; a pick; a tap mid-lift
#8228: main thread blocked 1 s
#8228: Reduce Motion
#8228 on Android 15

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.

Testing

In the story "Touch / long press", on a phone or the iOS simulator:

  1. Hold the card, then tap the tint: the menu shrinks into where your finger was and the card settles back; nothing under the tint is clicked.
  2. Open it again and press Escape, or pick a row: it closes the same way.
  3. Tap the tint while the menu is still growing: it shrinks from where it is.
  4. Tap again during the close: nothing happens.
  5. With Reduce Motion on: everything fades out in place.

pnpm test:interaction ContextMenuLift ContextMenuLongPress checks the same in Chromium and WebKit, including that nothing is left in the page after the close. Unrelated failures: #8638, #8671. GitHub shows a conflict with main in ContextMenu.spec.md: it is resolved in #8201 and reaches this PR at its next push.

@vercel

vercel Bot commented Oct 24, 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 27, 2026 5:42pm UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 1:42 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

ContextMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 1.1KB 1.1KB 0B
Lines of Code 678 1528 -
Complexity Very High Very High (146) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
charts/Chart N/A 116.2KB N/A N/A 458B N/A
charts/ChartTooltip N/A 93.1KB N/A N/A 386B N/A
core/AlertDialog 56.9KB 57.2KB +348B 12.4KB 12.4KB 0B
core/AppShell 57.9KB 58.2KB +307B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 39.9KB 40.3KB +370B 6.4KB 6.4KB 0B
core/Banner 47.8KB 48.1KB +343B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.3KB 54.6KB +305B 9.9KB 9.9KB 0B
core/BottomSheet 34.0KB 34.0KB 0B 7.9KB 7.9KB 0B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/BreadcrumbItem 66.4KB 66.9KB +549B 11.0KB 11.0KB 0B
core/Button 43.3KB 43.6KB +333B 9.2KB 9.2KB 0B
core/Calendar 54.9KB 55.3KB +351B 10.0KB 10.0KB 0B
core/Carousel 50.2KB 50.6KB +366B 10.2KB 10.2KB 0B
core/ChatComposer 60.6KB 61.0KB +345B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.7KB 56.1KB +332B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.7KB 56.1KB +332B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.5KB 46.8KB +320B 9.5KB 9.5KB 0B
core/ChatLayout 48.8KB 49.2KB +329B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.4KB 46.7KB +317B 9.6KB 9.6KB 0B
core/ChatSendButton 45.8KB 46.1KB +322B 9.2KB 9.2KB 0B
core/CheckboxInput 49.9KB 50.2KB +330B 9.3KB 9.3KB 0B
core/CheckboxList 46.0KB 46.0KB 0B 8.1KB 8.1KB 0B
core/CheckboxListItem 54.0KB 54.3KB +307B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.1KB +351B 6.9KB 6.9KB 0B
core/CodeBlock 60.5KB 60.8KB +335B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 12.1KB +353B 4.2KB 4.2KB 0B
core/CommandPalette 50.9KB 51.2KB +343B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.8KB +336B 3.5KB 3.5KB 0B
core/ComplexSelector 55.8KB 56.2KB +376B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 117.5KB +39.8KB ⚠️ 14.6KB 14.6KB +12B
core/ContextMenuItem 16.6KB 16.9KB +342B 5.2KB 5.2KB 0B
core/DateInput 99.9KB 100.2KB +306B 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 69.7KB +341B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.4KB +309B 15.2KB 15.2KB 0B
core/DialogHeader 48.8KB 49.1KB +332B 9.8KB 9.8KB 0B
core/Drawer 31.6KB 31.6KB 0B 8.0KB 8.0KB 0B
core/DrawerHeader 48.9KB 49.2KB +305B 11.2KB 11.2KB 0B
core/DropdownMenu 72.7KB 73.2KB +515B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.5KB +337B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 15.7KB +338B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.5KB +335B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.1KB 58.6KB +501B 9.5KB 9.5KB 0B
core/Field 44.2KB 44.2KB 0B 8.0KB 8.0KB 0B
core/FieldLabel 39.8KB 39.8KB 0B 6.5KB 6.5KB 0B
core/FileInput 54.1KB 54.4KB +326B 10.3KB 10.3KB 0B
core/IconButton 43.3KB 43.6KB +329B 9.2KB 9.2KB 0B
core/InputGroup 44.8KB 44.8KB 0B 8.0KB 8.0KB 0B
core/Item 11.4KB 11.7KB +330B 4.7KB 4.7KB 0B
core/LayerProvider 63.3KB 63.6KB +343B 10.1KB 10.1KB 0B
core/Lightbox 51.2KB 51.5KB +303B 9.8KB 9.8KB 0B
core/Link 43.5KB 43.8KB +339B 7.6KB 7.6KB 0B
core/ListItem 53.5KB 53.8KB +332B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 184.7KB +40.2KB ⚠️ 17.4KB 17.4KB +11B
core/MobileNav 51.0KB 51.3KB +315B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.2KB 46.5KB +311B 9.4KB 9.4KB 0B
core/MoreMenu 72.9KB 73.4KB +512B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 76.5KB +322B 14.6KB 14.6KB 0B
core/NumberInput 55.7KB 56.0KB +335B 10.7KB 10.7KB 0B
core/Outline 28.6KB 29.0KB +343B 4.0KB 4.0KB 0B
core/Pagination 87.7KB 88.2KB +472B 15.3KB 15.3KB 0B
core/Popover 49.8KB 50.1KB +283B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 155.5KB +491B 18.6KB 18.6KB 0B
core/RadioList 45.6KB 45.6KB 0B 8.0KB 8.0KB 0B
core/RadioListItem 52.1KB 52.5KB +348B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 8.9KB +343B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.1KB +352B 7.1KB 7.1KB 0B
core/Selector 79.1KB 79.6KB +520B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.8KB +310B 5.1KB 5.1KB 0B
core/SideNav 58.0KB 58.3KB +330B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.6KB 46.9KB +300B 9.5KB 9.5KB 0B
core/SideNavHeading 54.7KB 55.0KB +314B 10.1KB 10.1KB 0B
core/SideNavItem 54.5KB 54.8KB +325B 10.1KB 10.1KB 0B
core/Slider 48.5KB 48.5KB 0B 8.5KB 8.5KB 0B
core/Step 32.4KB 32.8KB +335B 5.3KB 5.3KB 0B
core/Stepper 47.4KB 47.7KB +316B 9.6KB 9.6KB 0B
core/Switch 48.4KB 48.7KB +325B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.0KB +349B 4.1KB 4.1KB 0B
core/TabMenu 51.6KB 51.9KB +322B 10.0KB 10.0KB 0B
core/Table 84.0KB 123.9KB +40.0KB ⚠️ 15.1KB 15.1KB +13B
core/TableCell 78.8KB 118.7KB +39.9KB ⚠️ 14.8KB 14.9KB +13B
core/TableHeaderCell 78.6KB 118.5KB +39.9KB ⚠️ 14.8KB 14.8KB +19B
core/TableSelectionToolbar 52.4KB 52.7KB +344B 11.2KB 11.2KB 0B
core/TextArea 49.4KB 49.4KB 0B 9.4KB 9.4KB 0B
core/TextInput 53.5KB 53.8KB +335B 10.5KB 10.5KB 0B
core/Thumbnail 50.7KB 51.0KB +333B 11.0KB 11.0KB 0B
core/TimeInput 86.8KB 87.2KB +323B 13.8KB 13.8KB 0B
core/Timestamp 27.5KB 27.5KB +1B 9.5KB 9.5KB 0B
core/Toast 59.8KB 60.2KB +320B 9.7KB 9.7KB 0B
core/ToggleButton 44.1KB 44.4KB +302B 9.3KB 9.3KB 0B
core/Token 29.6KB 29.9KB +347B 4.5KB 4.5KB 0B
core/Tokenizer 64.8KB 65.2KB +376B 11.7KB 11.7KB 0B
core/TopNav 51.9KB 52.2KB +340B 10.4KB 10.4KB 0B
core/TopNavHeading 54.2KB 54.4KB +301B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.4KB +343B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.2KB 54.5KB +338B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.5KB +340B 4.1KB 4.1KB 0B
core/TopNavMenu 53.6KB 53.9KB +325B 10.1KB 10.1KB 0B
core/TreeList 32.5KB 32.9KB +331B 4.7KB 4.7KB 0B
core/Typeahead 61.5KB 61.8KB +336B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.1KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 65.7KB N/A N/A 1.1KB N/A
lab/ChatTypingIndicator N/A 23.5KB N/A N/A 612B N/A
lab/CommentComposer N/A 93.8KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 47.7KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 255.3KB N/A N/A 2.4KB N/A
lab/Comments N/A 260.3KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 45.9KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.2KB N/A N/A 722B N/A
lab/ListInput N/A 75.5KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 160.0KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 91.5KB N/A N/A 775B N/A
lab/Schedule N/A 66.1KB N/A N/A 3.9KB N/A
lab/ThreeDChart N/A 4.8KB N/A N/A 20B N/A
lab/TourStep N/A 55.5KB N/A N/A 590B N/A
lab/TransferList N/A 67.0KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 72.6KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.0KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 215.5KB N/A N/A 454B N/A
richtext/RichTextView N/A 251.5KB N/A N/A 2.3KB N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/ContextMenu +39.8KB ⚠️ +12B
core/Markdown +40.2KB ⚠️ +11B
core/Table +40.0KB ⚠️ +13B
core/TableCell +39.9KB ⚠️ +13B
core/TableHeaderCell +39.9KB ⚠️ +19B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 34.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

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

Not ready to approve yet: closing a touch-opened ContextMenu with UIKit's reverse morph is new direction that no current spec covers (component:ContextMenu and spec:AST-094 FR7–FR8 are draft), so an engineering owner needs to approve or reject it, including whether the Reduce Motion exit should fade out in place (FR8's wording) or still travel into the finger as it does now. Any engineering owner, the author included if they are one, can record that decision by commenting /approve-spec <full head SHA> on this pull request. Separately from that decision, please rebase once #8052–#8213 land so this diff is only the final commit, and address these:

  • Closing while the menu's entrance is still playing pops it back to full size and opacity before it shrinks, because dismissMenu ends the morph and then measures the layout box.
  • A second tap on the fading backdrop removes the shrinking menu at once, because endExit() runs before the check that the menu is still showing.
  • drop() and the touch-close state resets run from an Effect watching isOpen rather than from the close handler, which architecture:react-component-runtime INV2 asks for.

[Automated review]

return;
}
// The menu as it shows now (its entrance ends where it is).
endMorph();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Closing while the entrance is still playing ends the morph here and then measures the layout box, so the exit starts from the full menu at opacity 1: a 97.8 × 94.7 px glass at opacity 0.46 became the 160 × 169 px menu at opacity 1 in a held capture. Starting the exit from the entrance's presented values when a morph is running would keep the close a reversal of the open.

}
// The menu as it shows now (its entrance ends where it is).
endMorph();
endExit();

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 second backdrop tap during the exit reaches here with the menu already hidden: endExit() removes the shrinking glass and clone before the 0 × 0 check below returns, while the copy keeps returning. Returning before this when the menu isn't showing (or ignoring backdrop taps once the menu has closed) avoids it.

// A popover reports itself open a task after the press asked (its toggle
// event), so only an open → closed change ends the touch-opened menu.
const wasOpenRef = useRef(isOpen);
useEffect(() => {

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 Effect now drives the exit's drop() and resets isTouchOpen and liftPlacement in reaction to isOpen. architecture:react-component-runtime INV2 asks for that work in the handler that closes the menu, and the layer's onHide already runs on this close.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c20-contextmenu-dismiss branch from f903d37 to b4036a9 Compare October 24, 2026 19:11

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

Not ready to approve yet: closing a touch-opened ContextMenu with UIKit's reverse morph is new direction that no current spec covers (component:ContextMenu and spec:AST-094 FR7–FR8 are draft), so an engineering owner needs to approve or reject it, including whether the Reduce Motion exit should fade out in place (FR8's wording) or travel into the finger as it does now. Any engineering owner, the author included if they are one, can record that decision by commenting /approve-spec <full head SHA> on this pull request. Separately from that decision, please rebase once #8043–#8213 land so this diff is only the final commit, and address these:

  • A second quick tap on the fading backdrop still removes the shrinking menu at once, because dismissMenu runs endExit() before it finds the closed menu at 0 × 0 (ContextMenuLift.tsx:623-634); the new browser test only covers a press held past the exit.
  • CI lint fails on the two flushSync calls in ContextMenu.tsx (lines 783 and 794), which are errors under the strict lint CI runs.
  • drop() and the touch-close state resets still run from an Effect watching isOpen (ContextMenu.tsx:861-872) rather than from the close handler, which architecture:react-component-runtime INV2 asks for.
  • The stacked --color-background-backdrop name and the new adaptive query conflict with the current architecture:theme-tokens INV10 and component:Selector DEC-1; settle those in their own pull requests.

[Automated review]

}
// The menu's own frame (its entrance ends here).
endMorph();
endExit();

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 second quick tap on the fading backdrop reaches here again (the backdrop takes taps until the lift layer hides, and closeMenu is its onBackdropTap): endExit() removes the shrinking glass and menu copy before the 0 × 0 check below returns for the already-hidden menu, so the exit vanishes mid-flight while the copy keeps returning. Measuring first and returning before endMorph()/endExit() when the menu is not showing would keep the exit; a quick second tap in the during-exit browser test would cover it.

// The menu's side depends on its height: place it below, show it,
// measure it, and move it above in the same task if it does not fit.
const below = menuPlacement(lifted.lifted, 0, lifted.viewport);
flushSync(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CI's strict lint reports @eslint-react/dom-no-flush-sync as an error here and at line 794, and the lint check fails on this head. A justified eslint-disable-next-line @eslint-react/dom-no-flush-sync -- <why the placement needs a synchronous commit>, as Drawer/useDrawerDialogPresence.ts does, or a placement without a synchronous commit would clear it.

// A popover reports itself open a task after the press asked (its toggle
// event), so only an open → closed change ends the touch-opened menu.
const wasOpenRef = useRef(isOpen);
useEffect(() => {

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 Effect drives the exit's drop() and resets isTouchOpen and liftPlacement in reaction to isOpen. architecture:react-component-runtime INV2 asks for that work in the handler that closes the menu, and the layer's onHide already runs on every close.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

  • Reduced motion: the exit asks the animator; the local check and every reducedMotion: 'keep' are gone. Under reduced motion everything fades out where it stands, on the animator's 200 ms fade, with reducedMotionFade's timing for the content.
  • Exit copy: the menu's exit copy moves to placeLayerExitCopy (Layer/layerExitCopy.ts) at the restack onto chore(core): the layer runtime's exit copy, for a touch presentation's close #8186.

@vjeux
vjeux force-pushed the uikit/c20-contextmenu-dismiss branch from b4036a9 to d054255 Compare October 25, 2026 01:37
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 25, 2026
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Converged in d054255: the exit's reduced path is the animator's (crossfadeAs: 'exit', reducedMotionFade for the content); the local check and 'keep' are gone. The exit copy moves to placeLayerExitCopy at the restack onto #8186.

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in d054255:

  • A second quick tap on the fading tint no longer kills the exit: dismissMenu returns before ending the morph or the exit when the menu isn't showing. New browser test: two quick taps, and the exit glass is still there and still shrinking. It fails without the fix.
  • flushSync, and the close work in the Effect: both moved into the handlers (see feat(ContextMenu): a held finger lifts the trigger as iOS 26 does, with the menu beside it #8201).
  • Reduce Motion: everything now fades out where it stands, with no shrink, as FR8's wording says.
  • A close during the entrance retargets from the presented glass and content, within 2 px (test).

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

Not ready to approve yet: closing a touch-opened ContextMenu with UIKit's reverse morph, with its springs exposed as theme motion roles, is new direction that no current spec covers (component:ContextMenu and spec:AST-094 FR7 are draft), so an engineering owner needs to approve, reject or amend it. Any engineering owner, the author included if they are one, can record that decision by commenting /approve-spec <full head SHA> on this pull request. The Reduce Motion exit now fades out in place within 200 ms as design:reduced-motion DR2 requires, and the second-tap, lint and Effect issues from the last round are fixed. Separately from that decision, please address these:

  • Rebase once #8043–#8213 land so this diff is only the final commit; today it is 37,195 authored lines.
  • Keep the menu's scroll position in the exit's copy (content.scrollTop = menu.scrollTop after layerEl.append(glass, content) in ContextMenuLift.tsx); a scrolled long menu jumps back to its first rows as it closes.
  • test-ui fails on the layerViewportInset gutter guard (the lift reads the safe-area insets itself) and on the pressableCoverage guard (ContextMenu.tsx marks its trigger with usePressFeedback() without a press the controller paints).
  • Release the exit's timers and the backdrop's capture click listener in cleanUp, as architecture:react-component-runtime INV5 asks.

[Automated review]

boxShadow: 'none',
pointerEvents: 'none',
});
layerEl.append(glass, content);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The copy of a scrolled menu starts at scrollTop 0: cloneNode does not carry the scroll offset, so a person who scrolled a menu taller than its 300 px cap sees the exit jump back to its first rows (Action 7–14 became Action 1–8 in a 14-item menu). Setting content.scrollTop = menu.scrollTop; right after this append keeps what they were looking at.

}
};
layerEl.addEventListener('click', finish, true);
const timer = setTimeout(finish, CLICK_AFTER_LIFT_MS);

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 timer and the capture click listener above, the exit's timer in drop (line 547) and the entrance's timer in morphMenu (line 696) are not released on unmount: the unmount cleanup only runs cleanUp, which clears none of them. architecture:react-component-runtime INV5 asks for such resources to be released when the component unmounts; keeping the ids and the listener in refs and clearing them in cleanUp would do it.

probe.setAttribute('data-astryx-safe-area-probe', '');
probe.style.cssText =
'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;' +
'padding-top:env(safe-area-inset-top,0px);' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Layer/layerViewportInset.test.ts ("is the only gutter definition among anchor-mode layers", spec:AST-059 FR1) fails on this file because of these safe-area-inset reads, and CI's test-ui (2/4) fails with it. Taking the insets from the layer runtime's own inset, or having an owner admit the lift as a gutter owner, would clear it.

// grows it while that machine believes the press, springs back when it
// ends with no menu, and stays grown while the menu it opened is up. Not
// shown on an inline trigger.
const pressable = usePressFeedback();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

utils/pressableCoverage.test.ts ("marks nothing that does not paint a press") fails on this file: the trigger is marked with usePressFeedback(), but its press is painted by useContextMenuPressFeedback's animator, not by an arm the touch press controller reads, so the marker asks the controller to write an attribute nothing reads. CI's test-ui (2/4) fails on it.

* following it in from a blur (`morphMenu`), and shrinks back into it when
* the menu closes (`dismissMenu`), from wherever the entrance stands (every
* part retargets, as UIKit's springs do). When the menu closes, the copy
* returns to the view's place on the dismissal spring while the backdrop and

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The header still says the copy returns "on the dismissal spring"; drop now returns it on LIFT.morphSpring with the shadow on shadowExitSpring (lines 526–538).

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

Not ready to approve yet: closing a touch-opened ContextMenu with UIKit's reverse morph, with its three exit springs exposed as theme motion roles, is new direction that no current spec covers (component:ContextMenu and spec:AST-094 FR4, FR7 and FR33 are draft), so an engineering owner needs to approve, reject or amend it. Any engineering owner, the author included if they are one, can record that decision by commenting /approve-spec <full head SHA> on this pull request. The scrolled-menu jump and the two failing guard tests from the last round are fixed, and the Reduce Motion exit fades out in place within 200 ms as design:reduced-motion DR2 requires. Separately from that decision, please address these:

  • Rebase once the predecessor pull requests land so this diff is only the final commit; today it is 43,687 authored lines, including the new architecture:layer-runtime INV13 and the rewrite of the current useMenuPress module record.
  • Release the exit's timers and the backdrop's capture click listener in cleanUp, as architecture:react-component-runtime INV5 asks (ContextMenuLift.tsx lines 439–440, 612 and 761).
  • Correct astryx docs motion, which still says no shipped component reads the motion roles while ContextMenu's lift and exit do.

[Automated review]

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
content: [
{
type: 'prose',
text: 'Touch motion has a table of springs, durations and curves measured on iPhone and iPad. Each value is a motion role named by its table path, such as `springs.sheetPresent` or `timing.curves.easeInOut`. A theme retunes a role through `motion.springs`, `motion.durations` or `motion.curves`, at its root or in an adaptation rule; a role it leaves alone keeps its measured default. No shipped component reads these roles yet.',

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 still tells theme authors that no shipped component reads these roles, but ContextMenu's lift and exit read contextMenu.lift.* and springs.contextMenuDismiss through resolveMotion (ContextMenuLift.tsx lines 649–651, 834–836 and 913), and this commit adds three more rows to the table below. Naming ContextMenu's touch lift and exit as the reader, or dropping the sentence, would keep the doc true.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added 6 commits October 27, 2026 05:40
…hrough the composed path) into the input hold
…tation has run

Under the touch model the menu opens with a presentation, and the layer's
input hold (spec:AST-094 FR35) drops every tap until that presentation
completes, as UIKit drops a touch while one runs. The check tapped the
shadow host two frames after the lift, so its tap landed on the hold's
shield and the menu stayed open. It now taps once the page's animations have
finished, and its assertions are unchanged.
@vjeux

vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Addressed on efefa0b:

@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 spec decision: this pull request adds requirements the current specs do not state (the layer runtime's exit-copy invariant INV13, ContextMenu's draft DEC-2 lift elements, the public useLongPress stages option and three new AST-094 FR4 motion-role rows), and any engineering owner, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request. The close itself follows the current AST-094 FR31 and FR33. Independently of that decision, these need changes:

  • Land the stacked predecessors first, so this pull request carries only its 432-line close instead of 38,202 authored lines.
  • Remove the AST-057 sentence that puts the row swipe on theme motion roles: current AST-094 FR4 and DEC-11 keep those roles internal, and the row still settles on a fixed 200 ms.
  • Remove the fine-pointer press-tracker listener branch in GestureHost.ts, which current AST-094 FR19 does not allow.
  • Name the synchronized system in the ContextMenuTouchLayer handle effect, and flag and justify ContextMenu's touch-only bundle growth in the size report.

[Automated review]

- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity,
resistance past the panel: behavior constants in source, not public API and
not design tokens (the same ruling the touch press model received for its
clocks). The row's slide and spring motion plays on theme motion roles

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 sentence contradicts current spec:AST-094 FR4 ("The roles are internal until the public motion-token set is decided, so a theme cannot override them yet (DEC-11)"), and the row still settles on SWIPE_SETTLE_MS = 200 with ease-out (Item/useSwipeAction.ts:86, :368). Please drop it and restore "slide and spring durations" in the behavior constants.

* `touchstart` and `pointerdown` listeners on its own element, and no
* style; the host's document and window listeners come for a finger's
* press on it and go when the press ends. It paints that press's feedback
* only (an FR19 exception for press feedback only, pending in

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 press tracker with perEventPointer registers touchstart and pointerdown listeners where the primary pointer is fine, which current spec:AST-094 FR19 rules out ("no recognizer attaches and no touch listener is registered"), excepting only the row swipe. No component passes the option; please remove the press-tracker branch until FR19 is amended.

},
);

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV3 asks a new Effect to name the external system it synchronizes. This one calls onHandleChange on mount and with null on cleanup; a comment naming what the handle keeps in step (the press recognizer and the lift), or a ref instead of an Effect, would cover it.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
… when asked, cancels on a drift, and times its request on the gesture clock) into the meta#8228 helper

@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 spec decision: this pull request adds requirements the current specs do not state (the layer runtime's exit-copy invariant INV13, ContextMenu's draft DEC-2 lift elements, the public useLongPress stages option and three new AST-094 FR4 motion-role rows), and any engineering owner, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request. The close itself follows the current AST-094 FR31 and FR33. Independently of that decision, these need changes:

  • Land the stacked predecessors first, so this pull request carries only its 432-line close instead of 38,276 authored lines.
  • Drop the AST-057 sentence that puts the row swipe's motion on motion roles: the row still settles on a fixed 200 ms ease-out, and the sentence already has its own owner question on #8186.
  • Remove the fine-pointer press-tracker listener branch in GestureHost.ts until the FR19 amendment (FR43) is on main.
  • Make the press feedback's springs motion roles, as AST-094 FR4 requires of every touch spring (contextMenuPressFeedback.ts).
  • Name the synchronized system in the ContextMenuTouchLayer handle Effect, as react-component-runtime INV3 asks.

[Automated review]

Comment on lines +137 to +138
clocks). The row's slide and spring motion plays on theme motion roles
(`spec:AST-094` FR4).

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 sentence says the row's slide and spring motion plays on theme motion roles, but the row still settles on a literal SWIPE_SETTLE_MS = 200 with ease-out (Item/useSwipeAction.ts:86, :368; Item/ItemSwipeLayer.tsx:108), and the current record keeps those durations as behavior constants. It also has its own owner question on #8186; please drop it from this branch so the current record does not describe motion the row never plays.

Comment on lines +228 to +241
/**
* The recognizer, or the press tracker, reads each event's pointer itself,
* so it attaches even where the primary pointer is fine, with no warning.
* A recognizer gets the host's listeners and its element's style there, as
* `spec:AST-057`'s row swipe does. A press tracker there gets only passive
* `touchstart` and `pointerdown` listeners on its own element, and no
* style; the host's document and window listeners come for a finger's
* press on it and go when the press ends. It paints that press's feedback
* only (an FR19 exception for press feedback only, pending in
* `spec:AST-094`'s amendment): no control event, nothing kept from the
* browser, which still pans, and no mouse or pen press. Everything else
* stays inactive there. @default false
*/
perEventPointer?: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-094 FR19 says "Where the primary pointer is fine, no recognizer attaches and no touch listener is registered (FR24)", excepting only the AST-057 row swipe. The press-tracker half of perEventPointer registers passive touchstart and pointerdown listeners there, and the amendment it waits on (FR43, #8646) is still open. No component passes it today; please remove the press-tracker branch until FR43 is on main.

Comment on lines +144 to +158
useEffect(() => {
onHandleChange({
release: () => feedbackRef.current.release(),
end: () => feedbackRef.current.end(),
get presentation() {
return liftRef.current;
},
placeMenu: (lifted, menuHeight) => ({
...menuPlacement(lifted.lifted, menuHeight, lifted.viewport),
gap: contextMenu.lift.menuGap,
}),
isTracking: () => recognizer.numberOfTouches > 0,
});
return () => onHandleChange(null);
}, [onHandleChange, recognizer]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV3: "A new or changed Effect MUST name the external system it synchronizes." This Effect hands the layer's handle to ContextMenu and clears it on unmount with no such comment; please add one (the parent's ref to the mounted touch layer), or hand the handle over with a ref callback instead of an Effect.

Comment on lines +183 to +185
animator.animate(
{scale: 1},
{spring: feedbackSpring},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-094 FR4: "Every spring, duration and curve a touch presentation plays MUST be a motion role named for its use." This spring-back, and the growth curve pressFeedbackMotion builds, play contextMenu.press.feedbackSpring, which registerContextMenuTable registers as an internal constant ("None is a motion role", motion/uikit/contextMenu.ts:248-259). Please register the feedback's springs as roles through registerMotionTable's roles, as the lift's springs are.

… the presented start

A zoom stage already drives its photo with an animator (pinch, pan,
settle), and the zoom's grow is a rect morph on the same element: two
animators would overwrite each other's channels. createRectMorph takes
that animator in `animator` and plays on it; dispose leaves it to its
owner. The morph's presentedRect then reads the stage's own transform, so
a dismissal from a zoomed photo starts from the shown rect.

A present or a dismissal also takes the animator's `start`: with
'presented', the element holds its first values until two frames have
presented it, so a surface that appears late (a dialog's first frame in
WebKit) plays the whole curve.

Both new cases fail before this change and pass after.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…s menu trigger tolerance at the press, meta#8358's useLongPress on each axis, meta#8336's flat-tree scroll checks) into the meta#8228 helper

# Conflicts:
#	packages/core/src/hooks/useLongPress.ts
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added 7 commits October 27, 2026 08:16
…only moves

Under Reduce Motion on a touch device, the morph treats a path with no
opacity of its own as an entrance on a present and an exit on a
dismissal, so the cross-fade lands it at rest (design:reduced-motion).
An element that stays visible and only moves, such as a sub-menu's
parent card, then cut out and faded in on present, and faded out and
popped back on dismissal, with no way for its caller to say so: the
animator's crossfadeAs names only an entrance or an exit. A present or a
dismissal now takes crossfadeAs: 'move', which passes no cross-fade
direction, so the animator plays its own dip: out, the travel lands,
back in.

The new case does not compile before this change (the option had no
value for a move; an untyped 'move' reached the animator only by
accident) and passes after.
…t morph's source copy

AST-057's record takes main's text throughout (meta#8646 drops two-finger
trackpad swipes from the non-goals); this branch keeps only its own
change there: the row's slide and spring motion plays on theme motion
roles (spec:AST-094 FR4), so its durations leave the behavior constants.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…5 a11y syncs) into the headless context menu

@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 spec decision: this pull request adds requirements the current specs do not state (the layer runtime's exit-copy invariant INV13, ContextMenu's draft DEC-2 lift elements, the public useLongPress stages option and three new AST-094 FR4 motion-role rows), and any engineering owner, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request. The close itself follows the current AST-094 FR31 and FR33. Independently of that decision, these need changes:

  • Land the stacked predecessors first, so this pull request carries only its 432-line close instead of 38,360 authored lines.
  • Drop the AST-057 sentence that puts the row swipe's motion on theme motion roles: the row still settles on a fixed 200 ms ease-out.
  • Bring the gesture host in line with the current AST-094 FR19 and FR43, which attach gestures for every pointer, or keep it out of this branch.
  • Make the press feedback's spring a motion role, as AST-094 FR4 requires (contextMenuPressFeedback.ts).
  • Name the synchronized system in the ContextMenuTouchLayer handle Effect, as react-component-runtime INV3 asks.
  • Add WebKit evidence that the close keeps moving through a blocked main thread (FR5), or have an owner accept the iOS recording in its place.

[Automated review]

- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity,
resistance past the panel: behavior constants in source, not public API and
not design tokens (the same ruling the touch press model received for its
clocks). The row's slide and spring motion plays on theme motion roles

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 sentence says the row's slide and spring motion plays on theme motion roles, but the row still settles on a literal SWIPE_SETTLE_MS = 200 with ease-out (Item/useSwipeAction.ts:86, :368; Item/ItemSwipeLayer.tsx:108), current AST-057 keeps those durations as behavior constants, and touch roles are internal under AST-094 DEC-11. Please drop it from this branch and let it follow the owner question on #8186.

*
* ONLY WHERE THE PRIMARY POINTER IS COARSE
*
* - Recognizers attach, and the host registers listeners, only while 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.

Current AST-094 FR19 now reads "Recognizers attach for every pointer, with FR43's exceptions", and FR43 says gestures attach for a mouse, a pen and a finger. This header, the fine-pointer gate and the development warning at :1198-1209 still describe FR19 as it stood before FR43, so the first component that attaches a gesture for a mouse or pen gets nothing.

settleBack(
animator.animate(
{scale: 1},
{spring: feedbackSpring},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

contextMenu.press.feedbackSpring plays here as an internal constant ("None of them is a motion role", contextMenuPressCurve.ts:22). AST-094 FR4 requires every spring a touch presentation plays to be a motion role named for its use; registering it as a role, as the lift's springs are, keeps the timing and closes the gap.

},
);

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

react-component-runtime INV3: a new Effect must name the external system it synchronizes. This one hands the handle to ContextMenu through onHandleChange and clears it on unmount; a one-line comment, or a ref callback instead of the Effect, would settle it.

This branch was successfully deployed

1 active deployment
Preview — 5e40861d Deployed Oct 27, 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