Skip to content

feat(ContextMenu): a finger held until the menu opens slides onto an action and picks it - #8133

Draft
vjeux wants to merge 147 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-scrub
Draft

vjeux wants to merge 147 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-scrub

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Waits on #8622; it will be rebuilt on the shared action-group tracker, per the decision on #8084.

Depends on #8125 and #8078 (and their predecessors); review the last four non-merge commits.

Evidence

iOS 26.2 Safari simulator: the HUD shows ms since the touch, clicks, the picked action and scroll; dots are fingers.

Before (main): held (the first row lit at once), slid down onto the menu, lifted: nothing is picked.
main: hold and slide

After:
#8133: slide to pick, lift before the handoff, slide past the menu

Android 15 emulator, Chromium 158 (Chrome 124 there lacks anchor positioning): #8133 on Android: slide to Share

User need

The finger that opens a context menu isn't done when the menu appears: it slides onto an action and lifts to pick it, in one gesture. With #8125 the menu opens under a held finger at UIKit's moment, but that finger is stranded: sliding does nothing.

Problem and solution fit

  • Where the primary pointer is coarse, on every touch device (AST-094 FR13): when the menu opens under a finger that is still down (735 ms), ContextMenu hands it to the menu's press model through its core-internal continuation (continueGesture, DropdownMenu: under a finger the trigger opens as the finger presses (AST-094 FR29) #8078) and the gesture host's pointerIdOf. Where it is fine, or with presentation="popover", a finger keeps today's long press and no handoff.
  • UIMenu's touch continuation applies (AST-094 FR33), as measured on iOS 26.2. Once the finger has moved 10 pt from the first position the open menu sees, the highlight follows it, and the row under its lift acts. A lift before that picks nothing and leaves the menu open.
  • No row is lit until the finger reaches one (feat(ContextMenu): open from a held finger on iOS 26's timeline #8125), and a slide off the rows leaves none lit.
  • No scroll can clear the lit row: the finger was claimed at 400 ms. Sliding past the menu scrolls nothing and picks nothing.
  • The sample clock is useMenuPress's own (Date.now()).

Current specification

component:ContextMenu is a draft; AST-094 (FR13, FR26–FR28, FR33) governs the touch model. module:DropdownMenu/useMenuPress FR1–FR7 hold, through #8078's continuation.

Owner decision, proposed; pending owner review: a context menu's opening finger continues into the menu, under UIMenu's handoff distance.

Public delta and caller burden

No API change; touch only. Size: +341/−34, about 75% tests.

Design and alternatives

  • ContextMenu tracking the finger itself: rejected; useMenuPress already owns the highlight, the pick, autoscroll and the stray click.
  • Counting the handoff from where the finger was when the menu showed: rejected; UIKit counts from the first sample after it opens.

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

  • pnpm vitest run packages/core/src/{ContextMenu,hooks,DropdownMenu,utils,Layer,gestures,motion}: 2427 pass, including the table-import rule. The slide-to-pick, lift-before-handoff and no-handover-at-a-lift cases run on iOS and Android stubs alike.
  • pnpm test:interaction ContextMenuLongPress DropdownMenu: Chromium 28/28, WebKit 24/24, under each engine's own finger:
    • the highlight follows the finger and Share is picked on the lift;
    • a lift before the handoff leaves the menu open;
    • sliding down and inward past the menu on a scrollable page: no scroll, a row lit on the way, none at the end, nothing picked, no focus ring.
  • Android 15 (Chromium 158) and iOS 26.2: every path above, reviewed frame by frame.
  • Core typecheck, eslint, prettier, check:changesets, the experimental-isolation golden.

vjeux added 15 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A file that reads only defineConsts values from a .stylex.ts module lost
that import under the legacy {stylexOptions} form Storybook uses: the
babel wrapper's StyleX instance ran without treeshakeCompensation (the
unplugin applies its Vite default only to its own instance, which runs
after the wrapper), so once stylex.create was compiled the TypeScript
transform dropped the import and the defining module never reached the
build. A constant used as a media-query key failed the build with
"Invalid empty selector"; one used as a value shipped as a custom
property nothing declares. The wrapper now gets treeshakeCompensation by
default.

The layer split also substituted constants only within each layer, so a
constant defined on the library side and read on the product side stayed
a placeholder in either form; every layer now sees every constant.

A production build now warns, naming the variable and the rule that reads
it, when the StyleX stylesheet reads a generated custom property nothing
declares; undefinedVariables: 'error' fails the build instead, and this
repo's Storybook and Sandbox configs set it so CI catches the next case.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
@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 25, 2026 7:10pm 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:43 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 24, 2026
vjeux added 6 commits October 23, 2026 23:46
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.
@vjeux
vjeux force-pushed the uikit/c20-contextmenu-scrub branch from 11d17f0 to 30320a7 Compare October 24, 2026 07:08
vjeux added 6 commits October 25, 2026 00:11
# Conflicts:
#	packages/core/src/ContextMenu/ContextMenu.spec.md
#	packages/core/src/DropdownMenu/DropdownMenu.spec.md
#	packages/core/src/DropdownMenu/modules/useMenuPress.spec.md
#	packages/core/src/Popover/Popover.spec.md
…gesture constants

The menu press model is shared code (hooks), and a component's motion
table belongs to the component that animates with it (the table-import
rule): the drift allowance and the opening finger's handoff move from
`motion/uikit/menu.ts` to `UIKIT_GESTURES.menuPress` (gestures.ts), the
guess with its measurement (M-G20). spec:AST-094 FR29 (meta#8378): a tap in a
scroll view that ends before the content-touch delay opens the menu at its
lift, now under test.
… the menu

The module-internal useDropdownMenuPress returns continueGesture again: a
pointer that went down elsewhere (a context menu's long press) is handed to
the menu as it opens and becomes its opening press. A finger picks only
after UIMenu's handoff distance, counted from the first position the menu
sees after it opened (as spec:AST-094 FR29 counts a trigger's opening
finger), whatever the touch model; a lift before that acts on nothing and
leaves the menu open. A mouse follows the settle rule. Ignored while another
gesture is live or the hook is disabled.

The public useMenuPress has no continuation, in its type or its return.
…ts module

Through the uikit barrel it reached every table module, including the menu
table, which the table-import rule reserves for the component that animates
with it once that component imports it.
…action and picks it

UIKit's touch continuation: when the menu opens under a finger that is
still down, ContextMenu hands that finger to the menu's press model
(useMenuPress.continueGesture), found by the gesture host's pointerIdOf.
Once it has moved UIMenu's handoff distance in the open menu, the
highlight follows it and the row under its lift acts. A lift before that
picks nothing and leaves the menu open. The touch story shows the last
action picked.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c20-contextmenu-scrub branch from 86076e9 to a885d27 Compare October 25, 2026 09:07
@cixzhang

cixzhang commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Requested change: slide-to-pick should cover every touch device, as spec:AST-094 FR26–FR29 and DEC-1/FR1 state, with no iPhone/iPad gate. It's the same ruling as on #8187.

The release note and a test header still described an iPhone/iPad gate that
the code no longer has: a finger still down when the menu opens slides onto
a row and picks it on every touch device, iOS and Android alike
(spec:AST-094 FR13, FR26-FR29), as the suites run on both.
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 84a8bad. The code had no device gate left: a finger still down when the menu opens slides onto a row and picks it on every touch device, iOS and Android alike (spec:AST-094 FR13, FR26–FR29). The release note and a test header still described the old iPhone/iPad gate; they now say what the code does.

The jsdom suite runs the slide cases on iOS and Android stubs, the interaction suite runs them in Chromium and WebKit under each engine's own finger, and the Android 15 clip in the description shows the slide picking Share.

The opening finger's handoff (10 pt from the first position the open menu sees) is AST-094 FR29 and the menu press model's own rule in #8078; ContextMenu only hands the finger over.

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

This pull request is on hold for an engineering owner's decision: letting the finger that opened a ContextMenu slide onto an action is a clear improvement on touch screens, but a lift before that finger has moved 10 px picks nothing even over a row, which departs from the current useMenuPress FR1 ("A row MUST act when a press is released over it"), and the branch adds spec text with no exact-head owner approval yet (AST-094, the rewritten useMenuPress FR1 and its new FR11, and edits to nine more records). An engineering owner can settle it by approving or amending that text and commenting /approve-spec 84a8bade625b8d33f5981aa164ae15a1df56e1c1 on this pull request; the author may do so as an engineering owner. Independently of that decision, please narrow this pull request to the ContextMenu change once its dependencies land (it carries about 38,700 authored lines today) and fix these:

  • presentation="popover" no longer opens the menu from a held finger, so on iPhone and iPad that ContextMenu cannot be opened by touch; keep the previous long press there, as AST-017 FR3 requires of the restoring value.
  • ContextMenu's import carries the touch layer statically, including the whole springs table; load it lazily for coarse pointers or justify the cost, as component-style-authoring INV6 requires.
  • The proposed useMenuPress delay row gives FR29's values as the default, while the public hook keeps 500 ms.

[Automated review]

import {PRESSED_ATTRIBUTE, type pressableProps} from '../utils/pressFeedback';
import {createAnimator, type Animator} from '../motion/animator';
import {resolveMotion} from '../motion/resolveMotion';
import {registerSpringsTable, springs} from '../motion/uikit/springs';

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 imports, and registers at line 111, the whole springs table (navigation, sheet, alert and switch rows included), while the feedback reads only springs.contextMenuDismiss (line 197). architecture:component-style-authoring INV6: a shared tuning table "ships to a component only the rows that component uses". Import only the row the feedback reads.

| highlight | DOM focus, or a caller callback | one lit row; focus with `preventScroll` in a menu, `aria-activedescendant` in a picker | focus | `module:DropdownMenu/useMenuPress` | stable |
| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable |
| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed |
| long-press delay | as `spec:AST-094` FR29 states (none in a fixed bar; 150 ms in a scroll view) | proposed: how long a held finger waits on a trigger before the menu opens (`spec:AST-094` FR29). A caller that sets `longPressDelayMs` gets that wait in a fixed bar and in a scroll view alike, which keeps the released behavior. | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | proposed |

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 row gives FR29's values as the long-press delay's default, but the public useMenuPress keeps MENU_PRESS_LONG_PRESS_MS = 500 (useMenuPress.ts:104), as its doc and .changeset/menu-press-uimenu.md say; FR29's values apply only to the internal uikit touch model. State 500 ms for the public hook and FR29's values for the internal model.

vjeux added 5 commits October 25, 2026 11:31
… long press

With presentation="popover", or where the primary pointer is fine, a finger
held on the trigger opens the menu at the touch point after 500 ms, as main
does (useLongPress: the timer, the move cancel and the cleanup). The touch
model replaced that long press with UIKit's timeline and left a finger
outside it to the browser, so on iOS Safari, which sends no contextmenu for
a long press, presentation="popover" opened nothing. The consumer's touch
handlers still run. The doc, the release note and the story say what each
device gets.
The same held finger opens the menu at UIKit's 735 ms where the primary
pointer is coarse and at today's 500 ms where it is fine, with nothing else
changed (spec:AST-094 FR13, FR33).
… the touch model) into the slide

# Conflicts:
#	packages/core/src/ContextMenu/ContextMenu.tsx
The spec is cixzhang's record, now on main; code cites it by id.
FR29 covers DropdownMenu's trigger only; FR33 states the handoff after a
held finger opens a ContextMenu.

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

This is on hold for an engineering owner's decision: the ContextMenu slide itself follows spec:AST-094 FR33, now current on main, but the branch adds useMenuPress rules that current spec does not state yet (FR9's touch model, FR10, FR11 and AR5). An engineering owner can approve or amend them by commenting /approve-spec 098481492634dc5cb5727fdc9726a111ca2c94b6 on this pull request (the author may do so as an engineering owner), and the rebase this branch needs will ask for that again on its new head. Independently of that decision, please:

  • rebase onto main once the nine predecessor pull requests land, so this one carries only the ContextMenu change (it carries 38,503 authored lines today);
  • keep main's text where your edits to the user-states, ContextMenu, DropdownMenu, useMenuPress and Popover records conflict with it, since the branch's versions contradict AST-094 FR11, FR29, FR32 and its menu scope;
  • load ContextMenu's touch layer lazily for coarse pointers or justify its cost, and import only the dismiss spring (component-style-authoring INV6);
  • add real-device iOS Safari and Android Chrome recordings of the slide, as AST-094 asks.

[Automated review]

| FR6 | Component anatomy contains the caller trigger and content, one painted Popover surface, and the optional fallback close control. Popover owns no Header, Body, or separate shared-hook surface part. | Current source, docs target inventory, and tests | Accepted anatomy; stale consumer anatomy corrected by this contract |
| FR7 | The painted surface has one broad canonical target, `popover`; composed components may add one authoritative component-specific refinement target. | Owner direction on 2026-08-31 plus current target inventory | Accepted canonical contract; covered by focused target tests |
| FR8 | The surface padding is one spacing-scale rung applied on the painted surface. Popover defaults to step 3 (the released inset); `0` paints a flush surface; a direct `usePopover` composition paints none unless it asks for a rung. The rung sits where a theme's `padding` on the `popover` target replaces it. | PR #6683 and its Popover tests; `architecture:container-padding/INV8` (padding alone does not enroll) | Pending owner review (DEC-4); implemented and covered by the Popover surface-padding suite |
| FR9 | Under a finger, Popover grows out of its trigger and shrinks back into it, a pan outside dismisses it while its outside dismissal is on, and a touch during its growth is ignored, as `spec:AST-094` FR31, FR32 and FR35 state. A mouse keeps today's entrance and dismissal. | `spec:AST-094` FR31, FR32, FR35 | Pending implementation; tests land with it |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-094 FR32 says “A Popover's dismissal is unchanged.” This row adds a pan-outside dismissal for Popover and cites FR32 for it; main's FR9 keeps the dismissal unchanged. Please take main's text here when you rebase.

| FR9 | Under a finger a tap on the trigger MUST open the menu through its click. A press held on the trigger for the long-press delay MUST open the menu with the finger down, prevent the default of the finger's later `touchmove`s until the gesture ends, and continue under FR1–FR4 with the same settle rule. | Proposed DEC-4; `DropdownMenu.test.tsx` held-touch case, `useMenuPress.test.tsx` | proposed; pending owner review |
| ID | Invariant | Basis | Verification state |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| FR1 | Under a mouse, a row MUST act when the press is released over it, never because the press began on it; a press that never left its row is a click on that row. Under a finger or pen, `spec:AST-094` FR26 governs. | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-094 applies FR26–FR30 only where the primary pointer is coarse; “where the primary pointer is fine, a finger in a menu keeps today's behavior”. Limiting FR1 (and FR2, FR6, FR7) to a mouse leaves that finger with no rule. main's FR1 keeps the coarse/fine split.

| FR2 | The current `context-menu` target remains on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | Current source, docs, and tests | Current inventory; verified |
| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | Current delegation; verified |
| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | Current branches; verified |
| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. Under a finger the press follows `spec:AST-094` FR26–FR30, and the menu's grow, dismissal, long-press lift and outside press follow FR31–FR33 and FR35; a mouse press outside is unchanged. Right-click, Shift+F10, long-press invocation and keyboard navigation keep their existing behavior. | `module:DropdownMenu/useMenuPress`; `ContextMenu.test.tsx` press model suite | Current module projection; verified in jsdom |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-094 FR29 “covers DropdownMenu's trigger only; ContextMenu, sub-menu rows and Selector keep their own opening”, so ContextMenu's press follows FR26–FR28 and FR30, as main says. Dropping “(pending implementation)” also claims FR31, FR32 and FR35, but the outside dismissal still listens for mousedown only.

import {PRESSED_ATTRIBUTE, type pressableProps} from '../utils/pressFeedback';
import {createAnimator, type Animator} from '../motion/animator';
import {resolveMotion} from '../motion/resolveMotion';
import {registerSpringsTable, springs} from '../motion/uikit/springs';

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 imports, and registers at line 111, the whole springs table while the feedback reads only springs.contextMenuDismiss (line 197). architecture:component-style-authoring INV6: a shared tuning table “ships to a component only the rows that component uses”, and a flagged touch-only feature loads lazily or justifies its cost.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

The useMenuPress record rules this PR proposed moved to their own spec change, #8451, where Cindy decided them: a sub-menu row opens only on a finger's lift, never on a rest; the handoff follows spec:AST-094 FR33; and a screen reader hears one open and one close. Please drop the record edits here and build against #8451 and current AST-094.

vjeux, could you update the PR to match these?

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's decision on context menus: a public, framework-agnostic createContextMenu(element, {items | render, onOpenChange?, presentation?}) → {open(at?), close(), destroy()} and a thin useContextMenu(ref, opts) hook, with <ContextMenu> rebuilt on them. Row menus attach to the list item element itself, so ul > li stays valid. They get a full-row hit area, open on long-press, right-click or Shift+F10, show press paint and lift on the row, and return focus on close. The records are being written now.

For this PR, a small change:
the action-group tracking moves into the factory unchanged. It tracks the finger across the open menu's items, so only its call site moves.

@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:31

This branch was successfully deployed

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