Skip to content

feat(core): one shared context request from a long press, a right click and Shift+F10 (spec:AST-094 FR43) - #8670

Draft
vjeux wants to merge 1724 commits into
meta:mainfrom
vjeux:uikit/f2-context-request
Draft

vjeux wants to merge 1724 commits into
meta:mainfrom
vjeux:uikit/f2-context-request

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8665. Review only this pull request's change: vjeux/astryx@c6c02fc...b361a9c

Why

A context menu should open the same way from a held finger or pen, a right click, and Shift+F10 or the context-menu key. Today each component wires its own handlers, so the rules differ: a held finger on Android can open the browser's own menu beside Astryx's.

What led to it

The approved gesture spec asks for one shared context request (spec:AST-094 FR43); the public headless context menu (spec:AST-107 FR8) and row menus build on it.

Options and tradeoffs

  • Keep each component's own handlers: the rules stay duplicated and drift.
  • Expose the request publicly: spec:AST-107 FR8 already makes the menu the public layer.
  • One internal attachContextRequest(element, options) that components and that menu share.

Why this choice

One place decides. It reports one request per gesture, naming its input (a hold, a pointer, or the keyboard), so a keyboard-opened menu can focus its first row. A mouse held still makes no request (it selects and drags). The hold loses to a claim another recognizer already made, such as a reorder handle's. A stepper that keeps its press owns only its hold: its right click and Shift+F10 still reach the request. The browser's own menu is refused once: a finger's while that finger is down inside the element, a pen's during any hold of it there and right after its lift. Inside nested requests, a right click or a key reaches only the innermost. It listens on its element only, and only while enabled (FR24).

How to check it

  1. On a phone, long-press a list row that has a context menu. Expected: one menu, and no browser callout.
  2. Right-click the row, also while a finger rests elsewhere. Expected: one menu at the pointer.
  3. Focus the row and press Shift+F10. Expected: the menu opens with its first row focused.
  4. Right-click a row inside a card with its own menu. Expected: only the row's menu.
  5. Hold a stepper's button with a pen. Expected: it repeats; no menu opens.

Evidence

  • Unit cases fail before each change: a hold ignoring a kept press, a mouse hold requesting, the browser menu left on, a pen's menu after another participant's hold, nested requests answering twice, a finger elsewhere refusing a right click.
  • In Chromium and WebKit, a held finger, a right click and Shift+F10 on one row each make exactly one request, naming their input.
  • Vercel's build failed on an earlier head (e6a352c); its exact build command passed locally, the deploy logs need team access, and this head's build succeeds.

vjeux and others added 30 commits October 26, 2026 19:07
# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
…t stream

Ports cixzhang's meta#8072 action-group press (d2c3c78) onto this pull
request's shared input stream, with no change to what it does. Its
per-press listeners were registered straight on the document and the
window with capture constants this layer no longer has, so the merged host
failed to compile, and its window click listener would have doubled the
input stream's click subscriber (two onClick calls per click once the host
installs, and a listener its guard never removed).

The press now subscribes its pointer's moves and lift and the window's own
blur through subscribeInput for as long as it is held. It subscribes to
click only while the host is not installed, drops that subscription after
the lift's task, and an installing host takes it over, so a click is heard
once. Her two host tests read the press's listeners on the window, where
the stream registers them; a new case counts onClick per click before and
after the host installs mid-press, and fails against each of three mutants
(no takeover, no drop, no subscription).
…uard

At this layer the host has no click listener of its own: the click guard
(utils/clickGuard.ts) hears every click through one listener per window.
cixzhang's meta#8072 action-group press, ported onto the input stream below,
still disarmed the host's swallower and subscribed the host's onClick, so
the merged host failed to compile. The press now disarms the click guard
when it begins and subscribes no click: what it does is unchanged.

The click-count case becomes: a group press adds no click listener of its
own, before or after the host installs mid-press. GestureHost.test.ts
resets the click guard after each case, so a guard one case armed no
longer keeps its pointerdown subscription into the next (it hid the coarse
host's pointerdown listener from a later case).
… that finger until it lifts (spec:AST-094 FR14)

A recognition that claimed its touches and was then cancelled gave them
back to the browser unless another recognition owned them, so a finger
resting there could pinch-zoom the page. A gesture that cancels itself on
a finger's move, a long press dragged past its tolerance after it began,
gave back a finger that was dragging: the host stopped cancelling its moves
and the page scrolled the rest of the drag, a hand change FR14 forbids and
UIKit never makes (its scroll view never takes a touch another recognizer
had). A cancel during a finger's move now keeps that finger claimed until
it lifts; a resting finger is given back as before, and a waiting
recognition that fails still gives its claim back.

The new arena case fails on the previous code: the dragging finger reads
unclaimed right after the cancel.
… scroller under it is at the edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628): a scrollEdge pan may
cancel its touch's moves from the first one, only while every scroller
under the finger sits at its edge and the finger moves away from it, and
only the moves the browser would rubber-band; it still begins at FR17's
10 px. The first case reads the first 2 px move cancelled at the edge and
fails on this head; the others keep what must not change: room to scroll
leaves every move to the browser, a first move toward the content too, a
pan without scrollEdge cancels nothing before it begins, and a touch the
browser took for a scroll stays the browser's when the list reaches its
edge.
… cancelled at the edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628). The sheet's scroll-edge
pan at the top of its content: every touchmove the page sees is cancelled,
the first one included. WebKit sends that first move at 2 px, before the
sheet's 10 px begin, and it reaches the page uncancelled on this head;
Chromium sends none inside its own 15 px slop, so there it proves only
that nothing scrolls.
…e away from an edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628). GestureRecognizer gains
keepsTouch(touchId), false by default and reached by the host through
recognizerInternals: whether it holds that finger before it begins. A
scrollEdge pan does while it is possible, the touch is a finger the browser
has not taken, its travel so far goes the pan's own way, and no scroller
under the finger can scroll for it (the probe its begin already reads), so
only moves the browser would rubber-band are held, never a real scroll.
The host's blocking touchmove check cancels a move a participant holds, as
it does a claimed one; the arena already holds that move when the check
runs, since the host feeds it from its window-capture subscriber first.
The pan still begins at 10 px; a mouse or a pen is never held. The README
says so.
…straight, (7, 7) and (8, 8), and the screen-edge band

The FR18–FR22 verification row's real-touch states, in the gesture browser
spec on a bare page:
- a finger moved 9 px straight or (7, 7) over a scroll view keeps its tap
  and scrolls nothing; one moved 10 px or (8, 8) is a tap or a scroll,
  never both; a tap never cancels a touchmove;
- a screen-edge pan on a strip as wide as its band, landing 40 px from the
  edge, has not claimed at 14 px of travel and claims at 15;
- a touch landing 41 px from the edge never begins the edge pan, and the
  horizontal scroll it starts is not blocked.
The bare fixture exposes the tap and screen-edge pan recognizers.
…edge-zone strip decides at UIKit's 15 px

The host's arena environment had no viewSize, so under the real host a
screen-edge pan never saw its edge zone while a touch was down and decided
at 10 px, while its attach-time touch-action (read through the default
environment) had already claimed the axis (spec:AST-094 FR18).

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…fter layout rounding

Lane 0's real-host browser spec found a strip authored at the 40.2 px edge
region lays out at 40.203 px (layout snaps to 1/64 px), so the strict
comparison read it as off the zone and the pan decided at 10 px instead of
UIKit's 15 (spec:AST-094 FR18). The zone takes half a pixel of tolerance:
the rounded strip is the zone, a view a pixel wider still is not.
Failing-first test at 40.203 px: it claims pan-x and decides at 15.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…w) as a dependency

# Conflicts:
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
…now that a strip within half a pixel is the edge zone (meta#8061)
…, and under inputMode 'pointer-events' a claimed finger's touchmoves are cancelled

- spec:AST-094 FR20 and DEC-8: a native scroll above an unclaimed finger
  counts as the browser taking it only once that finger has travelled the
  take distance (10 px) from where it landed. A scroll under a still finger
  (scroll anchoring, a carousel advancing, a transcript pinned to its end)
  is the page's own, and the touch and its click stay. (The same rule
  meta#8309 carried, moved down to the host that introduces it.)
- spec:AST-094 FR19 and FR21 under inputMode 'pointer-events': a finger
  comes from its pointer events, so its touches carried no id of ours and a
  claim never cancelled them. The blocking touchmove listener now matches a
  touch to the nearest live pointer-fed finger on its target, so a claimed
  pan's moves are cancelled and the browser cannot scroll under it.

Tests (failing first): 9 px and (7, 7) are not taken, 10 px and (8, 8) are,
a still finger is not; the FR30 own-scroll case moves its finger first; under
'pointer-events' a pan's touchmoves pass before its claim and are cancelled
after it.
…n: pan-y only on a strip within its band, left as is on a wider view
…r-events claims cancel) as a dependency

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
…8072 9800eb5, the FR18-FR22 browser states) as a dependency
…eta#8061 9adbe2d, meta#8072 9800eb5) into the gesture watcher

The README's touch-action table takes meta#8061's edge-pan rows beside this
PR's watcher row.
…es its recognizers, and the group keeps answering afterwards

Under a fine pointer the host is not installed, so usesTouchEvents keeps
its initial true, and a group press's own touch pointermove and pointerup
went down the touch-pairing path and returned: the finger's moves and lift
never reached the group, its contact stayed in the arena, and the group
answered no press after it, a mouse's included. The group press now feeds
its own events through the pointer path for every pointer type.

Test (failing first): under a fine pointer a finger tap on a group
recognizes, and a mouse tap afterwards does too.
… are all passive

spec:AST-094 FR20: a watcher registers only passive listeners, but a
watcher-only page still got the host's cancelable document touchend and
its contextmenu and window click listeners. The document touchend is now
passive until an enabled claimer (a recognizer that claims touches, or a
consumer) is attached, and the contextmenu and click listeners are on only
then, swapped as attachments change. The new test (a lone watcher leaves
no cancelable document or window listener; a claiming pan brings the
three) fails on the previous head.
…r watchers) into the host line

The host keeps this PR's isRtlElement and its fingers held before a
recognizer begins (a kept press, a scroll-edge pan), beside meta#8061's view
size and meta#8309's pointer-events finger lookup. meta#8589's claimer check
counts only participants active on this pointer (isActive,
isConsumerActive), and a group press's click listener now stays while a
claimer keeps it installed. The browser spec keeps the fine-pointer
describe beside the FR18-FR22 real-touch states.
…'s passive-only watcher listeners) into the arena fixes

The host-owner changeset keeps this PR's own listeners for the scroll
lock and finger-scroll watch, and adds meta#8336's scroll-edge hold.
…inter reaches its recognizers) as a dependency
… under a fine pointer) as a dependency
…tion group's finger under a fine pointer) into the gesture watcher
Main's meta#8521 is its head 25b468c squashed, which this pull request already
carries: every file it touches is byte-identical to 25b468c. In the two
files that conflicted (the motion README and animator.ts), the resolution is
this side's version, main's merged meta#8521 with this pull request's later work
on top. Nothing of main is dropped.
vjeux added 12 commits October 29, 2026 10:12
Where a moving element is drawn away from its layout box, the host
hit-tests what is drawn under the finger with the document's
elementFromPoint, which answers a shadow host for anything inside its
open root. The hit test now descends through each open root's own
elementFromPoint and compares with the movers along the flat tree, so a
recognizer on a control inside a moving sheet's shadow root gets its
finger and the tap clicks that control.
…nt lets go

The host's touch-action rules sit in an adopted sheet or a style element
per document or shadow root, and were never removed, so a page whose
last recognizer detached kept them, against spec:AST-094 FR24 (nothing
touch-related remains after the last instance unmounts). Fails until a
root's rules go with its last marked element.
…th its last marked element

The touch-action writer records which elements it marked in each
document or shadow root, and when the last one is unmarked (its last
recognizer detached) it removes that root's adopted sheet or style
element, so nothing touch-related remains after the last instance
unmounts (spec:AST-094 FR24). A later mark writes the rules again; an
element marked in another root first lets go of the old one.
… the inline style it reads

- The motion registry, the host's engine gate and the README name the
  presented hit test as an engine workaround (spec:AST-013 FR6) with its
  review condition (re-measure on each iOS release; remove it once iOS
  WebKit hit-tests a compositor-moved element where it is drawn) and its
  regression fixture.
- While a curve runs the animator holds the value it starts from inline,
  so presentation.ts and the README no longer say the inline style holds
  the end.
…core/internal/touch

Core ships the internal entry @astryxdesign/core/internal/touch, which
re-exports the gesture primitives for Astryx's own packages, and the
Lab-import guard fails a Lab import of core outside core's exports
(architecture:react-component-runtime INV13). The README and the barrel's
header named internal workspace imports and said nothing here is
exported; they now name that entry.
…ill needs the element, and the listeners that cancel

The release note, the stream's header, the host's comment and the README
said the stream is one passive listener per event type and that a
document-capture stopPropagation no longer starves the recognizers. Such a
stop never hid a listener on the same node, and some subscriptions cancel.
They now say what changed: an app's window-capture stopPropagation, or a
document-capture stopImmediatePropagation registered before core, no
longer hides a touch; a recognizer that must claim still needs the touch to
reach its element; the gesture host's touchend, click and contextmenu
cancel while a claimer is attached and the page hold's touchmove while it
holds; and the stream stays ahead of an app's window-capture listener only
until a type's listener is re-added. The README's list of blocking
touchmove listeners gains the claimed touch's target and the document
while a claiming element moves.
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
meta#8360 now carries the review fixes of the foundation it holds. The two
conflicts are this pull request's own files:
- GestureHost.ts: meta#8360 counts the attachments that ask for an action
  group; here the fine-pointer group listener and the per-event recognizer
  set are gone (every pointer is served, FR43), so the merge keeps the
  count and drops those two.
- GestureHost.test.ts: both sides added the same assertion to the
  second-finger case; the merge keeps this side's comment. meta#8360's
  action-group lifetime case counts a group's own pointerdown under a fine
  pointer, which this pull request replaces with one passive pointerdown
  on any element that uses a gesture (its own idle-listener case), so the
  merge leaves it out.
… attached, in this host's terms

meta#8360's action-group lifetime case pins that an element stays a group
while any attachment asks and stops with the last. Here every element
that uses a gesture is served as a group under a fine pointer, so the
same invariant reads as the element's one passive pointerdown: with two
gestures attached, detaching either keeps it, and detaching the last
removes it. It passes on this head.
meta#8665 now carries meta#8360's new head (75392b2), with the review fixes of
the foundation meta#8360 holds. The merge is clean.

@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 about the new spec:AST-094 FR41 sentence ("A mouse or pen drag that a recognizer claims suppresses text selection for that drag only; an unclaimed mouse drag keeps today's behavior."), which no current record states yet; an engineering owner records it by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or the sentence lands with #8665. Independently of that decision, the context request and its scope need these changes:

  • Land #8665 and its ancestors first, so this diff is the context request's 685 lines rather than 28,851.
  • Refuse a pen's own contextmenu during and right after any hold of that pen inside the element, whoever kept it, since a pen held on a stepper or on a claiming handle still opens the row's menu; add pen tests for both.
  • Let a right click or Shift+F10 inside nested requests reach only the innermost one, as a hold already does.
  • Refuse a menu event only for a finger down inside the element, since a right click while a finger rests on another gesture now opens nothing.
  • Update keepsPressBetween's JSDoc, which still says the control keeps its right click and Shift+F10.

[Automated review]

Comment on lines +146 to +149
(pointerType === 'pen' &&
((held?.pointerType === 'pen' &&
(recognizer.state === 'began' || recognizer.state === 'changed')) ||
time - lastPenHoldAt < MENU_ECHO_MS))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This refusal keys on this request's own hold: held is set only when the hold receives the touch (lines 95-101), and lastPenHoldAt only when that hold ends. A pen held on a stepper inside the element, or on a handle whose own hold claims first, still turns the pen's own contextmenu into a pointer request (a test with this file's fixtures gets pointer:pen in both cases), while a finger's is refused at line 142. Refusing a pen's menu event during and for 500 ms after any hold of that pen inside the element, with pen cases for the stepper and the claiming handle, would match FR41's "A pen's hold acts as a finger's".

}
});

const onContextMenu = (event: Event): 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 listener, like onKeyDown at line 162, answers every bubbling event, so a request on an ancestor also answers a right click or Shift+F10 an inner request already answered: in Chromium, one right click on a row inside a card that has its own request makes two requests, while a hold on the same spot resolves to one arena winner. Marking an answered event (a module-level WeakSet) and skipping it here would keep one request per gesture.

// a key's menu event is that key's echo.
if (
pointerType === 'touch' ||
host.fingersDown() > 0 ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

fingersDown() counts every finger the host hears, whatever its target. A mouse right click on this element while a finger rests on another gesture is refused here, after line 136 already cancelled the browser's menu, so nothing opens. host.isFingerDownIn(element) would keep the touch-echo refusal to this element's own finger.

Comment on lines +1706 to +1711
/**
* @internal Whether a press consumer that keeps its press (a hold-repeat
* control, `keepsTouch`) sits on the path from `target` up to `boundary`,
* both included: such a control keeps its long press, right click and
* Shift+F10 its own (contextRequest.ts, `spec:AST-094` FR43).
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This still says the control keeps its right click and Shift+F10 its own; they now reach the request (contextRequest.ts lines 130-131 and 166-167). The method also sits between fingersDown()'s JSDoc (lines 1698-1705) and fingersDown() (line 1732), so that method loses its documentation; moving it above that comment fixes both.

Comment on lines +13 to +16
* Touch work runs only where the primary pointer is coarse; where it is
* fine, no recognizer attaches and no touch-only listener is registered, so
* a finger on a touchscreen whose primary pointer is fine gets the browser's
* defaults (`spec:AST-094` FR19, FR24). The query is TOUCH_QUERY

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 header still says no recognizer attaches where the primary pointer is fine; spec:AST-094 FR19 now says recognizers attach for every pointer, and the gesture host does. Worth updating with #8665.

Comment on lines +471 to +473
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
suppresses text selection for that drag only; an unclaimed mouse drag keeps
today's behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This sentence states a requirement main does not have yet, so it needs an engineering owner's /approve-spec <full head SHA> on this pull request, or it lands with #8665. It also leaves open whether a selection made before the drag survives it: lockDragSelection clears every selection when the drag is claimed (utils/selectionLock.ts:148).

vjeux added 11 commits October 29, 2026 12:16
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
meta#8360's input-stream notes no longer list click among the gesture host's
subscriptions. The merge is clean.
… lift and the browser's menu, keeps a kept press's finger, and goes when the page hides

From the review of this pull request, three cases fail on its head:
- an armed press installed the page listeners without the claiming
  ones, so a claimed finger's touchend stayed passive and the browser's
  menu opened during a claimed long press (spec:AST-094 FR21);
- a kept press got its blocking touchmove during an armed press, but its
  moves were cancelled only where the primary pointer is coarse, so a
  held stepper on a touchscreen laptop lost its finger (FR11, DEC-21);
- a page hidden mid-press cancelled the press but kept the page's
  listeners until the next lift anywhere (FR24).
…lift and the browser's menu, keeps a kept press's finger, and goes when the page hides

- A finger's or a pen's armed press now brings the claiming listeners
  (the cancelable touchend and the contextmenu canceller) with the page
  listeners, as wherever the host installs, so a claimed lift clicks
  nothing and a claimed hold opens no browser menu (spec:AST-094 FR21). A
  mouse's press needs neither and adds neither.
- A kept press keeps its finger under any pointer: its target blocks and
  its moves are cancelled once its press is delivered, not only where the
  primary pointer is coarse (FR11, DEC-21).
- A page hidden mid-press releases the armed press as a blur does, so the
  page's listeners go with the cancelled press (FR24).
…'s selection

An action group's press is a long press with no minimum duration, so it
claims at the press's pointerdown, before any movement. The drag
selection hold then cleared the page's selection, so a plain click on a
group's button left its Copy or Cut with nothing to act on. The case
fails on this head: the selection is gone after the pointerdown.
…age's selection

The drag selection hold cleared the page's selection when any mouse or
pen contact was claimed, but an action group's press claims at its
pointerdown, before it has moved or selected anything, so a plain click
on a group's button left its Copy or Cut with nothing to act on. A claim
before any movement now keeps the page's selection: the hold still
refuses a new one, and puts the page's back over a selection that
appears while it runs. A claim after movement clears what its drag
selected, as before (spec:AST-094 FR41).

The case before the new one now waits for its hold to end a task after
the lift, so no hold outlives that test.
…t every pointer does

- GestureHost's header, the gestures README and coarsePointer.ts no
  longer describe the coarse-only host: a finger or a pen takes the
  touch-down lock under any primary pointer, a kept press's finger is
  kept during a fine pointer's press too, and recognizers attach for
  every pointer while the page listeners last a press (spec:AST-094 FR19,
  FR24, FR41, FR43).
- The fine-pointer selection case asserted the replaced rule and passed
  only because it read iOS WebKit's inline lock without iOS WebKit: it
  now asserts the lock a fine pointer's finger and pen take, and that a
  mouse takes none.
- FR41's sentence and its verification row say that a press claimed at
  its own pointerdown, before it moves, keeps the page's selection, while
  a drag that moved before its claim loses what it selected.
…ion, in the present tense

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.
meta#8665's review fixes: an armed press under a fine pointer brings the
claiming listeners, keeps a kept press's finger and goes when the page
hides; a press claimed at its own pointerdown keeps the page's selection;
FR41 says what a claimed drag does with the selection; meta#8360's
input-stream notes. The merge is clean.
…he element, nested requests answer once, and only a finger in the element refuses a menu

From the review of this pull request, three cases fail on its head:
- a pen held on a stepper inside the element, or on a handle whose own
  hold claims first, still turned the pen's own contextmenu, during the
  hold or at its lift, into a pointer request (four requests);
- one right click and one Shift+F10 on a row inside a card made a
  request on both, where a hold resolves to one arena winner;
- a mouse right click on the element while a finger rested on another
  gesture elsewhere was refused, so nothing opened.
…e element, nested requests answer once, and only a finger in the element refuses a menu

- The gesture host reports whether a pen that went down inside an
  element is down, and when the last one lifted or was cancelled; a
  context request refuses a pen's own menu event during any hold of that
  pen inside its element, whoever keeps it, and for 500 ms after its
  lift (spec:AST-094 FR43).
- A request marks the menu events and keys it answers: one inside nested
  requests reaches only the innermost, as a hold resolves to one arena
  winner.
- A menu event counts as a finger's only while a finger is down inside
  the request's element, so a right click while a finger rests on
  another gesture still opens the menu.
- keepsPressBetween's JSDoc says the kept control owns only its hold, and
  sits above fingersDown's, which had lost its documentation.
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in this push:

  • a pen's own menu event is refused during any hold of that pen inside the element, whoever keeps it, and for 500 ms after its lift: c8b8ff9 → b361a9c;
  • nested requests answer a right click or Shift+F10 once, at the innermost: the same pair;
  • a menu event counts as a finger's only while a finger is down inside the element: the same pair;
  • keepsPressBetween's JSDoc says the kept control owns only its hold, and sits above fingersDown's: b361a9c.

The coarsePointer.ts header is fixed in #8665, which this carries. The FR41 sentence is #8665's, and its approval stays the owner's decision.

@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 about the new spec:AST-094 FR41 text on claimed mouse and pen drags (“A mouse or pen drag that a recognizer claims takes no new text selection for that drag only. A claim made after the drag has moved clears the page's selection; a press claimed at its own pointerdown, before it moves, keeps it.”), which no current record states yet; an engineering owner records it by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or the text lands with #8665. The nested-request, held-pen and finger fixes to the context request work as intended, and independently of that decision two changes remain:

  • Land #8665 and its ancestors first, so this diff is the context request's 811 lines rather than 29,215.
  • Open the pen's echo window only after a held pen: a pen tap now blocks that pen's barrel right click on the element for 500 ms with the browser's menu cancelled, so nothing opens; add a tap-then-right-click test.

[Automated review]

{id, x: event.clientX, y: event.clientY, time: eventTime(event)},
]);
if (event.pointerType === 'pen') {
this.lastPenLift = {target, time: this.arena.now()};

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 records a lift for every pen contact the host tracked, a plain tap included, so attachContextRequest refuses the pen's next menu event for 500 ms after a tap (contextRequest.ts:156-158), after it has already cancelled the browser's menu: a barrel right click right after a tap opens nothing. Recording the lift only for a pen contact that was held (down at least the long press's 500 ms, or kept or claimed by a participant) keeps the press-and-hold echo refused without it; a tap-then-right-click test would pin it.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…the wheel's and the pen lift's

No behavior change. The touchend listener's registration field, its
sync and its removal now come before the touchmove listener's, and the
claimed lift is recorded before the pointer is forgotten, so this pull
request and its siblings on meta#8665 (meta#8676's wheel listener, meta#8670's pen
lift record) no longer edit the same lines of GestureHost.ts, and a
stack that carries them together merges without a resolution.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38

This branch was successfully deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants