Skip to content

feat(core): gestures attach for every pointer (spec:AST-094 FR43) - #8665

Draft
vjeux wants to merge 7 commits into
meta:mainfrom
vjeux:uikit/f2-every-pointer
Draft

vjeux wants to merge 7 commits into
meta:mainfrom
vjeux:uikit/f2-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8360. This pull request's own commit: vjeux/astryx@a57e57e...b848270

User impact

With a mouse or pen, a drag that a gesture takes (a sheet's, a pager's) selected the page's text on its way and cleared the person's selection. A drag that started on text moved the element instead of selecting it.

Problem and solution fit

The host turned selection off only for a held finger or pen. It now keeps the page's selection at each mouse or pen press and, once a recognizer claims the drag, refuses new selections and restores the kept one. A press on selectable text stays the browser's, so it selects.

Expected behavior and authority

Cindy approved option A. spec:AST-094 FR41 records it in her words: "A mouse or pen drag that a recognizer claims takes no new text selection for that drag and never clears an existing selection; a drag that starts on selectable text is not claimed. An unclaimed mouse drag keeps its released behavior." Its Verification row lists both facts.

Smallest restoration

  • At a mouse or pen pointerdown, the host reads the page's selection and whether the press lands on selectable text.
  • On a claim, lockDragSelection refuses a selection on the claiming elements and the document and restores the kept one, until a task after the lift or a cancel.
  • The arena's internal refusesClaim fails a claim on a moved mouse or pen press over selectable text; claims at pointerdown and a tap's at the lift are unaffected.
  • The last detach ends a lifted drag's hold.

Evidence

  • Before, on fix(core): one click guard for the menu press model and the gesture host #8360's head with real mouse input in Chromium and WebKit: a claimed drag replaced the page's selected heading with a selection over the card and the paragraph above it, and a drag starting on the card's text began the pan. After: both cases pass in each engine.
  • Unchanged: a tap's click keeps its selection; an action group's press keeps the page's selection for Copy; a finger keeps the touch-down lock.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at b848270 (9 files, +956/−16):

Writing rubric results

R1 narration: none (FR41's sentences are in the present tense). R2 research: none. R3 evidence naming a change: none (the Verification row names cases). R4 rejected alternatives: none added. R5 pull request or issue references: none. R6 open questions listing options: none (FR41's question is ruled).

Notes

@vercel

vercel Bot commented Oct 27, 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 Nov 1, 2026 9:24am UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 27, 2026
@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 1, 2026, 9:43 AM UTC

@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 a spec decision. Gestures for every pointer contradict spec:AST-094 FR19 and FR24 and architecture:react-component-runtime INV13 as they read on main, and the FR43 and DEC-21 amendment in #8646 that would allow them is not merged yet. Approval needs an engineering owner to settle that, either by landing #8646's amendment on main or by commenting /approve-spec <full head SHA> on this pull request; the author, as an engineering owner, may do so. Whatever the decision, these still need changes:

  • Land #8360 first, so this diff carries only its own 14 files instead of 27,964 authored lines.
  • Under a fine pointer, give a finger's claiming recognizer the same pre-registered blocking touchmove listener it gets under a coarse one (GestureHost.ts:825-831), with a test.
  • Apply a hold's selection lock to a finger or a pen whatever the primary pointer (utils/selectionLock.ts:62-67), with a test.
  • Either serve hover under a fine pointer or say in the README that it stays coarse-only.

[Automated review]

Comment on lines +825 to +831
const needsListener =
this.isCoarse &&
(registration.recognizers.some(
recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer),
) ||
(registration.consumer?.keepsTouch != null &&
registration.consumer.isEnabled?.() !== false));

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 a fine pointer this leaves a finger's claiming recognizer (an axis-limited pan, a sheet's pull) without a pre-registered blocking touchmove listener. The only non-passive one left is added at touchstart by watchTarget, which spec:AST-094 FR19's known limit says "would hand diagonal drags to the scroll". #8646's FR24 counts "a claiming recognizer's touchmove listener for a finger" outside the one-listener budget, so dropping this.isCoarse && for claiming recognizers, plus a fine-pointer finger test that a claimed touchmove is cancelled, would close this.

Comment on lines +62 to +67
export function locksSelectionFor(
pointerType: string,
view: Window | null | undefined,
): boolean {
return pointerType !== 'mouse' && isCoarsePointer(view);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With gestures attaching for every pointer, a finger's long press under a fine pointer (a touchscreen laptop) now runs the hold, but this gate skips the lock, so the browser's text-selection start and callout begin too, which spec:AST-094 FR41 rules out for an element whose hold does something. Locking for a finger or a pen whatever the primary pointer (a mouse stays exempt), and updating the comment above, would fix it.

Comment thread packages/core/src/gestures/README.md Outdated
| `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` |
| `RotationGestureRecognizer` | `UIRotationGestureRecognizer` | continuous | `rotation` in radians, unwrapped, clockwise positive; `velocity` in rad/s |
| `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges` including `leading` / `trailing` (resolved against the writing direction); fails as soon as the touch lands outside |
| `HoverGestureRecognizer` | `UIHoverGestureRecognizer` | continuous | a mouse or a hovering pen; silent for fingers |

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 host serves hover only where the primary pointer is coarse (GestureHost.ts:1267, :1395-1397), so on a desktop a hover recognizer never begins. Either say here that hover stays coarse-only, or serve it under a fine pointer.

@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 a spec decision: gestures for every pointer contradict spec:AST-094 FR19 and FR24 and architecture:react-component-runtime INV13 as they read on main, the FR43 and DEC-21 amendment in #8646 is not merged, and clearing the selection of a mouse or pen drag a recognizer claims also departs from FR41's "a mouse keeps its released behavior", which #8646 leaves unchanged. Approval needs an engineering owner to settle both, either by landing the amendment on main with FR41 covering drag selection, or by commenting /approve-spec <full head SHA> on this pull request; the author, as an engineering owner, may do so. Whatever the decision, these still need changes:

  • Land #8360 first, so this diff carries only its own 17 files instead of 28,460 authored lines.
  • Under a fine pointer, make a kept press's blocking touchmove (GestureHost.ts:832-833) actually keep the press, or stop registering it, and make the host header and README say which, with a test.
  • Apply a hold's selection lock to a finger whatever the primary pointer (utils/selectionLock.ts:62-67), with a test.
  • Either serve hover under a fine pointer or say in the README that it stays coarse-only.
  • Apply isPrimaryClick to a pen as the README says (GestureHost.ts:1132, :2198), or correct the README.

[Automated review]

Comment on lines +832 to +833
(registration.consumer?.keepsTouch != null &&
registration.consumer.isEnabled?.() !== false);

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 a fine pointer this now registers the blocking touchmove for a keepsTouch press tracker, but cancelIfClaimed still cancels a kept press's moves only where the pointer is coarse (:1771-1773), and the target listener's blocking flag is coarse-only too (:1605). On a touchscreen laptop the listener makes scrolls that start on the control wait for the main thread without ever keeping the press. Either drop those two coarse gates (and have #8646's FR24 budget the listener) or restore this.isCoarse && here; the header at :21-23 and README.md:320-322 should then say which, with a fine-pointer kept-press test.

pointerType: string,
view: Window | null | undefined,
): boolean {
return pointerType !== 'mouse' && isCoarsePointer(view);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With gestures attaching for every pointer, a finger's long press under a fine pointer (a touchscreen laptop) now runs the hold, but this gate skips the lock, so the browser's text-selection start and callout begin too, which spec:AST-094 FR41 rules out for an element whose hold does something. Locking for a finger whatever the primary pointer, keeping a mouse exempt, and updating the comment above and the README, would fix it.

Comment thread packages/core/src/gestures/README.md Outdated
| `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` |
| `RotationGestureRecognizer` | `UIRotationGestureRecognizer` | continuous | `rotation` in radians, unwrapped, clockwise positive; `velocity` in rad/s |
| `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges` including `leading` / `trailing` (resolved against the writing direction); fails as soon as the touch lands outside |
| `HoverGestureRecognizer` | `UIHoverGestureRecognizer` | continuous | a mouse or a hovering pen; silent for fingers |

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 host serves hover only where the primary pointer is coarse (GestureHost.ts:1269, :1397-1399), so on a desktop a hover recognizer never begins, while this row and the host header now promise every pointer. Either say here that hover stays coarse-only, or serve it under a fine pointer.

}
// A right click, or a ctrl-click on macOS (its context click), starts no
// press (FR43).
if (event.pointerType === 'mouse' && !isPrimaryClick(event)) {

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 README (README.md:247-249) and isPrimaryClick's JSDoc say a pen's barrel button starts no press, but this check, like the one at :1132, applies to a mouse only, so a pen pointerdown with its barrel button (button 2) still begins a pan or a press. Applying isPrimaryClick to every pointer but a finger here and at :1132, with a pen case, would match the docs.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…ment-wide; a fine-pointer finger's action-group press ends) into the context request
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
… selection off document-wide) into the meta#8228 helper
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Correction: since the move, pushes to pull requests from forks start no workflows at all. #8666's push at 23:22Z has 0 runs, while meta/astryx's own branches run normally. So there is nothing to re-run on those seven heads until fork pull requests can run workflows again.

@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 a spec decision: the rule that a claimed mouse or pen drag clears and refuses text selection changes spec:AST-094 FR41, which today keeps a mouse's released behavior, and the FR41 sentence added for it is not yet approved at this head. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or FR41 and its verification row are amended on main, and the decision should also say whether the rule may clear a selection made elsewhere before the drag. Independent of that decision, these need changes:

  • Land #8360 first (or rebase onto main once it lands) and move the click-guard test-setup fix there, so this diff carries only its own change, today 27,728 authored lines with 2,401 of them its own.
  • DropdownMenu's trigger gives a touchscreen laptop's finger 10 px instead of FR29's drift allowance (hooks/useMenuPress.ts:108-126).
  • Under a fine pointer, an armed press installs no cancelable touchend or contextmenu listener (GestureHost.ts:1271), a kept press never keeps its finger (GestureHost.ts:1920-1922), and a page hidden mid-press keeps the page listeners (GestureHost.ts:2736-2740).

[Automated review]

}
this.isArmedPress = true;
this.syncKeptPresses();
this.install();

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 a fine pointer this installs the host's listeners without syncClaimingListeners(), which only syncListeners() calls (:1081-1082). So during the press the window's touchend is subscribed passively and no contextmenu canceller exists: a claimed finger's touchend is handled first by the passive window listener, whose preventDefault() is ignored, and the finger's cancelable target listener then skips it as already handled (:2084-2086). That leaves FR21's "its touchend MUST be cancelled, and no click or compatibility mouse event reaches the page" unmet for a touchscreen laptop's finger, and a claimed long press can also open the browser's menu. Calling this.syncClaimingListeners() after this.install() here would fix both; a fine-pointer test of a claimed lift and a mid-claim contextmenu would pin it.

Comment on lines +1920 to +1922
(this.arena.isClaimed(id) ||
(this.isCoarse &&
this.arena.consumerOf(id)?.keepsTouch?.(id) === true) ||

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 kept-press path still requires a coarse pointer, so the blocking touchmove that syncKeptPresses now registers during a fine-pointer press never cancels anything (also :1752-1753). A finger that moves on a held stepper on a touchscreen laptop is then panned by the browser, which cancels the press (FR20), against FR11's "A control whose own record keeps its press through a drag keeps it". Either drop this.isCoarse && here and at :1753, or stop registering the listener under a fine pointer; the README currently says both (README.md:248-251, :336-338).


private readonly onVisibilityChange = (): void => {
if (this.doc.visibilityState === 'hidden') {
this.cancelEverything(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.

The blur path now ends the armed press, but a hidden page still only cancels the contacts: isArmedPress stays true, shouldListen() keeps returning true (:1421), and the window subscriptions stay until the next lift anywhere when the page is hidden without a window blur. FR24 and INV13's verification fail an idle document listener outside a press. Calling this.releaseArmedPress() here (or in cancelEverything) with a listener-count test would close it.

Comment thread packages/core/src/hooks/useMenuPress.ts Outdated
Comment on lines +122 to +126
function triggerTolerance(): LongPressTolerance {
return isCoarsePointer(typeof window === 'undefined' ? null : window)
? {moveCancelPx: MENU_PRESS_TRIGGER_DRIFT_PX}
: {moveCancelPx: LONG_PRESS_ALLOWABLE_MOVEMENT_PX};
}

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 applies FR29 to DropdownMenu's trigger under every pointer ("FR29 applies to DropdownMenu's trigger under the same condition"), and module:DropdownMenu/useMenuPress FR9 says the trigger's finger open follows FR29 "under every primary pointer". Gating the 13 px drift on (pointer: coarse) gives a touchscreen laptop's finger 10 px, so an 11 to 13 px drift cancels its held press where a phone's keeps holding. A finger or pen could take MENU_PRESS_TRIGGER_DRIFT_PX under every primary pointer, with the two tests at useMenuPress.test.tsx:487 and :537 updated.

selection.removeAllRanges();
}
};
view?.getSelection()?.removeAllRanges();

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 removes whatever selection the page holds when a recognizer claims a mouse or pen contact, including one made elsewhere that the drag itself did not replace, and the document then refuses selectstart until a task after the lift. Current spec:AST-094 FR41 keeps a mouse's released behavior and its verification row fails "a change to native selection elsewhere or to mouse ... selection"; the FR41 sentence this pull request adds needs an engineering owner's approval at this head, and the open question asks for it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pull request is on hold for a spec decision: the rule that a claimed mouse or pen drag clears and refuses text selection changes spec:AST-094 FR41, which today keeps a mouse's released behavior, and the FR41 sentence added for it is not yet approved at this head. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or FR41 and its verification row are amended on main, and the decision should also say whether the rule may clear a selection made before the drag; the description should report the ## Writing rubric results for that edit, which approval reads it against. Independent of that decision, these need changes:

  • Land #8360 first (or rebase onto main once it lands) and move the click-guard test-setup fix there, so this diff carries only its own change, today 28,168 authored lines with 2,482 of them its own.
  • Under a fine pointer, an armed press installs no cancelable touchend or contextmenu listener (GestureHost.ts:1299), a kept press never keeps its finger (GestureHost.ts:1950-1953), and a page hidden mid-press keeps the page listeners (GestureHost.ts:2767-2771).
  • Several comments, README passages and one test still describe the coarse-only host, for example GestureHost.ts:84-86, gestures/README.md:316-320 and GestureHost.test.ts:2599.

[Automated review]

}
this.isArmedPress = true;
this.syncKeptPresses();
this.install();

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 a fine pointer this installs the window listeners without syncClaimingListeners(), which only syncListeners() runs after install() (:1106-1110). For the whole press the window's touchend stays passive and no contextmenu canceller is subscribed, so a claimed finger's lift is not cancelled (spec:AST-094 FR21: no click or compatibility mouse event reaches the page) and a claimed long press can also open the browser's menu. Calling this.syncClaimingListeners() right after this.install(), with a fine-pointer test of a claimed finger's touchend and a mid-claim contextmenu, would close it.

Comment on lines +1952 to +1953
(this.isCoarse &&
this.arena.consumerOf(id)?.keepsTouch?.(id) === true) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A fine pointer's kept press gets its blocking touchmove during the armed press (:930, :1305-1311), but this coarse-only condition means its moves are never cancelled, so a finger that moves on a held stepper on a touchscreen laptop is not kept (spec:AST-094 FR11, DEC-21) while the listener still delays scrolls that start there. Dropping this.isCoarse && here and at :1783, or not registering the listener under a fine pointer, would make it consistent; the README says both things today (README.md:249-251, :338-340).

Comment on lines +2767 to +2771
private readonly onVisibilityChange = (): void => {
if (this.doc.visibilityState === 'hidden') {
this.cancelEverything(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.

A hidden page cancels the press here but leaves isArmedPress set, so shouldListen() stays true and the page's listeners remain until the next lift anywhere (spec:AST-094 FR24; architecture:react-component-runtime INV13). onBlur calls releaseArmedPress() for the same case; this branch could too, with a listener-count test.

expect(slider.style.getPropertyValue('user-select')).toBe('');
});

it("keeps selection for every contact where the primary pointer is fine: a touchscreen laptop's finger, a desktop pen and a mouse (spec:AST-094 FR19, spec:AST-017 FR59)", () => {

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 title states the superseded rule: locksSelectionFor now locks a finger and a pen under any primary pointer (utils/selectionLock.ts:60-62, selectionLock.test.ts:97). The test passes only because it reads the iOS-only user-select write after a touchstart with no arming pointerdown; it could assert the lock a fine-pointer finger and pen now take.

selection.removeAllRanges();
}
};
view?.getSelection()?.removeAllRanges();

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 removes whatever selection the page holds when a recognizer claims the drag, including one made before the drag that the drag did not replace. Whether FR41's proposed "for that drag only" covers that is part of the spec decision this pull request waits on.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in this push:

  • an armed press under a fine pointer now brings the cancelable touchend and the contextmenu canceller for a finger or a pen, keeps a kept press's finger, and ends when the page hides: bf1a58b → 1585de0;
  • a press claimed at its own pointerdown keeps the page's selection, so a group's Copy or Cut still has it: f46d28f → bce55d5;
  • the coarse-only notes, the coarsePointer.ts header and the fine-pointer selection test now describe every pointer, and FR41's sentence and Verification row say what a claimed drag and a claimed press do with a selection: 4de6442.

Whether FR41 may suppress selection for a claimed drag 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.

This pull request is on hold for a spec decision: the rule that a claimed mouse or pen drag clears the page's text selection and refuses a new one changes spec:AST-094 FR41, which today keeps a mouse's released behavior, and the FR41 amendment written for it is not yet approved at this head. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or asks for the amendment to change; the description should report the ## Writing rubric results for that edit, which approval reads it against. Independent of that decision, these need changes:

  • Land #8360 first (or rebase onto main once it lands) and move the click-guard test-setup fix there, so this diff carries only its own change: today 28,406 authored lines, 2,788 of them its own.
  • Several comments and README passages still say mouse selection is unchanged or that content under a pan stays selectable, for example GestureRecognizer.ts:71-73, gestures/README.md:316-320 and PressTracker.ts:90-94.

[Automated review]

Comment on lines +71 to +73
* hold does not bundle it. Content under a sheet's or a pager's pan stays
* selectable, so there is none by default; a mouse keeps desktop selection
* either way. A long press brings its own when it has a hold (a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A claimed mouse pan now clears the page's selection and refuses a new one until after the lift (GestureHost.ts:1659-1690, utils/selectionLock.ts:147-213), so "stays selectable" and "a mouse keeps desktop selection either way" no longer hold for a mouse. Describe what a claimed mouse or pen drag does, or whatever the FR41 decision settles.

Comment thread packages/core/src/gestures/README.md Outdated
Comment on lines +317 to +320
finger or a pen takes the lock under any primary pointer, a touchscreen
laptop and a desktop pen tablet included; a mouse never does, so mouse
selection is unchanged (`spec:AST-017` FR59); content under a sheet's or a
pager's pan stays selectable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A mouse never takes the hold lock, but a claimed mouse pan does change mouse selection (this README's own claimed-drag paragraph at :265-270), so "mouse selection is unchanged" and "content under a sheet's or a pager's pan stays selectable" contradict it for a mouse. The checklist's "content drags keep selection" (:574) has the same problem.

Comment on lines +90 to +94
* For a control whose own record defines a hold action (a stepper's
* hold-repeat): pass `lockSelection` (gestures/selectionLock.ts) to turn
* text selection and the callout off for each press, where the primary
* pointer is coarse (`spec:AST-094` FR41). A control whose hold does
* nothing passes none. @default undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

locksSelectionFor now locks a finger or a pen under any primary pointer (utils/selectionLock.ts:60-62), so "where the primary pointer is coarse" no longer describes when this lock applies.

*/
retain(): () => void {
this.retainers += 1;
// A watch is not an attach: no warning where the pointer is fine.

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 change deleted the fine-pointer warning, so this comment explains the absence of something that no longer exists; it can go.

Comment on lines +7 to +8
* @position Internal util, the one primary-click rule: the gesture host
* starts a press only for one, and the press controller paints only one;

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/pressFeedback.ts never imports isPrimaryClick: its mouse path checks event.button === 0 itself (pressFeedback.ts:492), so "the press controller paints only one" describes a rule the controller does not share.

vjeux and others added 7 commits November 1, 2026 01:11
meta#8360's change on the internal touch foundation: one click guard shared
by the menu press and the gesture host (its click listener stays once
the menu press arms it, as its record has it, and lasts only while armed
for the gesture host, FR24), the host as the one touch owner (finger
scroll, page holds, attachments, the press tracker hook and a held
control's kept touch, the long press's single source, presented hit
testing on iOS WebKit), its cases in the gesture browser proof (Chromium
and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320
and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an
unused import). Rebased onto main after section 2 as one commit; the
touch-action rule came with meta#8071. Where fingers come from their pointer
events, a claimed finger's touchend is cancelled from its element
(FR21), under a fine primary pointer wherever the browser sends Touch
Events and inside an open shadow root too; at most eight such lifts wait
for their touchend, and each goes once taken or after 500 ms, kept
across the host's uninstall, which can run in the microtask after the
pointerup, before the touchend that comes later in the same task (FR24).
An armed press's page listeners go once the arena has decided its last
touch, a tap that waits on a double tap included, and after its last
detach the host holds no listener, note, frame or timer (FR24).

The animator's busy proof is judged against the harness's compositor-only
control (meta#8906), with a main-thread mutant as its negative, and the
spring's curve is placed against the control's own clock, so the late
frames a loaded runner sends fail nothing.

With the host serving every pointer (meta#8071), this change's own pieces
follow it: a press tracker takes a mouse press as a press, painting at
once and braking no scroll (FR11); a finger's or a pen's hold that does
something keeps text unselected under any primary pointer, while a mouse
keeps its released behavior (FR41); and `perEventPointer`, from before
FR43, changes nothing. These come from meta#8665, which keeps only the
undecided rule for text selection during a mouse or pen drag.

The hover recognizer and the host's hover routing land with hover's
first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…finger landed

On iOS WebKit the host resolves moving content at touch-down and asks the
engine what is under the finger. When a busy main thread hands it the touch
later, the content has moved on: the host now asks at the point where what
was under the finger at the touch's timestamp is drawn, so a tap on a moving
sheet's Done still clicks Done (spec:AST-094 FR11). A presented source
reports its offset at a given time for this.

The presented browser proof drives the sheet with Core's animator and a
critically damped spring, up into place and down off the screen over the
scrim: a tap on the drawn Done clicks it once, and a drag on the drawn sheet
catches it where it is drawn, in both directions.
…the finger (FR11, FR19); INV13 names the shared input listener

Where the engine hit-tests an element a compositor animation moves at the
motion's start (iOS WebKit), a touch on that content goes to what was drawn
under the finger at touch-down, and a tap there activates that element with
one click while the browser's own click is cancelled or swallowed (FR11).
While such content around a recognizer that claims moving touches is moving,
the document may carry a non-passive touchmove listener that cancels only
owned moves and comes off when the motion ends; while it is on, a native
scroll waits for a free main thread (FR19). The gesture host, press
feedback, page hold, finger-scroll watch and click guard hear document-wide
input through one capture-phase listener per event type on the window
(architecture:react-component-runtime INV13).
…view fixes

A touch the host handles late now also leaves alone content that slid
under the finger after it landed: the touch goes to what was beneath it
then (spec:AST-094 FR11). The input-stream changeset says what moved:
the gesture host's listeners, and the press feedback's scroll and
visibility listeners, since its pointer listeners were already on the
window's capture phase. The coarse-pointer gate reads TOUCH_QUERY from
touchQueries.ts instead of spelling it again.

The WebKit interaction job's scope covers every module the gesture host
now bundles: hooks/isRtlElement and utils' flatTree, hapticSwitch,
inputStream, clickGuard, selectionLock and coarsePointer. The scope
test's example of a util outside it moves from clickGuard, which the
gesture fixture now bundles, to focusReturn.

The presented proof checks that the sheet moves on a spring (its speed
changes along the animator's curve) and no longer compares the drawn box
with the animator's offset: under a 3-CPU cap the two disagree by 3 to
27 px in about half the frames whatever reference is used, while the tap
and drag cases go through the host's real path.
…e origin most frames agree with

Under the CI lane's WebKit profile (four CPUs, one worker), "a spring keeps
advancing every frame through a 1 s main-thread block" failed in two ways,
each about once in a few hundred runs, and a failing run's frames show each
cause:

- The spring drifted 138.6 ms against the control. The control's clock took
  its origin from the first usable frame alone, reading that frame's
  direction from the next usable one: the frame sat at x 210 on its way out,
  the next usable one at x 210 after the turn, and the tie read as "back".
  The clock now lives in controlClock.ts and takes the origin most frames
  agree with. On that run's own frames it reads 6.5 ms (bound 12), and its
  unit case for that layout fails on the old reading (150.7 ms astray).
- The spring "moved on 61 of the 62 frames the control moved". At the
  block's end it was in its tail, under two of the cast's 1 px steps a
  frame, where a frame delivered early shows no step and the next catches
  up two. The harness now reads such a tail at its own pace
  (busyWindowProof), and the proof judges the whole block: on that run's
  own frames the earlier proof fails it and this one passes.

No bound changes.
… (architecture:public-component-api INV23)

Main's lint rule now keeps every private data attribute in its owner module.
This change had moved `data-astryx-haptic-switch` from the haptics module to
utils/hapticSwitch.ts, so the click guard could skip the hidden switch
without importing haptics (architecture:react-component-runtime INV13). The
name now lives in privateAttributes/hapticSwitch.stylex.ts, modelled on
segmentDisabled.stylex.ts: it exports markHapticSwitch, which the haptics
module uses on its hidden switch, and isHapticSwitch, which the click guard
reads. utils/hapticSwitch.ts and the deprecated gestures/hapticSwitch.ts
re-export go, the haptics module's baseline entry goes with its raw name,
and the WebKit interaction job's scope covers the new module. No behavior
changes.
…election and never clears the page's; a drag that starts on selectable text is not claimed (spec:AST-094 FR41)

FR41, as approved: a mouse or pen drag that a recognizer claims takes no
new text selection for that drag and never clears an existing selection; a
drag that starts on selectable text is not claimed.

- The host keeps the page's selection from a mouse or pen pointerdown,
  read before the arena admits the press. When a recognizer claims the
  contact, lockDragSelection refuses a new selection on the claiming
  elements and the document until a task after the lift (a cancel ends it
  at once), and puts the kept selection back over whatever the press's
  collapse or the drag made of it.
- At the pointerdown the host also reads whether the press lands on
  selectable text: a character of text whose element allows a selection,
  or a text field. Once such a contact moves, the arena's refusesClaim
  fails a recognizer that claims touches instead of letting it recognize,
  so the browser's selection runs. A press claimed at its own pointerdown,
  before it moves, and a tap's claim at the lift are unaffected.
- The last detach ends a lifted drag's hold at once, so nothing outlives
  the host; the nothing-left test lists the three new collections.
- FR41's text and Verification row state both facts; the gestures README
  and the host's header say what the host does.
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebuilt as option A, as one commit on #8360's head a57e57e: b848270. It replaces the earlier merge branch.

  • FR41 carries Cindy's two sentences in place of the three this pull request had, and its Verification row lists both facts: an existing selection survives a claimed drag, and a drag starting on text selects text.
  • A claimed mouse or pen drag takes no new selection and puts back the selection the page had at the press. A drag that starts on selectable text is not claimed, so it selects.
  • Both facts fail on fix(core): one click guard for the menu press model and the gesture host #8360's head with real mouse input in Chromium and WebKit, and pass with this change. The numbers are in the description.

@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 a spec decision: the sentence it adds to spec:AST-094 FR41, in particular "a drag that starts on selectable text is not claimed", goes beyond the earlier owner direction and narrows FR43's every-pointer attach, so an engineering owner needs to approve the sentence and its verification row by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or ask for the clause to change. Independent of that decision, a few things need fixing:

  • A drag claimed by a swipe, or by a recognizer that waited for another to fail, never gets the selection hold (GestureHost.ts:1900-1906), so those drags still select text; please lock the claiming recognizer's element whatever its state and add cases for both.
  • The title describes every-pointer attach, which main already has, and .changeset/drag-selection.md announces a [fix] for sheets and pagers that no released component runs on this host; please retitle to the FR41 change and mark the changeset internal like the other gesture changesets.
  • The diff still carries #8360's 16,943 lines; landing #8360 first leaves this pull request's own 972-line change.

[Automated review]

const elements = new Set<Element>();
for (const recognizer of this.arena.participantsOf(id)) {
if (
(recognizer.state === 'began' || recognizer.state === 'changed') &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only began/changed participants get the drag hold, and the arena reports a touch's claim once. A swipe claims as ended while the button is still down, and a recognizer waiting on another's failure claims while possible and gets no second report when it begins, so neither drag gets the hold: the selection keeps running. Could the hold lock the claiming recognizer's element whatever its state (the arena knows it in claim), with cases for a swipe and a requireToFail pan?

(`spec:AST-017` FR59; DEC-9).
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
takes no new text selection for that drag and never clears an existing
selection; a drag that starts on selectable text is not claimed. An

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"a drag that starts on selectable text is not claimed" goes beyond the earlier owner direction (a claimed drag suppresses selection for that drag only): under a mouse or a pen, a sheet's, pager's or swipe's drag can no longer start from text, while FR43's verification row still fails "a gesture missing under a mouse or pen". This sentence needs an engineering owner's /approve-spec at this head, or a change.

'@astryxdesign/core': patch
---

[fix] Gestures: a mouse or pen drag that a gesture claims, such as a sheet's or a pager's pan, no longer starts a text selection or clears the page's existing one, and a drag that starts on selectable text is left to the browser, which selects that text.

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 released component runs the gesture host yet (BottomSheet drags through its own useSheetGestures), so a [fix] naming a sheet's or a pager's pan would send consumers looking for a change they cannot see. Suggest [chore] Internal: ... Not exported from the package entry point., like the other gesture changesets.

return winner.canPrevent(other) && other.canBePrevented(winner);
}

/** UIControl's rule: a control may veto an overlapping ancestor recognizer. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is isVetoedByConsumer's doc comment; it now sits on isRefusedByBrowser above that method's own doc block. Move it down onto isVetoedByConsumer.

This branch was successfully deployed

1 active deployment
Preview — b8482702 Deployed Nov 1, 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