Skip to content

feat(ContextMenu): a held finger lifts the trigger as iOS 26 does, with the menu beside it - #8201

Draft
vjeux wants to merge 1445 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-lift
Draft

vjeux wants to merge 1445 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-lift

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8125 and #8485. It carries the static-import offer on #8125 (16dc55e) as a dependency; if #8125 lands differently, this stack follows her decision. Own commits: vjeux/astryx@16e1f21...e2edd39, vjeux@6ee4d62801, vjeux@5ea4de4f62, vjeux@bf71cc6f0d, vjeux@e3379dc53c, vjeux@0a5ad53a5a, vjeux@413b599e4a, vjeux@1271081b23, vjeux@a62d28d40d, vjeux@2c9794db08, vjeux@c997f735ee, vjeux@7ba52120e6, vjeux@e20a9343e8, vjeux@895b75d53f, vjeux@b8b636df5c, vjeux@3d038b9b72, vjeux@533e88113b, vjeux@fa073be1b5, vjeux@7df4c952d1

User need

Part of the UIKit-parity program. On iOS, a finger held on a view with a context menu lifts the view above a tinted page, the menu beside it; ContextMenu gives a phone the desktop menu at the finger (recordings below).

Problem and solution fit

On every touch device (spec:AST-094 FR13, FR33), as measured on iOS 26.2: the block trigger itself lifts with a shadow (26 pt larger on its longer side, at most 15%) while its spot stays held; a tint fades in, and tapping it closes the menu; while the lift grows, taps reach nothing (FR35); the menu sits 16 pt from it, below if it fits; Reduce Motion fades it in. Fine pointers, inline triggers, presentation="popover" and the sheet keep today's menu.

Current specification

component:ContextMenu (draft) FR5, spec:AST-094 FR31–FR33 and FR35, design:reduced-motion; the lift is #8485's module (architecture:layer-runtime INV15). Owner decisions, pending:

  1. A touch-opened menu presents as UIKit's lift on block triggers.
  2. The tint is --color-overlay, the shadow --shadow-high.
  3. component:ContextMenu/DEC-2: open, the lift adds four elements, within spec:AST-017 FR60.

Public delta and caller burden

No new token or prop. ContextMenu's JavaScript grows from 79,600 to 120,254 B gzip. Public-API gaps (spec:AST-107 FR7): the lift, its springs and UIKit tables, the input hold.

Design and alternatives

A copy of the trigger is a second React tree that grows with its content; lifting the trigger itself costs the same whatever it holds and keeps its state, so it lifts. Open gap: a touch trigger keeps a touchmove listener at rest.

Evidence

iOS 26.2 simulator, then Android 15:

main: held finger
#8201: the card itself lifts, its shadow and the tint rise, the menu fades in beside it; a tap on the tint closes
#8201: a tap on a row of the lifted menu picks it
#8201: a tap on the tint while the lift grows is swallowed
#8201: near the bottom and at the left edge
#8201: main thread blocked 1 s
#8201: Reduce Motion
#8201 on Android 15, Chromium 158: the card lifts over the tint, the menu beside it

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: it starts to rise at 0.4 s; at 0.7 s the page dims and the menu fades in.
  2. Tap a row: it acts and the menu closes. Tap the tint: the menu closes, nothing under it is clicked.
  3. Tap the tint while the card rises: nothing happens.
  4. Hold a card near the bottom or left edge: the menu stays on screen.
  5. With Reduce Motion: the card lands at once.

pnpm test:interaction ContextMenuLift ContextMenuLongPress runs these under Chromium's and WebKit's own finger. Unrelated failures: #8638, #8671. ContextMenuLongPress's lift-after-commit case used to time the menu against wall clock and failed under load; it now checks that the menu shows in the lift's own task, before the next task and frame, with 40 ms of other work in that task.

Writing rubric results

R1: present tense. R2: the element counts are INV7's required evidence. R3: verification rows name tests and measurements.

@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 28, 2026 1:41am 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 29, 2026, 10:33 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 1508 -
Complexity Very High Very High (143) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
charts/Chart N/A 64.3KB N/A N/A 20B N/A
core/AlertDialog 57.3KB 57.6KB +342B 12.4KB 12.4KB 0B
core/AppShell 58.3KB 58.6KB +310B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 40.3KB 40.7KB +370B 6.4KB 6.4KB 0B
core/Banner 48.2KB 48.5KB +340B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.7KB 55.0KB +315B 9.9KB 9.9KB 0B
core/BreadcrumbItem 66.8KB 67.3KB +549B 11.0KB 11.0KB 0B
core/Button 43.7KB 44.0KB +336B 9.2KB 9.2KB 0B
core/Calendar 55.3KB 55.6KB +349B 10.0KB 10.0KB 0B
core/Carousel 50.6KB 50.9KB +329B 10.2KB 10.2KB 0B
core/ChatComposer 61.0KB 61.4KB +348B 10.8KB 10.8KB 0B
core/ChatComposerInput 56.2KB 56.5KB +321B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 56.2KB 56.5KB +324B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.9KB 47.2KB +319B 9.5KB 9.5KB 0B
core/ChatLayout 49.2KB 49.6KB +347B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.8KB 47.1KB +333B 9.6KB 9.6KB 0B
core/ChatSendButton 46.2KB 46.5KB +329B 9.2KB 9.2KB 0B
core/CheckboxInput 50.3KB 50.6KB +321B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.5KB 54.8KB +314B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.1KB +351B 6.9KB 6.9KB 0B
core/CodeBlock 60.9KB 61.2KB +324B 10.7KB 10.7KB 0B
core/Collapsible 17.0KB 17.3KB +364B 4.2KB 4.2KB 0B
core/CommandPalette 51.4KB 51.7KB +344B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.8KB +336B 3.5KB 3.5KB 0B
core/ComplexSelector 56.2KB 56.6KB +355B 10.6KB 10.6KB 0B
core/ContextMenu 75.0KB 115.1KB +40.2KB ⚠️ 14.6KB 14.6KB +12B
core/ContextMenuItem 53.3KB 53.6KB +325B 9.6KB 9.6KB 0B
core/DateInput 99.9KB 100.2KB +341B 14.8KB 14.8KB 0B
core/DateRangeInput 69.8KB 70.1KB +352B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.5KB +350B 15.2KB 15.2KB 0B
core/DialogHeader 49.2KB 49.5KB +328B 9.8KB 9.8KB 0B
core/Drawer 31.6KB 31.6KB 0B 8.0KB 8.0KB 0B
core/DrawerHeader 49.3KB 49.6KB +306B 11.2KB 11.2KB 0B
core/DropdownMenu 73.1KB 73.6KB +530B 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.5KB 59.0KB +494B 9.5KB 9.5KB 0B
core/FileInput 54.5KB 54.8KB +314B 10.3KB 10.3KB 0B
core/IconButton 43.7KB 44.0KB +338B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.7KB +330B 4.7KB 4.7KB 0B
core/LayerProvider 63.7KB 64.0KB +359B 10.1KB 10.1KB 0B
core/Lightbox 51.6KB 51.9KB +301B 9.8KB 9.8KB 0B
core/Link 43.9KB 44.2KB +334B 7.6KB 7.6KB 0B
core/ListItem 53.9KB 54.2KB +315B 10.5KB 10.5KB 0B
core/Markdown 144.8KB 185.1KB +40.4KB ⚠️ 17.4KB 17.4KB +11B
core/MobileNav 51.4KB 51.7KB +316B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.6KB 46.9KB +306B 9.4KB 9.4KB 0B
core/MoreMenu 73.4KB 73.9KB +533B 14.7KB 14.7KB 0B
core/MultiSelector 73.3KB 73.7KB +376B 14.6KB 14.6KB 0B
core/NumberInput 56.1KB 56.4KB +350B 10.7KB 10.7KB 0B
core/Outline 29.0KB 29.3KB +340B 4.0KB 4.0KB 0B
core/Pagination 85.2KB 85.7KB +495B 15.3KB 15.3KB 0B
core/Popover 50.3KB 50.6KB +332B 12.1KB 12.1KB 0B
core/PowerSearch 164.7KB 165.3KB +599B 19.9KB 19.9KB 0B
core/RadioListItem 52.6KB 52.9KB +323B 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 76.7KB 77.2KB +543B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.8KB +310B 5.1KB 5.1KB 0B
core/SideNav 58.4KB 58.7KB +328B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 47.0KB 47.3KB +306B 9.5KB 9.5KB 0B
core/SideNavHeading 55.1KB 55.4KB +310B 10.1KB 10.1KB 0B
core/SideNavItem 54.9KB 55.2KB +324B 10.1KB 10.1KB 0B
core/Step 32.8KB 33.1KB +338B 5.3KB 5.3KB 0B
core/Stepper 47.8KB 48.1KB +326B 9.6KB 9.6KB 0B
core/Switch 48.8KB 49.1KB +332B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.1KB +352B 4.1KB 4.1KB 0B
core/TabMenu 52.0KB 52.3KB +336B 10.0KB 10.0KB 0B
core/Table 84.2KB 124.3KB +40.1KB ⚠️ 15.2KB 15.2KB +13B
core/TableCell 76.0KB 116.2KB +40.1KB ⚠️ 14.9KB 14.9KB +13B
core/TableHeaderCell 75.8KB 116.0KB +40.2KB ⚠️ 14.9KB 14.9KB +19B
core/TableSelectionToolbar 52.8KB 53.2KB +345B 11.2KB 11.2KB 0B
core/TextInput 54.0KB 54.3KB +335B 10.5KB 10.5KB 0B
core/Thumbnail 51.1KB 51.4KB +320B 11.0KB 11.0KB 0B
core/TimeInput 87.2KB 87.5KB +305B 13.8KB 13.8KB 0B
core/Timestamp 27.9KB 27.9KB -1B 9.5KB 9.5KB 0B
core/Toast 60.2KB 60.5KB +315B 9.7KB 9.7KB 0B
core/ToggleButton 44.5KB 44.8KB +300B 9.3KB 9.3KB 0B
core/Token 30.0KB 30.3KB +349B 4.5KB 4.5KB 0B
core/Tokenizer 69.0KB 69.3KB +338B 16.5KB 16.5KB 0B
core/TopNav 52.2KB 52.6KB +335B 10.4KB 10.4KB 0B
core/TopNavHeading 54.5KB 54.8KB +297B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.4KB +343B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.6KB 54.9KB +354B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.5KB +340B 4.1KB 4.1KB 0B
core/TopNavMenu 54.0KB 54.3KB +333B 10.1KB 10.1KB 0B
core/TreeList 38.6KB 39.0KB +372B 4.7KB 4.7KB 0B
core/Typeahead 61.9KB 62.2KB +337B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.8KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 66.3KB N/A N/A 1.1KB N/A
lab/ChatTypingIndicator N/A 23.9KB N/A N/A 612B N/A
lab/CommentComposer N/A 94.8KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 48.1KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 256.1KB N/A N/A 2.4KB N/A
lab/Comments N/A 261.0KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.3KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.6KB N/A N/A 722B N/A
lab/ListInput N/A 75.8KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 147.2KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 92.8KB N/A N/A 809B N/A
lab/Schedule N/A 60.5KB 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 56.0KB N/A N/A 590B N/A
lab/TransferList N/A 67.4KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 73.0KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.4KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 213.3KB N/A N/A 454B N/A
richtext/RichTextView N/A 251.9KB 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 +40.2KB ⚠️ +12B
core/Markdown +40.4KB ⚠️ +11B
core/Table +40.1KB ⚠️ +13B
core/TableCell +40.1KB ⚠️ +13B
core/TableHeaderCell +40.2KB ⚠️ +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.9KB 34.9KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

57 of 504 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 37 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.

Approval is on hold for an engineering-owner decision: this pull request adds direction that no current spec states, namely draft spec:AST-094 (FR1–FR23, DEC-1, DEC-2), the ContextMenu lift for touch-opened block triggers, and the new --color-background-backdrop dim that replaces the blurred scrim on Dialog, Drawer, MobileNav and BottomSheet, and its FR11 and FR14 would change the current design:user-states DR1 and spec:AST-025 FR14, which those records would then need to say. Any engineering owner, including the author as one, can record approval by commenting /approve-spec <full head SHA>, or reject or split any of these. Whatever is decided, these need fixing before approval:

  • A block trigger without its own background lifts as a see-through copy over the still-visible trigger, so the ContextMenu Default story shows its label and border twice.
  • lint runs out of memory on this head (as on #8074 and #8125) while it passes on main.
  • The range carries 26,730 authored lines because the stacked predecessors are unmerged; landing them first, or basing this pull request on its predecessor, leaves this commit's own change to review.

Smaller fixes (the close-path Effect, the unused safe-area clamp, right-to-left menu alignment, the zh guidance and a citation of the unmerged AST-090) are detailed in the review.

[Automated review]

}

/** A copy of the trigger to lift: inert, unnamed, with no ids to collide. */
function clonePreview(trigger: HTMLElement): HTMLElement {

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 is cloned as is and the trigger stays visible under it, so a trigger without its own background (the Default story's dashed area) lifts as a see-through copy and its label and border show twice. Hiding the source trigger from lift() until drop() finishes, or giving the copy an opaque surface, would fix it; contextMenuPressFeedback.ts:72-75 already says the trigger "is hidden while its preview is up".

// 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 sets isTouchOpen and liftPlacement in reaction to isOpen, so the render after a close still carries the touch-open state. architecture:react-component-runtime INV2 asks for this work in the handler that causes it: useLayer's onHide (and updateOpenState(false) for the sheet).

);
}

function viewportOf(doc: Document): LiftViewport {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

liftedBox() and menuPlacement() subtract safeTop and safeBottom, but this never sets them, so on a viewport-fit=cover page the copy can lift to 20 px from the screen edge, under the status bar.

: 'above';
const epsilon = 0.5;
const alignment =
lifted.x <= screenInset + epsilon

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 picks start for the physical left edge, but the layer reads alignment logically (span-self-inline-end), so in a right-to-left page the menu is flush with the copy's inner edge instead of the edge it is held against.

{
guidance: true,
description:
'块级触发区域周围请留出少量空间:按住时它会从中心放大约 6%,直到菜单关闭。',

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 zh guidance still says the trigger stays grown until the menu closes; the English guidance and the code return it to its size when the copy lifts about 13 %.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the touch-presentation direction this pull request introduces: draft spec:AST-094, the iOS ContextMenu lift, the unblurred modal dim token and the new 744 / 500 px compact rule. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these changes are needed:

  • Rename --color-background-backdrop, which breaks the colour token grammar (theme-tokens INV10) and fails test-ui, and regenerate the native Swift theme after rebasing on main.
  • Clear the two strict-lint flushSync errors at ContextMenu.tsx:779 and :790.
  • Split the 28,130-line range so that this pull request reviews the ContextMenu press and lift alone.
  • Hide the source trigger under the lifted copy, move the close reset out of the Effect, feed the safe-area insets to the lift geometry, and fix right-to-left menu alignment.
  • Correct the Chinese guidance in ContextMenu.doc.mjs and the AST-090 citation in the spec.

[Automated review]

@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 lift asks the animator; the local check, the local fade and every reducedMotion: 'keep' are gone, so a finger gets the animator's 200 ms opacity fade in place.
  • Spring player: the copy, its shadow and the dim play on the shared animator. The menu entrance's local spring sampler moves to the single spring player once it is specified (the animator does not drive filter today).
  • Exit copy: the 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-lift branch from 1d09473 to e01d233 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 e01d233:

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in e01d233:

  • flushSync: gone. The menu renders below first, and a layout effect measures it and moves it above before the frame paints.
  • Close reset: in the close handlers (the lifted copy's return, the placement and the anchor size included); the Effect is gone.
  • The source trigger now hides under its lifted copy, as UIKit hides the source view, and shows again once the copy is back. Under Reduce Motion it hides after the copy's fade-in and shows as the fade-out starts. New browser test; it fails without the fix.
  • Safe-area insets: liftViewportOf reads env(safe-area-inset-top/bottom) through a probe and feeds the clamp and the placement. New unit test.
  • Right-to-left: the alignment is logical, so a right-to-left page swaps the physical sides. New geometry and browser tests; both fail without the fix.
  • zh guidance: corrected to match the English.
  • The tint is now its own token, --color-background-backdrop-muted (UIKit's #16152A at 21 %, the same in dark mode), within the INV10 amendment proposed in spec(theme-tokens): the modal dim and the bar glass are surface roles #8283.
  • The lift's margins against the layer runtime's gutter wait on an owner decision; the touch direction is spec(AST-094): touch presentations feel native by default #8316's one touch model.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…hrough the composed path) into the input hold

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on five pieces of direction that no current spec states: component:ContextMenu DEC-2 (the four elements the lift adds while open), the three context-menu spring rows added to spec:AST-094 FR4, the new architecture:layer-runtime INV13 (the exit copy), the page scroll lock while a menu a finger opened is up, and the public useLongPress stages option. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of that decision, these need changes:

  • The range carries the whole unmerged stack (37,674 authored lines); landing it first would leave this diff as the lift alone.
  • Two repository guards that pass on main fail at this head: the press-coverage test, whose widened press arm now matches hooks/actionGroupTracker.ts, and the touch-support bundle test's root-entry case.
  • The lift layer's touch-action: none (liftFromTrigger.ts:530) blocks pinch-zoom while the menu is up, against spec:AST-094 FR10, FR32 and FR35, and no test pinches over the lift.
  • A pan outside a finger's menu does not close it (FR32), a trigger without its own background lifts see-through, the press spring is not an FR4 motion role, the lifted open calls onOpenChange(true) from a layout effect, the lifted menu vanishes instead of shrinking back (FR31), and the AST-057 edit describes motion no code plays.

[Automated review]

color: 'inherit',
overflow: 'visible',
pointerEvents: 'auto',
touchAction: 'none',

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 layer covers the whole viewport while a trigger is lifted, so touch-action: none here stops page pinch-zoom for as long as the menu is up (probed: computed none over 390 × 664 on the lifted Default story). spec:AST-094 FR10, FR32 ("its surface never takes touch-action: none") and FR35 ("A pinch still zooms the page") ask for the opposite; touchAction: 'pinch-zoom' still keeps one-finger pans and taps off the page.

return;
}
pendingLiftRef.current = null;
openLifted(lifted);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

openLifted calls layer.show(), which runs onShow and with it the consumer's onOpenChange(true) from this layout effect. architecture:react-component-runtime INV2 wants the callback fired from the handler that caused the open (openFromFinger); the effect can keep the show, the measurement and the flip.

// UIKit's context menu. A sheet locks the page itself. (The lifted trigger
// and the menu's anchor are fixed to the viewport in the top layer, so
// pinning the page moves neither.)
useScrollLock(isOpen && isTouchOpen && !usesBottomSheet);

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 current record states this lock: spec:AST-094 FR19 allows one only under a modal presentation, and FR32 wants an anchored menu to close as a touch outside it begins to pan. Today a pan outside a finger's menu neither scrolls nor closes it (the outside handler listens for mousedown, which a pan never sends).

@@ -63,7 +66,8 @@ const source = new Map(
files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]),
);

const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./;
const PRESS_ARM =
/':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/;

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 widened arm also matches main's hooks/actionGroupTracker.ts (getAttribute(PRESSED_ATTRIBUTE) at lines 271 and 441), which has no usePressFeedback(), so this guard fails at this head and passes on the base. The body points the fix at #8638, which this head does not carry.

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

No code here moves the row swipe onto motion roles: Item still settles on SWIPE_SETTLE_MS = 200 (useSwipeAction.ts:86, :368), and spec:AST-094 FR4 and DEC-11 keep touch motion roles internal, so no theme reaches them. This edit fits the pull request that puts the swipe on roles.

vjeux added 3 commits October 27, 2026 05:51
…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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on five pieces of direction that no current spec states: component:ContextMenu DEC-2 (the four elements the lift adds while open), the three context-menu spring rows added to spec:AST-094 FR4, the new architecture:layer-runtime INV13 (the exit copy), the page scroll lock while a menu a finger opened is up, and the public useLongPress stages option. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of that decision, these need changes:

  • The range carries the whole unmerged stack (37,748 authored lines); landing it first would leave this diff as the lift alone.
  • Two repository guards that pass on main fail at this head: the press-coverage test, whose widened press arm now matches hooks/actionGroupTracker.ts, and the touch-support bundle test's root-entry case.
  • The lift layer's touch-action: none (liftFromTrigger.ts:530) blocks pinch-zoom while the menu is up, against spec:AST-094 FR10, FR32 and FR35, and no test pinches over the lift.
  • A trigger without its own background lifts see-through (its label drops to about 1.8:1 over the default tint), a pan outside does not close a finger's menu (FR32), the lifted menu vanishes instead of shrinking back (FR31), the lifted open calls onOpenChange(true) from a layout effect, the press spring is not an FR4 motion role, and the AST-057 edit describes motion no code plays.

[Automated review]

color: 'inherit',
overflow: 'visible',
pointerEvents: 'auto',
touchAction: 'none',

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 viewport-wide layer takes every touch outside the menu while the trigger is lifted, and touch-action: none also takes pinch-zoom from the page (probed: computed none over 390 × 664 on both lifted stories); the input hold's pinch-zoom shield is appended inside it, so it cannot restore the pinch. spec:AST-094 FR10, FR32 ("its surface never takes touch-action: none") and FR35 keep pinch-zoom the browser's. touchAction: 'pinch-zoom' still keeps one-finger pans and taps off the page.

'padding-bottom',
'padding-left',
'color',
'background-color',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keeping the laid-out background-color leaves a trigger without its own background transparent over the tint: on the ContextMenu Default story its #6b7280 label goes from 4.83:1 on white to 1.21:1 in the captured frame (1.83:1 over Core's default light --color-overlay). An opaque surface under such a trigger, or a documented and tested requirement that a block trigger carry its own background, keeps the lifted item legible.

return;
}
pendingLiftRef.current = null;
openLifted(lifted);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

openLifted calls layer.show(), which runs onShow synchronously and with it the consumer's onOpenChange(true), from this layout effect. architecture:react-component-runtime INV2 has the callback fire from the handler that caused the open (openFromFinger); the effect can keep the show, the measurement and the flip.

// UIKit's context menu. A sheet locks the page itself. (The lifted trigger
// and the menu's anchor are fixed to the viewport in the top layer, so
// pinning the page moves neither.)
useScrollLock(isOpen && isTouchOpen && !usesBottomSheet);

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 current record states this lock: spec:AST-094 FR19 allows one only under a modal presentation, and FR32 wants an anchored menu to close as a touch outside it begins to pan. With it, a pan outside a finger's menu neither scrolls nor closes it (the outside handler listens for mousedown, which a pan never sends).

attribute: 'data-astryx-context-menu-lift',
});
},
enterMenu(menu) {

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 menu enters through presentSurface, but nothing calls the lift module's dismissSurface, and the lifted placement has no layerAnimations, so the menu disappears at once while the trigger drops back. spec:AST-094 FR31 has an anchored menu close by shrinking back, and layer-runtime INV15 plays the lift module's exit on the runtime's exit copy.

* ContextMenu's touch code calls it before its first motion (it is
* idempotent).
*/
export function registerContextMenuTable(): void {

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 registers the table with no roles, so the press feedback's growth and early return play contextMenu.press.feedbackSpring as a table value (contextMenuPressCurve.ts:73, contextMenuPressFeedback.ts:185). spec:AST-094 FR4: "A component MUST read touch motion through these roles and MUST NOT play a literal"; DEC-11 keeps roles internal, not absent.

@@ -63,7 +66,8 @@ const source = new Map(
files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]),
);

const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./;
const PRESS_ARM =
/':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/;

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 widened arm also matches main's hooks/actionGroupTracker.ts:271 (getAttribute(PRESSED_ATTRIBUTE)), which has no usePressFeedback(), so this guard fails at this head and passes on the base. The body points the fix at #8638, which this head does not carry.

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

No code here moves the row swipe onto motion roles: Item still settles on SWIPE_SETTLE_MS = 200 (useSwipeAction.ts:86, :368), and spec:AST-094 FR4 and DEC-11 keep touch motion roles internal, so no theme reaches them. This edit fits the pull request that puts the swipe on roles.

vjeux added 8 commits October 27, 2026 07:28
… 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.
…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.
…er work in the lift's task

A busy page does other work in the task that delivers the lift. The menu
still shows in that task, before the next task and the next frame, but the
check measured the time from the lift to the show against one frame
(1000/60 ms) of wall clock, so it failed whenever the lift's task ran long:
once per engine at a load of 67. With 40 ms of injected work it fails every
time (49 ms in Chromium, 51 ms in WebKit). Red: the next commit measures the
order instead.
…r, not a frame of wall clock

The check now notes the lift at the finger's first pointer or touch event,
queues a task and an animation frame there, and records the menu's show
among them: the show comes first, in the lift's own task, before the next
task and the next frame. That is what "before the next frame could paint"
means, and it holds however long the lift's task runs: with the 40 ms of
injected work it passes in both engines, three times in a row.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on five pieces of direction that no current spec states: component:ContextMenu DEC-2 (the four elements the lift adds while open), the three context-menu spring rows added to spec:AST-094 FR4, the new architecture:layer-runtime INV13 (the exit copy), the page scroll lock while a menu a finger opened is up, and the public useLongPress stages option. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of that decision, these need changes:

  • The range carries the whole unmerged stack (37,832 authored lines); landing it first would leave this diff as the lift alone.
  • Two repository guards that pass on the base fail at this head: the press-coverage test, whose widened press arm now matches hooks/actionGroupTracker.ts, and the touch-support bundle test's root-entry case.
  • The lift layer's touch-action: none (liftFromTrigger.ts:530) blocks pinch-zoom while the menu is up, against spec:AST-094 FR10, FR32 and FR35, and no test pinches over the lift.
  • A trigger without its own background lifts see-through (its label drops to about 1.8:1 over the default tint), a pan outside does not close a finger's menu (FR32), the lifted menu vanishes instead of shrinking back (FR31), the lifted open calls onOpenChange(true) from a layout effect, the press spring is not an FR4 motion role, and the AST-057 edit describes motion no code plays.

[Automated review]

color: 'inherit',
overflow: 'visible',
pointerEvents: 'auto',
touchAction: 'none',

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 layer covers the whole viewport while the trigger is lifted, so touch-action: none stops page pinch-zoom over the menu. spec:AST-094 FR10, FR32 and FR35 keep the pinch the browser's; 'pinch-zoom' still keeps one-finger pans and taps from reaching the page.

'padding-bottom',
'padding-left',
'color',
'background-color',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keeping the laid-out background-color means a trigger that takes its background from the page lifts see-through: the Default story's grey label drops from 4.83:1 to about 1.8:1 over the default light --color-overlay. An opaque surface under a transparent trigger (or a documented requirement) would keep it legible.

return;
}
pendingLiftRef.current = null;
openLifted(lifted);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

openLifted runs layer.show(), whose onShow calls the consumer's onOpenChange(true) from this layout effect. architecture:react-component-runtime INV2 wants it from the handler that caused it (openFromFinger); the effect can keep the show, the measurement and the flip.

});
},
enterMenu(menu) {
lift.presentSurface(menu, null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The menu enters through presentSurface, but nothing calls dismissSurface, so on close the menu disappears at once while the trigger drops back; spec:AST-094 FR31 wants it to shrink back into the trigger on the runtime's exit copy.

packages/core/src/ContextMenu/ContextMenu.spec.md: main's text as meta#8648
merged it (FR10, its verification row and the layer note), with this
PR's DEC-2 verification row re-applied after it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on six pieces of direction that no current spec states: component:ContextMenu DEC-2 (the four elements the lift adds while open), the three context-menu spring rows added to spec:AST-094 FR4, the new architecture:layer-runtime INV13 (the exit copy), the page scroll lock while a menu a finger opened is up, the public useLongPress stages option, and whether the lift must first move into the createContextMenu factory that spec:AST-107 FR8 names. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of that decision, these need changes:

  • The range carries the whole unmerged stack (37,874 authored lines); landing it first would leave this diff as the lift alone.
  • The lift layer's touch-action: none (liftFromTrigger.ts:530) blocks pinch-zoom while the menu is up, against spec:AST-094 FR10, FR32 and FR35.
  • The press-coverage and touch-support bundle guards pass on the base and fail at this head; their fixes in #8638 and #8671 are not in this range.
  • The lifted menu disappears at the tap instead of shrinking back through the exit copy (FR31, layer-runtime INV15), and the lifted open calls onOpenChange(true) from a layout effect.
  • A trigger without its own background lifts see-through (its label drops from 4.83:1 to about 1.2:1 over the tint), ContextMenu's touch-only size needs its INV6 justification, and the AST-057 edit describes motion no code plays.

[Automated review]

color: 'inherit',
overflow: 'visible',
pointerEvents: 'auto',
touchAction: 'none',

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 viewport-wide layer takes every touch on the tint, so touch-action: none leaves the browser no pinch-zoom while the menu is lifted, against spec:AST-094 FR10, FR32 and FR35. touchAction: 'pinch-zoom' still keeps one-finger pans off the page; a lift case in the browser spec that pinches on the tint would pin it.

'padding-bottom',
'padding-left',
'color',
'background-color',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keeping the laid-out background-color means a transparent trigger (the Default story's dashed area) lifts see-through: its #6b7280 label drops from 4.83:1 on the page to about 1.2:1 over the tint. An opaque fallback surface when the trigger's background is transparent would keep the lifted item legible.

attribute: 'data-astryx-context-menu-lift',
});
},
enterMenu(menu) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

enterMenu and drop are exposed, but nothing reaches the lift's dismissSurface, so a lifted menu hides at the tap with no exit copy, while spec:AST-094 FR31 and architecture:layer-runtime INV15 want it to shrink back through the runtime's exit copy (and the proposed DEC-2 says that copy joins). Calling dismissSurface(menu) while the menu still shows, before the layer hides it, would close the gap.

return;
}
pendingLiftRef.current = null;
openLifted(lifted);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

openLifted calls layer.show(), whose onShow runs onOpenChange?.(true) synchronously, so the consumer callback fires from this layout effect rather than from the handler (architecture:react-component-runtime INV2). Showing the layer from openFromFinger and keeping only the measurement and flip here would keep the callback in the handler.

@@ -63,7 +66,8 @@ const source = new Map(
files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]),
);

const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./;
const PRESS_ARM =
/':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Widening the arm to getAttribute(PRESSED_ATTRIBUTE) makes hooks/actionGroupTracker.ts on main match without the marker, so this guard fails at this head (test-ui (2/4)) while it passes on the base; the fix in #8638 is not in this range.

This branch was successfully deployed

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