Skip to content

fix(core): one click guard for the menu press model and the gesture host - #8360

Merged
cixzhang merged 11 commits into
meta:mainfrom
vjeux:uikit/f2-click-guard
Nov 1, 2026
Merged

cixzhang merged 11 commits into
meta:mainfrom
vjeux:uikit/f2-click-guard

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Behavior-module PR: click guard, input stream, gesture host touch ownership, page hold, finger-scroll watch, selection lock, presented hit testing

Section 3 of the merge plan: #8360 → #8098 → #8309 → #8763 → #8342. Eleven commits on main (4f66b07), after #8071, #8076, #9233 (the harness reads a slow tail at its own pace), #9285 (the animator rechecks a finished animation's end) and #9376 (the WebKit interaction job's scope for this change's modules), holding only this change: the change itself, the late-touch fix with the spring proof, the record sentences, the last review round's fixes, the busy proof's control clock; sixth, the move of the haptic switch's private attribute into its owner module (architecture:public-component-api INV23); seventh, one shared copy each of the iOS WebKit engine check (utils/touchQueries.ts) and the flat-tree containment walk (utils/flatTree.ts), which the gesture host and the selection lock had each kept; eighth, the input-stream changeset's [fix] narrowed to what ships (touch press feedback's scroll and page-hide listeners), with the recognizers' shared stream filed as [chore], since no Core or Lab component attaches a recognizer yet; and ninth, presented hit testing deciding "inside the moving element" along the flat tree in its last two places: presentedOffsetOf walks through a slot to the panel a control is drawn in, and the host's check for a claiming recognizer around a moving element crosses shadow roots both ways; and tenth, the moving-content blocking touchmove subscribing through the shared input stream with {cancels: true}, as the page hold does, instead of its own listener on the document; and eleventh, AST-094's Platform support stating how presented hit testing is verified before and after merge, in its owners' words. It carries #8074, #8270, #8293, #8315, #8320 and #8356, which closed as carried by it, and absorbs #8358 (the long press's tolerance comment and an unused import), which closes with a link here. It also takes the pieces of #8665's serving of every pointer that belong to its own features; #8665 keeps only the undecided rule for text selection during a mouse or pen drag.

Primary intent

Make the gesture host the one touch owner on the page, and give the menu press model and the gesture host one guard for the click that follows a gesture that already acted. No narrower template fits: this is one section of the plan, internal infrastructure together with the four user-visible fixes those same changes make.

User or maintainer impact

  • After a gesture acts (a menu row chosen by a lift, a tap a recognizer took), the browser still sends its own click. Two guards swallowed it, for 400 ms and 1000 ms, with different rules for a script's click, so one stray click could act twice in one place and not at all in another. One guard now decides it everywhere.
  • An app's window-capture stopPropagation, or a document-capture stopImmediatePropagation registered before Core, hid touches from the recognizers, and scrolls and page hides from the press feedback. Both now hear page-wide input through one stream on the window's capture phase, where the press feedback's pointer listeners already were.
  • A finger held on a DropdownMenu trigger may drift up to 13 px on each axis and still open the menu, under every primary pointer (spec:AST-094 FR29); it cancelled past 10 px.
  • Where the primary pointer is fine, the press feedback controller registers no touch listener.
  • For the touch components: one owner for the finger count, touch gates, page holds and kept presses; a watcher recognizer; useFingerScroll; presented hit testing on iOS, so a touch on an element the animator is moving reaches what is drawn; and a hold's selection lock that each recognizer brings for its own contact.

Problem and solution fit

  • Why does it happen? The menu press model and the gesture host each guarded the click their way, and each touch feature registered its own page listeners.
  • Why does that hurt? A click acts twice or not at all, an app's stop hides a touch, and listeners multiply with components.
  • Why does it matter? spec:AST-094 asks for one activation per gesture and for gesture work that costs nothing where it is not used (FR21, FR24), and every touch component builds on this host.

Deltas:

  • utils/clickGuard.ts: one guard for both. It swallows the next click on the window's capture phase and disarms on that click, at the next pointerdown or after 400 ms; a row's own activation passes through withoutClickGuard, and a keyboard's or assistive technology's click (detail 0) always passes. Its click listener stays once the menu press model arms it (module:DropdownMenu/useMenuPress PR2) and lasts only while armed for the gesture host (FR24).
  • utils/inputStream.ts: one native listener per event type on the window's capture phase, shared by every subscriber, passive unless a subscriber cancels the event.
  • GestureHost:
    • the one document-level touch owner: the finger count, touch gates, and a kept press for a control that tracks its finger;
    • a second finger fails a one-finger pan that has not claimed, so the page can pinch, and a scroll-edge pan holds a lone finger from its first move toward an edge every scroller under it is at;
    • a finger matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots, and a claimed pen holding only its own stylus touches;
    • presented hit testing on iOS WebKit, from the offsets the animator reports (motion/presentation.ts).
    • where fingers come from their pointer events (inputMode: 'pointer-events', or a window with no ontouchstart), a claimed finger's touchend cancelled from its element, as the Touch Events path cancels it (FR21): the lift is noted where its pointer landed, inside an open shadow root too, under a fine primary pointer wherever the browser sends Touch Events, and the note expires after 500 ms; at most eight notes wait, and none is kept where no touchend can come (FR24).
    • its lifecycle: an armed press ends once the arena drops its last touch (the arena's onIdle), a tap that waits on a double tap included, and in the microtask after its last detach, unless something attached again in that task, the host releases its notes, frames and timers (FR24).
  • pageHold.ts, fingerScroll.ts with useFingerScroll, and hostRegistry.ts: a scroll lock's page hold and the finger-scroll watch, which ask the host only about the touches it owns.
  • Recognizers and the arena: a watcher (claimsTouches: false) takes passive listeners only and follows a touch the browser scrolls with to the lift; a pan claims nothing before it begins at 10 pt; one velocity tracker for every gesture (spec:AST-100); the arena plumbing moves behind recognizerInternals.
  • utils/selectionLock.ts: the selection and callout lock a recognizer or a press tracker brings for its own contact; a long press brings one only when it has a hold, and an element attached with manageStyle: false keeps its own styles (FR41).
  • Hooks: useGestureRecognizer and usePressTracker (now its own file) call no caller back from an Effect or after unmount, and what a sync unblocks resolves a microtask later, where its listener reports as usual; usePressTracker brakes with the host and keeps one tracker through an option change.
  • useMenuPress uses the shared guard and FR29's drift allowance; useLongPress takes its 500 ms and 10 px from the long-press recognizer's table, and its public start takes the point only, so the per-press tolerance stays internal to useMenuPress (architecture:public-component-api INV12).
  • From fix(gestures): a mouse or pen drag a gesture claims keeps the page's text selection (spec:AST-094 FR41) #8665, with the host serving every pointer: a press tracker takes a mouse press as a press (FR11's fine-pointer profile); 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); perEventPointer is accepted and does nothing.
  • Its cases in the gesture browser proof, which comes with feat(core): touch-action derivation, native scroll probe, haptics and the gesture host #8071, and presentedHitTesting.interaction.browser.spec.ts, in Chromium and WebKit, and the README; the animator's busy proof is judged against the harness's compositor-only control (test(interaction-harness): a busy-window proof can judge a component against a compositor-only control #8906).

Known gap: in iPadOS Safari, the stylus touchend of a pen a recognizer claimed is not cancelled yet; that waits for evidence from that browser.

Authority

spec:AST-094 (current): FR11, FR19 (with its fourth exception: a gesture cancelled while its finger moves keeps that finger), FR20, FR21, FR24, FR29, FR41 and FR43; module:DropdownMenu/useMenuPress (current) FR5, FR9 and PR2; spec:AST-057 FR6; spec:AST-100; architecture:react-component-runtime INV2, INV3, INV5 and INV13. The hover recognizer and the host's hover routing land with hover's first user. This pull request adds the spec owner's approved sentences to FR11 (a tap on content that moved under the finger), FR19 (the document listener while that content moves) and INV13 (the shared input listener).

Change and evidence

  • The click guard's unit cases failed before it; so did the case for the gesture host's arm, whose click listener now goes at the click, the next press or the end of the window, and the browser case where a claimed drag leaves no page listener once that window ends.

  • The held trigger's 12 and 14 px drifts are pinned under either primary pointer.

  • Each review fix the carried pull requests made has a case that failed before it.

  • In the browser proofs, a claimed drag leaves no page listener once the click window ends, and a touch on a moving sheet reaches the button drawn there; the rest of the gesture proof comes with feat(core): touch-action derivation, native scroll probe, haptics and the gesture host #8071.

  • The public useLongPress start keeps its own tolerance when a caller passes one anyway, a case that failed before.

  • With fingers from pointer events, a claimed pan's lift and a recognized tap's have their touchend cancelled, under a fine primary pointer (with that touchend coming after the microtask that follows its pointerup, once the armed press's listeners have gone) and inside an open shadow root too, while an unclaimed lift keeps its own; those cases failed before.

  • A nothing left after the last detach block runs 17 scenarios and checks that no listener, note, frame or timer remains after each; 16 of them failed before this round's fixes, and the double-tap wait's release failed before too.

  • Claimed lifts that wait for their touchend stop at eight, the oldest going first, and go once 500 ms pass, while a desktop with no Touch Events keeps none; those cases failed before.

  • Fixed in this round, found by the load runs: a touch that a busy main thread handed the host late went to what was drawn when the host ran, not where the finger landed, so a tap on a moving sheet's Done could miss it. It now goes to what was drawn under the finger at touch-down (FR11): the host asks at the point that content has moved to since the touch's timestamp, and content that slid under the finger after it landed takes nothing, so the touch goes to what was beneath it. A unit case failed before each. In WebKit at 3 CPUs and 16 workers, 15 of 384 runs fail without it, all taps and drags on the moving sheet, and all 384 pass with it.

  • 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, with a tap on the drawn Done and a drag on the drawn sheet in each direction.

  • The WebKit interaction job's scope covers every module the gesture host now bundles (hooks/isRtlElement, the haptic switch's owner module privateAttributes/hapticSwitch.stylex.ts, and the utils flatTree, inputStream, clickGuard, selectionLock and coarsePointer) through ci: the WebKit interaction lane also runs for #8360's new click-guard paths #9376, already on main: a pull request from a fork cannot change .github, so this one changes no file there.

  • Sixth commit, for main's rule that a private data attribute is written only in its owner module (@astryx/no-raw-private-attribute, INV23): the haptic switch's attribute now lives in privateAttributes/hapticSwitch.stylex.ts, modelled on segmentDisabled.stylex.ts. The haptics module marks its hidden switch through markHapticSwitch, and the click guard reads it through isHapticSwitch, still without importing haptics (INV13). This change's own utils/hapticSwitch.ts and the deprecated gestures/hapticSwitch.ts re-export go, and so does the haptics module's baseline entry, with its raw name. The rule failed on this change before. No behavior changes.

  • Dropped: the browser check that compared the sheet's drawn box with the animator's offset. Under load, Linux WebKit draws the moving sheet ahead of the time it reports to script, so the two disagree: at 3 CPUs and 16 workers, 61 of 128 runs by more than 3 px, a median of 11 px and up to 27. The animator itself agrees with WebKit's animation every frame. On the iOS 26.2 simulator, with and without a busy main thread, the drawn box resolves where the animator records it (32 of 32 within 0.17 px), so the host's correction holds there. The tap and drag cases, which go through the host's hit path, still pass 384/384 at 3 CPUs. The spring itself is still checked: the animator's own curve speeds up and slows down. Follow-up: [Bug] Under CPU starvation, Linux WebKit draws a compositor-animated element ahead of its reported currentTime, so the animator's offset and the drawn box differ by 10–17 px #9186.

  • Fixed in this round, found by CI's WebKit lane profile (a 4-CPU container, 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. A failing run's frames show each cause, and on those frames the fix passes.

    • The spring drifted 138.6 ms against the control box: the control's clock took its origin from one frame, whose direction it read across a turn. The clock (controlClock, a test helper) now takes the origin most frames agree with; it reads 6.5 ms on that run's frames (bound 12), and its unit case for that frame layout failed before.
    • The spring "moved on 61 of the 62 frames the control moved": at the block's end it was in its tail, moving under two of the screencast'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 (test(harness): busyWindowProof reads a slow tail inside a fast window locally #9233), and the proof judges the whole block: on that run's frames the earlier proof fails it ("moved on 61 of the 62 frames") and the local-pace proof passes ("moved on 62 of its 62 frames").
    • No bound changed. Under CI's WebKit profile with renderer threads matched, 1,400 more runs of this case passed on this change's code, across test(harness): busyWindowProof reads a slow tail inside a fast window locally #9233's three heads (600, 400 and 400); none of the first 600 read a drift of 9 ms or more.
  • Motion proofs under load, both trees run at the same time (the gesture, animated-layout and animator proofs, ten runs each). WebKit capped at 4 CPUs with CI=1, and at 3 CPUs with one worker, renderer threads matched to the CPUs: every proof passes on main and with this change. At 3 CPUs and 16 workers, five workers a CPU, the real-time motion proofs fail on both alike: "a spring keeps advancing every frame…" main 10/10, this PR 10/10; "a finished animation leaves the element…" 9 and 9; "a spring that ends inside the block…" 8 and 8; the animated-layout flex case 4 and 5; this PR's own negative control, "a move driven from the main thread … fails it", 10/10. In Chromium pinned to 2 cores with one worker, where the busy-main-thread proofs are judged, every animator proof passes on both trees (main 140/140, this PR 150/150, ten runs each); at four workers on those cores, the animator's start check (|median| < 20 ms) fails on both trees (main 13 of 30 runs, this PR 17 of 30) because it reads late screencast stamps; it is fixed in the stacked draft that reads the control's clock on this head.

  • Re-measured in this round with each container's renderer threads matched to its CPUs, which an earlier setup left unmatched: the late-touch counts (15 of 384 without the fix), the drawn-ahead disagreement (a median of 11 px), and the motion proofs (all passing at the required profiles).

  • Records: spec:AST-094 FR11 and FR19 state what the host does with a touch on moving content, and architecture:react-component-runtime INV13 states the shared input listener, in the spec owner's wording.

Scope

Lifecycle audit

Every listener, record and timer the gesture host (and the arena, where it holds host state) creates, what creates and clears it, and what happens in each case (spec:AST-094 FR21, FR24). "Fixed" marks a row this round changed. A nothing left after the last detach block runs 17 scenarios through one check: no listener added during the scenario is left on any target, every host collection is empty, no arena timer is armed, and no frame or timeout is pending. Before these fixes 16 of them failed.

Resource Created by Cleared by Double-tap wait Last detach Blur: coarse / fine Expiry Pointer-fed vs touch-fed
Page listeners (pointer, touch, scroll, visibility, blur) Coarse: something enabled attached, or a finger-scroll watch. Fine: an armed press Nothing enabled; the armed press over Fine: kept until the arena drops the waiting touch (fixed: arena onIdle) Removed at the detach Kept / removed in the next microtask (refusing a lift kept down through it: follow-up) — Same; 'pointer-events' reads fingers from pointer events
Claiming listeners (window touchend that cancels, contextmenu) A recognizer that can claim, while installed No claimer, or uninstall As above Removed As above — Touch-fed lifts are cancelled here
Element touchend (a claimed lift) Pointer-read fingers, a coarse pointer or Touch Events sent, an enabled touch recognizer Sync, detach — Removed with its element Kept on the element — Pointer-fed only
Element blocking touchmove A recognizer that claims a moving finger; a kept press (coarse: always; fine: during an armed press) Sync, detach — Removed Kept while attached — Both
Element pointerdown (armed press) Fine pointer, an enabled recognizer or consumer The pointer turns coarse, disabled, detach — Removed Kept — Both
Per-finger target listeners touchstart That finger's end or cancel; a later touch event without it; uninstall — Removed Removed at the lift / at uninstall — Touch-fed
Blocking touchmove through the input stream (moving content) Installed, iOS WebKit, an element around a claiming recognizer drawn away from its box The motion ends, uninstall — Removed As page listeners — Both
Presented-element watch Installed and hit-testing what is drawn Uninstall — Removed As page listeners — Both
Coarse-pointer media watch The first attach The last detach — Removed Kept — —
ResizeObserver An enabled screen-edge pan None left — Disconnected Kept — —
Pending author touch-action read and its frame An attach on an element whose author value is unknown The frame runs — Cancelled in the microtask after the last detach (fixed) — One frame —
Selection locks An admitted contact whose hold locks Its end or cancel; uninstall Released at the lift Released Released — Both
Per-contact maps (finger, pointer, pairing, samples, retargeted taps, FR20 judgement) Each contact Its end or cancel; uninstall Dropped at the lift (the arena keeps its own record until the decision) Empty Dropped — Pairing maps only where a browser sends both channels
Lifts a blur cancelled (cancelledFingers) Blur, or uninstall with a finger down That finger's touchend or touchcancel; a later touch event without it — Cleared in the microtask after the last detach, unless something attached again in the same task (fixed) Refused at the lift / follow-up — Touch-fed
Claimed-lift notes and their timer A pointer-read finger's claimed or retargeted lift Its touchend; 500 ms; at most 8 — Cleared after the last detach (fixed) — 500 ms, 8 at most Pointer-fed; kept across a fine pointer's uninstall, which runs in the microtask after the pointerup, before the touchend that comes later in the same task
ownScroll records and their frames A recognizer scrolling a scroller itself Two frames — Cancelled after the last detach (fixed) — Two frames —
Armed press A press on an armed element Its contacts end and the arena drops their last record Ends when the wait is decided (fixed) Reset Reset at blur — Both
Arena timers (long press, double tap, swipe) A recognizer's deadline It fires; the recognizer finishes or is cancelled (a detach cancels it) The double tap's timer decides the single tap None left Cancelled Per recognizer Both
Click guard (utils/clickGuard) A claimed, retargeted or refused lift The click, the next pointerdown, or 400 ms — Outlives the host by design: a late click is still swallowed (FR21) — 400 ms Both
Finger-scroll watch, touch gates, finger listeners Their owners Their owners' release — Removed with them — — —

Real-browser evidence

simulator/emulator evidence gates merge; real iPhone + Android gate release.

Pages built from this PR's tree with esbuild, as the gesture browser fixture is: the real host, animator, recognizers and press tracker, nothing stubbed. iOS: iPhone 17 Pro simulator, iOS 26.2, Safari. WebKit: Playwright 1.61 with WebKit 26.5, in a touch-enabled phone context. Every take was recorded and checked frame by frame. The takes ran at d7e6d99; this round's changes touch neither path (they run after the last detach, or end an armed press under a fine pointer).

Hit testing on moving content (AST-013 FR10), iOS 26.2 Safari. A sheet slides on the compositor over a scrim that closes it: down 0→444 px, back up, down again, 4 s each. Taps land on the drawn Done button while it slides down (A) or up (B), or where Done started (C). The run was 18 takes with this PR, then 18 with the correction off (presentedHitTesting: 'never') as a control. In every take the engine's own hit test was whatever sat under the finger where the slide started.

Tap (6 takes each) This PR Control (correction off)
A: on the drawn Done, sliding down Done clicked 6/6 Nothing 6/6 (lands on the sheet body)
B: on the drawn Done, sliding up Done clicked 6/6 Scrim tapped 6/6 (would close the sheet)
C: where Done started Scrim tapped 6/6; Done not clicked Done clicked 6/6

Every click with this PR came from the host, with the engine's touchend cancelled (18/18).

One loop: this PR left, the control right

Contact sheets of one loop: this PR, control.

Pointer-fed touchend timing (inputMode: 'pointer-events').

  • pointerup comes first and touchend follows in the same task, in every take: iOS 30/30, with equal timestamps; WebKit 30/30, 0–1 ms apart. Only microtasks ran between them; a message, a setTimeout(0) and a frame queued at the pointerup had not run yet. Under a fine pointer the armed press's listeners go in that microtask, which is why a claimed lift's note outlives the uninstall.
  • A claimed pan, 60 px and 25 px drags: touchend cancelled, and no mousedown, mouseup or click reached the element. iOS 12/12, WebKit 12/12.
  • An unclaimed tap keeps its click: iOS 6/6 (mousedown, mouseup and click 34–46 ms after the lift), WebKit 6/6.
  • Controls on an element with no gesture: iOS clicks an uncancelled 25 px drag 6/6 (33–37 ms after the lift) and a 60 px drag 0/6, so the 25 px claimed drag is where the cancel stops a click iOS would send. Linux WebKit sends no click from raw touch events (0/12), so its unclaimed taps go through Playwright's tap.
  • Frames: iOS, a tap and a 60 px drag, iOS, a claimed and a plain 25 px drag; the page's own per-take lines on iOS and WebKit.

A drag catching a moving sheet, iOS 26.2 Safari. While the sheet slides on the compositor (down 0→300 px or up 300→0 px over 4 s), a finger lands on it where it is drawn and drags 135 px down in about 0.4 s. The run was 14 takes with this PR and 14 with the correction off, on a page the sheet holds still.

Slide (7 takes each) This PR Control (correction off)
Sliding down Caught 7/7 Caught 7/7
Sliding up Caught 7/7 Lost 7/7: the finger lands on the scrim, no pan begins, and its moves are not cancelable

With this PR the pan begins 31–51 ms after touch-down, and the sheet follows after 3–5 frames (the pan's 10 px threshold, while the slide goes on); from then on it stays within 2 px of the finger. Every move after the pan began is cancelled, and nothing scrolls or clicks.

Takes 3 (down) and 4 (up): this PR left, the control right

Contact sheets: this PR, control.

A limit already on main, which this PR does not change: over a page that can scroll, with no page hold, the page's scroll takes about half the drags with or without the correction (this PR 8/14; the control 3/7 of its down drags). A one-way pan claims only when it begins at 10 px; at the simulator's 6.75 px per event it begins at 13.5 px, after Safari has committed to the scroll (GIF). An earlier claim for a non-modal sheet over a scrolling page is an owner decision under AST-094 FR19.

Writing rubric results

R1 narration: none; each added sentence states what the system does, in the present tense, and the added Platform support bullet states how presented hit testing is verified before and after merge.
R2 research: none in the records; the measurements behind them are in this description and its evidence media.
R3 evidence: none; the added sentences cite no pull request, issue or commit.
R4 rejected alternatives: none added.
R5 change references: none in the added text (searched for # followed by digits, /pull/ and /issues/).
R6 open questions: none touched.

Merge gates

Merge gates per the owner: true spring fixture + the presented spec passing in CI's pr-interaction-webkit. A physical-iPhone take of the presented tap/drag proof is tracked as a follow-up: #9310.

AST-094's Platform support records the split, in its owners' words: "Presented hit testing (FR11's and FR19's iOS WebKit sentences): unit tests that mimic iOS WebKit's hit testing at the motion's start, the WebKit tap and drag proof with the correction off as its control, and the iOS Simulator gate a merge; real-device evidence for it follows merge."

Owner real-device check: on 10/31 at 11:31 the owner tried this PR's preview on her own phone and approved ('8360 seems good'); a physical-iPhone recording is tracked in #9310.

Testing

What re-ran, and what carried

Each rebase re-ran every test whose loaded files (its imports and every page it bundles) changed since the gate before, and carried the rest.

Local gates at 5337c27, eleven commits on main 4f66b07 holding only this change (110 files, +15,306/−1,815). A test re-ran when what it loads, its imports and the pages it bundles, includes a file that changed since the gate before; the Storybook-backed accessibility suites ran whole at 8759525's tree, and the tenth commit changes no file they load. The rest load only identical files and carry the earlier results.

  • pnpm test:ui (the UI tests of Core, Lab and Richtext): 714 files, 17,761 tests passed.
  • The tenth commit's re-run of the gesture host's and presentedHitTesting proofs: Chromium 60 passed; WebKit 50 passed and 10 skipped; CI's WebKit lane profile, ten runs, 500 passed, none failed; WebKit at 3 CPUs, ten runs, 500 of 500 at one worker and at 16 workers; Chromium pinned to 2 cores, ten runs, 600 of 600 at four workers, and 780 of 780 at one worker with the animator's busy-main-thread proofs.
  • pnpm exec vitest run --project node, after building Core: 839 files passed; the only failures are two cases in packages/cli/foundation/release/cache.test.mjs, which fail on main as well (fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520). The harness's own tests: 104/104.
  • The ninth commit's re-run of the gesture host's, presentedHitTesting and animator proofs: Chromium 78 passed; WebKit 64 passed and 14 skipped; CI's WebKit lane profile, ten runs, 640 passed, none failed; WebKit at 3 CPUs, ten runs, 640 of 640 at one worker, and at 16 workers 29 runs of the frame-by-frame proofs fail, named here and never counted as a pass; Chromium pinned to 2 cores, ten runs: 180 of 180 of the animator's busy-main-thread proofs at one worker, and at four workers 778 passed and 2 failed, both the animator's start check, report-only there.
  • At b5cbf78, the interaction proofs that re-ran (harness, MonthScrollerSteps, SideNavRailMotion, TabPressPointer, presentedHitTesting, animatedLayout, animator), with the gesture host's: Chromium 245 passed; WebKit 206 passed and 39 skipped, for input WebKit's driver does not provide.
  • CI's WebKit lane profile (a container capped at 4 CPUs with CI=1, renderer threads matched, one worker), ten runs of those proofs: 2,060 passed, none failed.
  • WebKit at 3 CPUs, ten runs each: at one worker, 2,060 passed, none failed; at 16 workers, five a CPU, the frame-by-frame proofs fail (157 runs: the harness's busy-window judgments, the animator's, and the animated-layout edges), named here and never counted as a pass.
  • Chromium pinned to 2 cores, ten runs each: at one worker, the animator's busy-main-thread proofs, 180 of 180; at four workers, 2,448 passed and 2 failed, both busy proofs, which are report-only there: the animator's start check, which reads late screencast stamps and is fixed in the stacked draft that reads the control's clock, and the retarget proof main added with fix(core): a retarget before Chromium reports a call's start continues from the screen #9255, which fails on main as well (1 in 80 on main and 0 in 80 with this change, run at the same time).
  • The accessibility suites, whole, on a built Storybook stamped with its commit: Chromium 2,268 passed; WebKit, in CI's container profile, 138 passed and 10 skipped. The sandbox's performance scene: 5/5.
  • tsc --project packages/core/tsconfig.json --noEmit, strict ESLint and Prettier on every changed file: clean.
  • pnpm check:knowledge, pnpm check:repo and pnpm check:changesets: clean. The eleventh commit changes only the record: the record-reading tests (knowledge checks, the record contract, specs:for, review-diff, changeset coverage and fully-specified; 7 files, 362 tests) pass. The private-attribute baseline (no-raw-private-attribute.baseline.mjs --check): up to date.

@vercel

vercel Bot commented Oct 25, 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 10:11pm UTC

Request Review

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

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 10:25 PM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs an owner decision before it can be approved. Against main, the pull request carries the whole touch-presentation stack, and the draft spec:AST-094 it adds is the only spec for the gesture host, the motion animator, the experimental subpaths and the theme motion roles, though its FR4 and DEC-1 rule out those same theme roles.

An engineering owner can record that decision, including whether touch motion is themable, by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Whatever the decision, please land the predecessors first so this diff is the 550-line click-guard commit, and fix the following:

  • pressFeedback.ts now turns off text selection and the touch callout under a held finger on every stable press-feedback surface, Link included, under an [experimental] changeset, which spec:AST-017 FR22 does not allow for stable behaviour.
  • The touch resolver behind presentation="adaptive" stays at 768 px instead of the theme's effective md (spec:AST-012 FR9), although its changeset says it reads the theme.
  • theme.template.ts documents a platform adaptation axis that defineTheme rejects, and touch-experimental-subpaths.md misstates what is exported.

[Automated review]

Comment thread packages/cli/assets/theme.template.ts Outdated
Comment on lines +361 to +362
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

themeAdaptations.ts accepts only width, pointer, contrast and motion in when (its CONDITION_AXES), so a rule copied from this sentence with platform makes defineTheme throw "… .platform is not supported." The stack removed the platform condition; please drop it from the template too.

@@ -283,6 +302,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This lock now applies to every stable usePressFeedback surface on a touch device, Link's <a href> included. -webkit-touch-callout: none for the whole hold removes the iOS link callout on Astryx links. spec:AST-017 FR22 keeps stable runtime changes out of an [experimental] changeset; can the lock stay on the experimental press trackers?

Comment on lines +71 to +72
export const COMPACT_TOUCH_QUERY =
DEFAULT_COMPACT_DEFINITION.coarsePointerQuery;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This query is built from DEFAULT_COMPACT_DEFINITION (md 768), so a theme that overrides adaptations.widthBreakpoints.md never moves the sheet/popover boundary. spec:AST-012 FR9 defines compact by the effective md, and its verification row lists "overridden md"; .changeset/touch-presentation.md also says the theme's md is used.

'@astryxdesign/core': patch
---

[experimental] Two experimental subpaths for building touch presentations, outside the stable compatibility promise (they may change or be removed in a patch release). `@astryxdesign/core/motion/experimental` exports the compositor animator (`createAnimator`, `useAnimator`), UIKit's spring, rubber-band and cubic-bézier math, `resolveMotion` (a motion role's value under the element's theme), and the UIKit constants table as the read-only `uikit` namespace. `@astryxdesign/core/gestures/experimental` exports UIKit-style gesture recognizers (tap, long press, pan, screen-edge pan, swipe, pinch, rotation, hover), `useGestureRecognizer`, `usePressTracker`, `useTouchPresentation`, `readTouchPresentation`, `playHaptic` and `attachTapHaptic`, with the `GestureArena` and `VelocityStrategy` types their options reach. Nothing is added to the package root or to any other subpath.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"Nothing is added to the package root or to any other subpath" does not hold at this head: packages/core/package.json also adds ./motion/reducedMotion.stylex. And VelocityStrategy is listed as exported, but gesture-recognizer-fixes.md removes it and gestures/experimental.ts does not export it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs an engineering owner's spec decision before it can be approved: nothing current on main governs the gesture host's click handling or most of what this diff carries beneath it (the gesture and motion systems, theme motion roles, the selection lock, the long-press distance and undefinedVariables), so an owner needs to provide or identify that spec, or rule that those parts leave this pull request. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, please land the predecessors first so this diff is the 549-line click-guard commit, and address these:

  • undefinedGeneratedVariables in packages/build/src/vite.ts flags an app's own --xs-style variables declared outside the StyleX stylesheet, so default builds warn and 'error' builds fail.
  • armClickGuard adds and removes its window listener per gesture, where useMenuPress PR2 says it is installed once per document.
  • .changeset/click-guard.md and .changeset/reduced-motion-record.md describe internal, unexported work as consumer fixes.

[Automated review]

Comment thread packages/core/src/utils/clickGuard.ts Outdated
Comment on lines +60 to +75
subscribeInput(
view,
'click',
event => {
if (isExempt(event)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
disarmClickGuard(doc);
},
{cancels: true},
),
// The next press is a new gesture: its click must never be eaten.
subscribeInput(view, 'pointerdown', () => disarmClickGuard(doc)),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

useMenuPress.spec.md PR2 says the window capture click listener is installed once per document and armed per gesture; here it is subscribed at every arm and removed at every disarm, and clickGuard.test.ts asserts that. Either keep one installed listener per document and arm it per gesture, or have PR2 amended.

Comment thread packages/build/src/vite.ts Outdated
Comment on lines +419 to +431
const declared = new Set(
[...css.matchAll(new RegExp(`(--(?:${names})[a-z0-9]+)\\s*:`, 'g'))].map(
match => match[1],
),
);
const read = new Set(
[
...css.matchAll(
new RegExp(`var\\(\\s*(--(?:${names})[a-z0-9]+)\\s*[,)]`, 'g'),
),
].map(match => match[1]),
);
return [...read].filter(name => !declared.has(name)).sort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any --x… or --astryx… name of lowercase letters and digits counts as generated here, and only declarations inside the StyleX stylesheet count, so an app style reading var(--xs) that its global CSS declares is reported as dropped (a warning by default, a failed build with 'error'). Could the check use the variable and constant names StyleX actually generated (from rulesById), or the declarations of every emitted CSS asset?

Comment thread .changeset/click-guard.md Outdated
'@astryxdesign/core': patch
---

[fix] The gesture recognizers and the menu press model share one guard for the browser's click after a gesture that already acted. The gesture system now swallows that click for 400ms or until the next press, as menus do, instead of 1000ms. Script-dispatched clicks with a non-zero `detail` are swallowed in that window too; keyboard and assistive-technology clicks (`detail` 0) always pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No release has the gesture system or its 1000 ms window, and menu users see no change, so this reads as a consumer fix that is not one. The other internal touch changesets use "[chore] Internal: … Not exported from the package entry point"; could this one do the same?

Comment thread .changeset/reduced-motion-record.md Outdated
'@astryxdesign/core': patch
---

[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The animator is internal at this head (there is no experimental subpath), so "The experimental touch animator", its removed call options and reducedMotion.stylex.ts are nothing consumers can import. Could this be worded as an internal change like the other touch changesets?

} from './GestureArena';
import type {GestureRecognizer} from './GestureRecognizer';
import type {PressBounds} from './PressTracker';
import {HAPTIC_SWITCH_ATTRIBUTE} from './hapticSwitch';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HAPTIC_SWITCH_ATTRIBUTE has no use here since onClick moved into utils/clickGuard.ts (an unused-import warning); the headers of hapticSwitch.ts and haptics.ts also still describe the host's own swallower.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 5337c27

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC
Pagination 90.2 kB (+776 B) · 15.8 kB CSS (0) —
TableCell 80.8 kB (+769 B) · 15.7 kB CSS (0) —
ContextMenu 79.6 kB (+761 B) · 15.3 kB CSS (0) —
103 more components
Component Size LoC
Table 89.4 kB (+756 B) · 16.0 kB CSS (0) —
TableHeaderCell 80.4 kB (+756 B) · 15.4 kB CSS (0) —
lab/CommentThread 222.7 kB (+738 B) · 2.4 kB CSS (0) —
richtext/RichTextEditorToolbar 218.1 kB (+737 B) · 454 B CSS (0) —
Markdown 149.9 kB (+722 B) · 18.2 kB CSS (0) —
PowerSearch 176.1 kB (+703 B) · 20.3 kB CSS (0) —
lab/Comments 227.3 kB (+686 B) · 2.5 kB CSS (0) —
TopNavMenu 62.0 kB (+685 B) · 10.5 kB CSS (0) —
SideNavHeading 62.5 kB (+682 B) · 10.5 kB CSS (0) —
Selector 81.4 kB (+671 B) · 15.4 kB CSS (0) —
TopNavHeading 62.0 kB (+656 B) · 10.3 kB CSS (0) —
TabMenu 59.2 kB (+654 B) · 10.4 kB CSS (0) —
MoreMenu 77.7 kB (+644 B) · 15.4 kB CSS (0) —
DropdownMenu 77.4 kB (+641 B) · 15.4 kB CSS (0) —
DropdownMenuSubMenu 62.7 kB (+639 B) · 9.9 kB CSS (0) —
BreadcrumbItem 71.7 kB (+579 B) · 11.4 kB CSS (0) —
richtext/RichTextEditor 273.7 kB (+541 B) · 2.6 kB CSS (0) —
NavHeadingMenu 14.3 kB (+527 B) · 3.4 kB CSS (0) —
FileInput 58.2 kB (+522 B) · 10.7 kB CSS (0) —
AvatarGroupOverflow 43.1 kB (+520 B) · 6.5 kB CSS (0) —
lab/MobilePowerSearch 161.4 kB (+520 B) · 1.2 kB CSS (0) —
lab/ChatReactionBar 69.8 kB (+514 B) · 1.1 kB CSS (0) —
ClickableCard 14.5 kB (+510 B) · 7.1 kB CSS (0) —
DateInput 104.4 kB (+510 B) · 15.1 kB CSS (0) —
SideNavItem 58.6 kB (+510 B) · 10.4 kB CSS (0) —
lab/ChatEmojiPicker 69.2 kB (+510 B) · 773 B CSS (0) —
DrawerHeader 52.5 kB (+505 B) · 11.5 kB CSS (0) —
LayerProvider 67.6 kB (+504 B) · 10.4 kB CSS (0) —
DialogHeader 52.4 kB (+502 B) · 10.1 kB CSS (0) —
lab/CommentPin 51.2 kB (+500 B) · 1.2 kB CSS (0) —
Token 32.1 kB (+498 B) · 4.8 kB CSS (0) —
CodeBlock 63.9 kB (+495 B) · 11.0 kB CSS (0) —
Banner 51.4 kB (+494 B) · 10.2 kB CSS (0) —
lab/TransferListSelector 76.8 kB (+494 B) · 1.9 kB CSS (0) —
CommandPalette 54.6 kB (+493 B) · 12.1 kB CSS (0) —
MultiSelector 77.1 kB (+493 B) · 15.0 kB CSS (0) —
SelectableCard 14.3 kB (+493 B) · 7.3 kB CSS (0) —
lab/TransferList 71.1 kB (+493 B) · 1.7 kB CSS (0) —
BaseTypeahead 59.2 kB (+492 B) · 10.2 kB CSS (0) —
Tokenizer 74.0 kB (+492 B) · 16.9 kB CSS (0) —
lab/TourStep 58.6 kB (+492 B) · 590 B CSS (0) —
lab/Schedule 64.6 kB (+491 B) · 3.9 kB CSS (0) —
TopNavMegaMenuItem 10.6 kB (+490 B) · 4.2 kB CSS (0) —
ChatDictationButton 50.0 kB (+489 B) · 9.8 kB CSS (0) —
lab/MobileTokenizer 97.0 kB (+489 B) · 880 B CSS (0) —
lab/DiffViewer 47.7 kB (+488 B) · 2.1 kB CSS (0) —
Carousel 54.0 kB (+487 B) · 10.5 kB CSS (0) —
Popover 53.2 kB (+487 B) · 12.4 kB CSS (0) —
lab/CommentComposer 98.4 kB (+487 B) · 1.9 kB CSS (0) —
ContextMenuItem 57.1 kB (+486 B) · 9.9 kB CSS (0) —
Stepper 51.0 kB (+486 B) · 9.9 kB CSS (0) —
TopNavMegaMenu 58.1 kB (+486 B) · 11.1 kB CSS (0) —
TableSelectionToolbar 56.1 kB (+485 B) · 11.4 kB CSS (0) —
ChatComposer 65.6 kB (+484 B) · 11.1 kB CSS (0) —
CommandPaletteItem 9.9 kB (+482 B) · 3.6 kB CSS (0) —
Step 34.8 kB (+482 B) · 5.6 kB CSS (0) —
lab/ListInput 79.0 kB (+480 B) · 1.6 kB CSS (0) —
ChatLayout 52.5 kB (+479 B) · 10.2 kB CSS (0) —
MobileNavToggle 49.8 kB (+479 B) · 9.7 kB CSS (0) —
ToggleButton 47.4 kB (+479 B) · 9.5 kB CSS (0) —
ChatSendButton 49.3 kB (+477 B) · 9.4 kB CSS (0) —
DateRangeInput 73.6 kB (+477 B) · 11.5 kB CSS (0) —
Tab 11.2 kB (+477 B) · 4.2 kB CSS (0) —
ChatLayoutScrollButton 50.0 kB (+476 B) · 9.9 kB CSS (0) —
Collapsible 22.1 kB (+476 B) · 4.5 kB CSS (0) —
AlertDialog 60.4 kB (+475 B) · 12.7 kB CSS (0) —
AppShell 72.2 kB (+474 B) · 12.0 kB CSS (0) —
SideNavCollapseButton 50.3 kB (+474 B) · 9.8 kB CSS (0) —
Item 13.2 kB (+473 B) · 4.9 kB CSS (0) —
DropdownMenuCheckboxItem 18.2 kB (+472 B) · 5.9 kB CSS (0) —
SegmentedControlItem 10.2 kB (+472 B) · 4.0 kB CSS (0) —
Typeahead 66.9 kB (+472 B) · 11.7 kB CSS (0) —
DropdownMenuRadioItem 18.2 kB (+471 B) · 5.9 kB CSS (0) —
TopNavItem 10.6 kB (+471 B) · 4.0 kB CSS (0) —
Outline 31.2 kB (+469 B) · 4.2 kB CSS (0) —
Toast 63.5 kB (+469 B) · 10.0 kB CSS (0) —
DateTimeInput 116.1 kB (+468 B) · 15.6 kB CSS (0) —
ComplexSelector 60.1 kB (+466 B) · 11.0 kB CSS (0) —
ListItem 57.6 kB (+466 B) · 11.0 kB CSS (0) —
SelectorOption 16.5 kB (+466 B) · 5.4 kB CSS (0) —
ChatComposerInput 60.2 kB (+465 B) · 10.3 kB CSS (0) —
SideNav 70.0 kB (+465 B) · 10.9 kB CSS (0) —
ChatComposerTokenElement 60.2 kB (+464 B) · 10.3 kB CSS (0) —
DropdownMenuItem 17.4 kB (+464 B) · 5.5 kB CSS (0) —
RadioListItem 56.2 kB (+463 B) · 10.0 kB CSS (0) —
richtext/RichTextView 254.2 kB (+463 B) · 2.3 kB CSS (0) —
Link 47.1 kB (+458 B) · 7.9 kB CSS (0) —
CheckboxListItem 58.2 kB (+456 B) · 11.0 kB CSS (0) —
TreeList 43.6 kB (+456 B) · 5.0 kB CSS (0) —
Thumbnail 54.3 kB (+455 B) · 11.3 kB CSS (0) —
MobileNav 54.8 kB (+454 B) · 10.4 kB CSS (0) —
Button 46.5 kB (+451 B) · 9.4 kB CSS (0) —
IconButton 46.6 kB (+450 B) · 9.4 kB CSS (0) —
TextInput 57.3 kB (+450 B) · 10.8 kB CSS (0) —
Calendar 58.7 kB (+448 B) · 10.3 kB CSS (0) —
Lightbox 55.0 kB (+445 B) · 10.1 kB CSS (0) —
lab/EmojiPicker 67.0 kB (+442 B) · 722 B CSS (0) —
NumberInput 59.6 kB (+437 B) · 11.1 kB CSS (0) —
CheckboxInput 53.5 kB (+433 B) · 9.6 kB CSS (0) —
TopNav 55.7 kB (+429 B) · 10.7 kB CSS (0) —
Switch 51.9 kB (+425 B) · 9.7 kB CSS (0) —
TimeInput 92.7 kB (+416 B) · 14.1 kB CSS (0) —
Timestamp 28.4 kB (0) · 9.9 kB CSS (0) —

Package size: @astryxdesign/core 5.76 MB packed (+79.2 kB); no new dependencies.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: the shared flat-tree fix in presented hit testing holds and the record sentences are approved for this head, so one evidence item remains. spec:AST-094's Platform support asks for real-device evidence for every iOS Safari claim, and presented hit testing still rests on the iOS simulator and Playwright WebKit; the phone check of the preview could not exercise it, since no story, sandbox or docs page uses the gesture host. Please add the physical-iPhone take of the presented tap and drag proof with its correction-off control (#9310), or have an engineering owner record a merge/release split for this evidence in AST-094.

[Automated review]

vjeux and others added 9 commits November 1, 2026 10:21
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 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, and the haptics module's baseline entry goes with its raw
name. No behavior changes.
…y the gesture host and the selection lock

The selection lock and the gesture host each kept a copy of two helpers:
the iOS WebKit engine check, and the walk that asks whether a node is
inside an element along the flat tree. The engine check now lives in
utils/touchQueries.ts, the React-free half of touchPresentation.ts, as
IOS_SUPPORTS_QUERY with one isIosWebKit(view) reader; touchPresentation.ts
re-exports the query, so its export is unchanged. The walk is
containsAlongFlatTree in utils/flatTree.ts, beside parentAcrossShadow.
Unit cases cover both in their new homes. No behavior changes.
No Core or Lab component attaches a gesture recognizer yet, so the
input-stream changeset's fix entry told consumers about a change they
cannot meet. It now keeps `[fix]` for what ships, touch press feedback
hearing scrolls and page hides on the window's capture phase, and a new
`[chore] Internal:` changeset carries the recognizers' shared stream and
its passive-unless-cancelled listeners.
…along the flat tree everywhere

Two checks of whether a node is inside a moving element still stopped at a
shadow boundary. presentedOffsetOf walked a node's light-DOM parents, so a
control slotted into a moving shadow-tree panel read no offset and, on iOS,
its press bounds stayed where the panel started; it now walks
parentAcrossShadow, through the slot. The host's check for a claiming
recognizer in or around a moving element used Node.contains, so a pan in an
open shadow root of a moving sheet, or around a moving page in its own
shadow root, left the document without its blocking touchmove; it now uses
containsAlongFlatTree both ways, as presentedTarget does. A presented
hit-testing case for each failed before.
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main with #9376, which carries this pull request's two .github lines (the WebKit interaction job's scope and its test's example), so 9775a27 changes no file under .github; its other changes are the same lines as before.

@cixzhang

cixzhang commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 9775a27

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: the code and the approved record sentences hold, but presented hit testing's evidence and the new AST-094 verification row need work before this can merge. The iOS Safari claim still rests on the simulator and Playwright WebKit, while AST-094's Platform support asks for real-device evidence: add the physical-iPhone take of the presented tap and drag proof with its correction-off control (#9310), or have an engineering owner amend Platform support with a durable merge/release split. The new verification row cites #9310, this pull request's preview and a date, which architecture:knowledge-contracts INV10 keeps out of records, so drop it or replace it with a rule that names none of them.

[Automated review]

| FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails |
| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a slider's thumb under a wheel and a trackpad scroll; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, a wheel or trackpad scroll that changes a thumb's value or does not reach the scroll under it, an idle listener in a component with no gesture, or added stylesheet bytes, fails |
| FR43 trackpad | Unit tests on timed wheel streams; WebKit browser tests with trackpad streams (Chromium's driver wheels read as a mouse wheel) | a form and a required sheet pulled 30 to 240 px by a finger and by a trackpad; mouse lines, notches, ticks and accelerated deltas over a sheet, a pager and a row; ten swipes each starting mid-momentum, ten whose momentum ends, and a slow drag with 150 ms pauses; a trackpad flick and a slow stopped drag on an info sheet; a fling, a slow 60 px swipe, a 42 px spike and back-to-back swipes on a pager; a row opened and swiped back within one gesture, and a long fast swipe; a pinch ending | a trackpad stretch that differs from a finger's for the same travel (120 px gives 54 px on the reference sheet), a mouse wheel that moves a sheet, a pager or a row, splitter counts other than ten, ten and one, momentum that moves a surface, a flick released with no velocity, a pager that travels past one page or pages on a spike, an info sheet a flick leaves open or a slow stopped drag closes, an open row that reveals its other side within one gesture, or a pinch that ends after 100 ms fails |
| FR11, FR19 iOS | Presented tap/drag proof: CI WebKit (`pr-interaction-webkit`) and the iOS Simulator | owner phone check of the preview on 10/31 | evidence gap: no recorded physical-iPhone take yet (#9310). |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row cites #9310, a dated check of this pull request's preview and a "yet", which architecture:knowledge-contracts INV10 keeps out of records ("Pull requests and issues are not standing content"), and its last column names no failure. The preview has no surface that attaches the gesture host or registers a presented offset, so the phone check could not exercise FR11 or FR19. Drop the row and keep the tracking in #9310, or state a durable rule (for example a merge/release split in Platform support) that names no issue, preview or date.

@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

On 8759525, the AST-094 verification row is gone: a record cites no issue, preview or date (architecture:knowledge-contracts INV10), and the physical-iPhone take stays tracked in #9310. The code and the approved record sentences are unchanged.

@cixzhang

cixzhang commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 8759525

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: the record sentences are approved for this head and dropping the verification row settles the last round's record finding, so two items remain.

  • Presented hit testing's iOS Safari claim still needs the real-device evidence AST-094's Platform support asks for: the physical-iPhone take of the presented tap and drag proof (#9310), or an engineering owner's amendment of Platform support with a merge/release split.
  • The moving-content blocking touchmove in GestureHost.ts (syncMoverBlocking) registers on the document directly; subscribe it through subscribeInput with {cancels: true}, as the page hold does, or say in the code why the stream can't serve it.

[Automated review]

Comment on lines +1346 to +1348
this.doc.addEventListener('touchmove', this.onBlockingTouchMove, {
passive: false,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the host's one page-level listener outside subscribeInput. The page hold's cancelling touchmove already subscribes there with {cancels: true} (pageHold.ts:205), and the INV13 sentence this pull request adds names one window-capture listener per event type. Subscribing onBlockingTouchMove the same way keeps one path (the window's capture listener sees every touch on the page); if the stream can't serve this case, a comment here saying why would settle it.

`family:action-groups` member tracks activates at the lift (FR26), and a toggle
under `spec:AST-020` FR9's slide-off exception (Switch, Checkbox,
CheckboxListItem) may also complete on a lift after the finger slid off.
CheckboxListItem) may also complete on a lift after the finger slid off. Where the engine hit-tests an element a compositor animation moves at the motion's start (iOS WebKit), a touch on moving content goes to what was drawn under the finger at touch-down, and a tap there (no recognizer claimed it, the browser did not take it, and it stayed within 45 pt) MUST activate it: the browser's own click for that touch is cancelled or swallowed, and that element receives one click, an editable focused first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This sentence is an iOS WebKit claim. AST-094's Platform support asks for real-device evidence for every iOS Safari claim, and the evidence so far is the iOS 26.2 simulator and Playwright WebKit (#9310).

vjeux added 2 commits November 1, 2026 12:50
…gh the shared input stream

While an element a claiming recognizer lives in moves on the compositor,
the host's blocking touchmove now subscribes to the window's capturing
touchmove through subscribeInput with {cancels: true}, as the page hold
does, instead of adding its own non-passive listener on the document. The
one input stream carries every page-level listener
(architecture:react-component-runtime INV13), and its window capture phase
sees every touch whatever the engine targets. The presented hit-testing
cases for that listener now check that the window's touchmove turns
non-passive while the element moves and passive again after, with no
touchmove listener on the document; both failed before.
…ng is verified

Presented hit testing on iOS WebKit merges on unit tests that mimic its
hit testing, the WebKit tap and drag proof with the correction off as its
control, and the iOS Simulator; real-device evidence for it follows merge.
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

On 5337c27: the moving-content blocking touchmove subscribes through subscribeInput with {cancels: true}, as the page hold does (the tenth commit, with presented hit-testing cases that failed before it), and AST-094's Platform support states the merge/release split for presented hit testing in its owners' words, naming no issue, preview or date (the eleventh).

@cixzhang

cixzhang commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 5337c27

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks ready to merge: every change matches the current AST-094 and useMenuPress records or the record sentences an engineering owner approved for this head, and the moving-content touchmove blocker now goes through the shared input stream. CI is green at this head, including the WebKit tap and drag proof that AST-094's new Platform support bullet asks for. One optional cleanup: the gestures README's "Touches on moving elements", the field comment at GestureHost.ts:411 and the inputStream.ts header's list of cancelling subscribers still say the document carries that blocker.

[Automated review]

private readonly delegated = new Set<DelegatedConsumerType>();
/** Stops following the animator's moving elements (iOS WebKit only). */
private stopWatchingMovers: (() => void) | null = null;
/** Whether the document carries the blocking touchmove for a mover. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: since the blocker subscribes through subscribeInput on the window's capture phase, this comment (and the README's "the document carries the blocking listener") no longer matches. Something like "Unsubscribes the window's capturing blocking touchmove for a mover" would say what this holds.

This branch was successfully deployed

1 active deployment
Preview — 5337c27d 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

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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