Skip to content

feat(ContextMenu): the menu's glass grows out of the finger as iOS 26's does - #8213

Draft
vjeux wants to merge 1464 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-morph
Draft

vjeux wants to merge 1464 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-morph

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8201 (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@a0e1620...94744dc

User need

Part of the UIKit-parity program. On iOS 26 a context menu opened by a held finger grows out of the finger. With #8201 alone, Astryx's menu fades in beside the lifted trigger, so it reads as a separate popup.

Problem and solution fit

Under a finger on every touch device (spec:AST-094 FR31), inside #8201's lift, the menu starts 62 pt wide at the press point and grows into its frame on UIKit's morph spring (0.3 s), fading in on the same curve. It is the menu itself, moved by core's rect morph with transform and opacity only (FR5). A tap mid-growth is swallowed (FR35), Escape closes it, and Reduce Motion fades it in where it is.

Current specification

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

Public delta and caller burden

No API change. ContextMenu JS goes from 79,600 B on main to 120,303 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

UIKit's glass blob, its content settling out of a blur, needs an extra element and an animated clip-path, which runs on Chromium's main thread; FR5 rules both out. So the menu itself grows, through the lift module's presentSurface(menu, press), the grow every lifting surface shares (architecture:layer-runtime INV15). Still a guess: the start size for other menu heights (M-CM10).

Evidence

iOS 26.2 simulator (time since touch shown; dots are fingers), then Android 15:

main: held finger
#8213: the menu grows out of the finger
#8213: at the right edge and near the bottom
#8213: main thread blocked mid-growth
#8213: Reduce Motion
#8213: a tap on the tint while the menu grows is swallowed
#8213 on Android 15, Chromium 158: the menu grows out of the finger

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: at 0.7 s it lifts, and the menu grows out of your finger into place beside it.
  2. Hold a card at the right edge or near the bottom: the menu grows toward the room it has.
  3. Tap the tint while the menu grows: nothing happens, and the menu finishes.
  4. With Reduce Motion on: the menu fades in where it is.

pnpm test:interaction ContextMenuLift ContextMenuLongPress checks the same in Chromium and WebKit, including that only transform and opacity move. 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:45pm 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:34 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 1516 -
Complexity Very High Very High (144) -

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.7KB ⚠️ 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.6KB +40.1KB ⚠️ 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 +39.9KB ⚠️ 15.1KB 15.1KB +13B
core/TableCell 78.8KB 118.6KB +39.8KB ⚠️ 14.8KB 14.9KB +13B
core/TableHeaderCell 78.6KB 118.4KB +39.8KB ⚠️ 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.2KB 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.7KB ⚠️ +12B
core/Markdown +40.1KB ⚠️ +11B
core/Table +39.9KB ⚠️ +13B
core/TableCell +39.8KB ⚠️ +13B
core/TableHeaderCell +39.8KB ⚠️ +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.

Approval waits on an engineering owner's decision on the draft spec:AST-094 touch-presentation requirements (FR1–FR23, DEC-1, DEC-2) and on the proposed glass-morph entrance for touch-opened menus; an engineering owner records it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Because the draft also amends current design:user-states DR1 and DR3 and spec:AST-025 FR14, those records need updating on main before the amendments can count. Separately from that decision, please land the predecessor pull requests so this one carries only the morph commit, and fix the following:

  • The morph removes background-color, box-shadow and overflow from the menu's inline style when it ends, erasing values a caller passed through style (ContextMenuLift.tsx lines 231–233).
  • The content animation does not get the glass's start time, so it trails the glass by a frame (ContextMenuLift.tsx line 463).
  • On a trigger without its own background, the original shows through the lifted copy and its label renders twice.
  • packages/core/src/gestures/README.md points at utils/touchPresentation.ts, which does not exist.

[Automated review]

morph.glassAnimator.dispose();
morph.glass.remove();
for (const property of ['background-color', 'box-shadow', 'overflow']) {
morph.menu.style.removeProperty(property);

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 also removes a background-color, box-shadow or overflow the caller passed through style, and React does not re-apply an unchanged value. Record the previous values and priorities in morphMenu and restore them here.

filter: `blur(${(blur * Math.max(1 - m.fade, 0)).toFixed(3)}px)`,
};
});
const animation = menu.animate(keyframes, {duration});

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 content animation gets no start time, so it begins when the browser next makes it ready; in Chromium that was 16.6 ms after the glass. Setting animation.startTime = startAt puts it on the glass's clock.

## Touch presentation

Whether a component presents itself the touch (UIKit) way is answered once,
in `../utils/touchPresentation.ts`:

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/touchPresentation.ts does not exist at this head, and neither do TOUCH_QUERY, COMPACT_QUERY or COMPACT_TOUCH_QUERY.

@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 draft spec:AST-094 touch-presentation requirements (FR1–FR23, DEC-1, DEC-2) and on the proposed glass-morph entrance for touch-opened menus; an engineering owner records it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Because the draft also amends current design:user-states DR1/DR3 and spec:AST-025 FR14, those records need updating on main, or the amendments dropped. Whatever that decision, these fixes are needed:

  • Land #8201, #8125, #8120 and their predecessors first, so this pull request carries only the morph commit instead of about 28,800 authored lines.
  • Clear the two @eslint-react/dom-no-flush-sync lint errors in ContextMenu.tsx.
  • Give --color-background-backdrop a name the current colour-token grammar (theme-tokens INV10) admits, or amend that grammar first, then rebase and regenerate the native Swift tokens; both checks fail in CI today.
  • In ContextMenuLift.tsx, restore the caller's inline background-color, box-shadow and overflow after the morph, start the content animation at the glass's start time, keep a transparent trigger from showing through its lifted copy, and take the lift's margins from the layer runtime's gutter.

[Automated review]

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

@eslint-react/dom-no-flush-sync fails the lint check here and at line 784. If the synchronous commit is needed to measure the menu before choosing its side, record why in an eslint-disable-next-line @eslint-react/dom-no-flush-sync -- … comment, as Drawer/useDrawerDialogPresence.ts does; otherwise place the menu without flushSync.

// default is the iOS dimming colour, black at 20 % in light mode and 48 % in
// dark mode. --color-overlay stays the scrim that keeps text and icons
// legible over media.
'--color-background-backdrop':

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

backdrop is not a role the current colour-token grammar admits (theme-tokens INV10: surfaces are surface, body, card, popover, muted, inverted), and main's grammar test fails on this name in CI (tokens.test.ts line 398). It needs an INV10 amendment first or a name the grammar has; once rebased, the native Tokens.swift also needs regenerating (pnpm -F @astryxdesign/native generate:swift-ios).

);
}

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.

viewportOf never fills safeTop or safeBottom, so the copy's clamp and menuPlacement see no safe-area or app-declared inset, and the 16 px and 20 px margins in contextMenu.lift act as this component's own gutter. The layer runtime owns that gutter (AST-059 FR1, layer-runtime INV11).

};
const viewport = viewportOf(trigger.ownerDocument);
const lifted = liftedBox(source, viewport);
const preview = clonePreview(trigger);

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 source stays painted under the semi-transparent backdrop and this copy has no surface of its own, so a trigger without a background (the Default story's dashed area) shows through its lifted copy, border and label twice. Hide the source while it is lifted and restore it on drop, or give the copy an opaque surface.

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

  • Reduced motion. core/src/ContextMenu/ContextMenuLift.tsx:435 handles reduced motion locally. Per design:reduced-motion (DR1–DR4), the animator is the one place that resolves it (touch cross-fades in 200 ms or less with no travel, a fine pointer changes at once), and core has no opt-out, so reducedMotion: 'keep' should be removed.

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 morph asks the animator; the local check and every reducedMotion: 'keep' are gone. Under reduced motion the glass holds the menu's frame and fades on the animator's 200 ms fade, and the content's fade takes its timing from reducedMotionFade.

@vjeux
vjeux force-pushed the uikit/c20-contextmenu-morph branch from 2ab77df to f449bb2 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 f449bb2: the morph's reduced path is the animator's (reducedMotionFade for the content, the animator's fade for the glass); the local check and 'keep' are gone, and menuMorph has no reduced-motion option of its own.

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in f449bb2:

@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 draft spec:AST-094 touch-presentation requirements (FR1–FR23, DEC-1, DEC-2), the proposed glass-morph entrance for touch-opened menus, and this head's change to the architecture:theme-tokens INV10 colour grammar; an engineering owner records it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Because the draft also amends design:user-states DR1/DR3 and spec:AST-025 FR14, those records need updating on main, or the amendments dropped. Whatever that decision, these fixes are needed:

  • Land #8201 and the rest of the stack first, so this pull request carries only the morph commit (about 1,100 authored lines instead of about 35,900).
  • Take the lift's clamp and the menu's side from the layer runtime's gutter and declared inset (spec:AST-059 FR1), which also clears CI's gutter guard, and clear the press-coverage guard that fails on ContextMenu.tsx.
  • Drop the platform adaptation condition or amend spec:AST-012 FR2 first, align draft FR8 with the 200 ms opacity-only rule in design:reduced-motion, remove the citation of spec:AST-090, which does not exist, and add the writing rubric results to the description.

[Automated review]

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.

This probe gives the lift its own gutter: the copy's clamp and menuPlacement use 16 px and 20 px plus the safe area, and never the layer runtime's gutter or LayerProvider's declared inset. spec:AST-059 FR1 keeps the gutter in the layer runtime ("no component or family defines its own"), and CI's layerViewportInset.test.ts guard fails on this file. Read the runtime's gutter and inset instead.

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

The trigger now carries the press marker, but ContextMenu paints no press arm (the growth plays on the animator), so pressableCoverage.test.ts ("marks nothing that does not paint a press") fails in CI. Paint the press through a [data-astryx-press arm, or change the guard with its owners in the same change.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

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-012 FR2 keeps adaptation conditions to width, height, pointer, contrast and motion, and says a new field needs an explicit addition to that closed vocabulary first. Drop platform here, or amend FR2 on main before adding it.

on: a transition that travels becomes a cross-fade carrying the same change
of state. An entrance lands in place and fades in, an exit fades out in
place, and any other move fades through. A navigation push or pop
cross-fades over 0.5 s on the ease-in-out curve; a sheet, a full-screen

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under reduced motion, design:reduced-motion DR2 allows only an opacity change that finishes within 200 ms (at once on a fine pointer), so a 0.5 s navigation fade and spring-timed menus contradict it, and the draft declares no amendment of that record. The code already follows DR2; restate FR8 to defer to it.

follows.
- **FR7 — Exits animate as UIKit's do.** A touch presentation MUST play the
exit its UIKit counterpart plays. **Proposed; pending owner review:** this
departs from the draft `spec:AST-090` FR1 (exits off by default) and FR10

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-090 exists neither on main nor in this pull request; drop the citation or name the record that holds these claims.

@cixzhang

cixzhang commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Holding this PR. The iOS 26 glass morph is a look-and-feel choice, which waits on its own decision and isn't governed by spec:AST-094.

Superseded (Cindy, Oct 25): there's no glass hold. This PR adds no glass material, and the touch open is governed by spec:AST-094 (now current). See the requirements comment below for what's needed.

@vjeux
vjeux force-pushed the uikit/c20-contextmenu-morph branch from f449bb2 to 7105e4b Compare October 25, 2026 05:04
vjeux added 6 commits October 27, 2026 04:57
… a mouse press

At the document, an event from inside an open shadow root is retargeted to
the shadow host, and so is a touch's target, so the host looked for
participants from the shadow host and found none inside the root: the
browser then sent its own mousedown and click. Both cases, a finger and a
mouse, attach a tap to an element in the shadow root. Fail until the host
resolves targets through the composed path.
…composed path

Inside an open shadow root the document's listeners see the shadow host,
in event.target and in a touch's target alike, so a recognizer on an
element in the root never got its finger, and nothing claimed it. landedOn
takes the composed path's first element for the event's own target and for
a touch whose target is the event's (another finger keeps its own): a
touch's start, a pointer's press and its pairing, the last touch a scroll
reads, a cancelled pointer's touch and hover.
…esentation 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 outside
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 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.

On hold for an engineering owner's decision: this head writes three ContextMenu springs into spec:AST-094 FR4 (and grows the menu on one where FR31 names springs.menuOpen), adds architecture:layer-runtime INV13, proposes draft ContextMenu DEC-2 and exports useLongPress stages, none of which the current specs state. An engineering owner records that decision by commenting /approve-spec followed by the full head commit SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • Rebase onto the landed predecessors so only the 8-file growth remains.
  • Give a lifted trigger without its own background a surface: on the Default story its text drops to about 1.84:1 over the tint.
  • Fire onOpenChange(true) from the open handler rather than a layout effect, make the press feedback's spring a motion role, and drop the spec:AST-057 motion-role sentences and the unused fine-pointer press tracker.

[Automated review]

vjeux added 5 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.
… 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.

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

On hold for an engineering owner's decision: this head writes three ContextMenu springs into spec:AST-094 FR4 (and grows the menu on springs.contextMenuMorph where FR31 names springs.menuOpen), adds architecture:layer-runtime INV13, proposes draft ContextMenu DEC-2 and exports useLongPress stages, none of which the current specs state. An engineering owner records that decision by commenting /approve-spec followed by the full head commit SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • Rebase onto the landed predecessors so only the 8-file growth remains; today the branch carries 37,880 authored lines.
  • Give a lifted trigger without its own background a surface: on the Default story its label drops from 4.83:1 to 1.22:1 over the tint.
  • Fire onOpenChange(true) from the open handler rather than a layout effect, make the press feedback's spring a motion role, drop the unused perEventPointer press tracker and the spec:AST-057 motion-role sentences, and add real-device and busy-main-thread evidence for the growth and an INV6 justification for ContextMenu's own touch code.

[Automated review]

clear: springs.contextMenuDismiss,
shadowIn: springs.contextMenuMorph,
shadowOut: springs.contextMenuMorph,
surfaceIn: springs.contextMenuMorph,

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 growth plays springs.contextMenuMorph, but current spec:AST-094 FR31 names springs.menuOpen for a menu. Whether the three ContextMenu springs join FR4 is the open owner decision; if they do not, this moves to springs.menuOpen.

'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 trigger's computed background-color leaves a transparent trigger transparent in the top layer: on the Default story its label drops from 4.83:1 on the page to 1.22:1 over the tint (spec:AST-020 FR10, 1.4.3). When this is transparent, paint the lifted trigger on the surface it stood on.

}
if (!pending.isShown) {
pending.isShown = true;
openLifted(pending.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 onOpenChange(true) synchronously, so on a lifted open the consumer callback fires from this layout effect instead of from openFromFinger (architecture:react-component-runtime INV2). Notify from the handler and keep this effect for measuring and showing.

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

The press feedback plays press.feedbackSpring (contextMenuPressFeedback.ts line 185), and spec:AST-094 FR4 says every spring a touch presentation plays MUST be a motion role named for its use; registering it as no role leaves it outside FR4.

* control event, nothing kept), and a mouse or pen press is not taken.
* @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.

This option attaches a press tracker's touchstart and pointerdown listeners where the primary pointer is fine, which spec:AST-094 FR19 rules out ("no touch listener is registered"), and it cites an amendment the current record lacks. No component passes it; please remove it until that amendment is approved.

},
);

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 new Effect hands the parent a handle through onHandleChange and names no external system (architecture:react-component-runtime INV3). Pass the handle through a ref, or name what it synchronizes.

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

The row still settles on SWIPE_SETTLE_MS = 200 (Item/useSwipeAction.ts line 86), and spec:AST-094 FR4 and DEC-11 keep touch roles internal, so a theme cannot set this motion. Please drop this sentence and its twin at line 225.

vjeux added 6 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.

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

On hold for an engineering owner's decision: this head writes three ContextMenu springs into spec:AST-094 FR4 (and grows the menu on springs.contextMenuMorph where FR31 names springs.menuOpen), adds architecture:layer-runtime INV13, proposes draft ContextMenu DEC-2 and exports useLongPress stages, none of which the current specs state. An engineering owner records that decision by commenting /approve-spec followed by the full head commit SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • Rebase onto the landed predecessors so only the 8-file growth remains; today the branch carries 37,964 authored lines.
  • Make the gesture host attach for every pointer, as spec:AST-094 FR19, FR43 and DEC-21 now require; it still attaches only under a coarse primary pointer.
  • Give a lifted trigger without its own background a surface (its label falls to 1.83:1 over the tint), fire onOpenChange(true) from the open handler rather than a layout effect, make the press feedback's spring a motion role, and drop the spec:AST-057 motion-role sentences and the unused options.
  • Add real-device evidence and Chromium and WebKit busy-main-thread proofs for the growth, and the INV6 size split.

[Automated review]

recognizer.view = element;
if (options.perEventPointer === true) {
this.perEventRecognizers.add(recognizer);
} else if (!this.isCoarse) {

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 spec:AST-094 FR19 now reads "Recognizers attach for every pointer, with FR43's exceptions", FR43 "Gestures attach for every pointer, a mouse, a pen, and a finger, not only a coarse primary pointer", and DEC-21 rejects keeping gestures touch-only. Here an attach under a fine primary pointer installs nothing and only warns, so a held finger on a touchscreen laptop falls back to the released long press with no timeline or lift. Attach for every pointer with FR43's exceptions (and FR24's fine-pointer budget).

'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 trigger's computed background-color leaves a transparent trigger transparent in the top layer: on the Default story the #6b7280 label goes from 4.83:1 on a white page to 1.83:1 over the core light --color-overlay (1.69:1 on --color-background-body), under the 4.5:1 of spec:AST-020 FR10's 1.4.3 row. When the kept background is transparent, paint the lifted trigger on the surface it stood on.

}
if (!pending.isShown) {
pending.isShown = true;
openLifted(pending.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 onOpenChange?.(true) synchronously (Layer/useLayer.tsx lines 1310–1348, this file's lines 492–495), so on a lifted open the consumer callback fires from this layout effect. architecture:react-component-runtime INV2 has it fire from the handler that caused the change; notify from openFromFinger and keep the effect for measuring, placing and showing.

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.

feedbackSpring is a table literal: registerContextMenuTable registers the table with no roles ("None is a motion role", motion/uikit/contextMenu.ts lines 246–260). Current spec:AST-094 FR4 says every spring a touch presentation plays MUST be a motion role named for its use and a component MUST NOT play a literal. Register it as a role through the component's record (DEC-5).

},
);

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 new Effect hands the parent a handle through onHandleChange and names no external system, as architecture:react-component-runtime INV3 asks of a new Effect. Pass the handle through a ref (useImperativeHandle) or name what it synchronizes.

clear: springs.contextMenuDismiss,
shadowIn: springs.contextMenuMorph,
shadowOut: springs.contextMenuMorph,
surfaceIn: springs.contextMenuMorph,

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 growth plays springs.contextMenuMorph, while current spec:AST-094 FR31 names springs.menuOpen for a menu. Whether the three ContextMenu springs join FR4 (with FR31 amended) or the growth moves to springs.menuOpen is the open engineering-owner decision.

- **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 row code changes here: the row still settles on SWIPE_SETTLE_MS = 200 (Item/useSwipeAction.ts lines 86, 368), and current spec:AST-094 FR4 and DEC-11 keep touch roles internal, so "a theme cannot override them yet". Drop this sentence and the one at line 225, or move them to the pull request that moves the row onto roles.

* control event, nothing kept), and a mouse or pen press is not taken.
* @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.

No component passes perEventPointer, and the host justifies it as "an FR19 exception for press feedback pending in AST-094's amendment" (GestureHost.ts lines 818–819), which current AST-094 does not contain. Move it to the pull request that uses it.

This branch was successfully deployed

1 active deployment
Preview — 94744dc9 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