Skip to content

feat(Layer): one input hold, first used by Dialog, Drawer and MobileNav (spec:AST-094 FR35) - #8342

Draft
vjeux wants to merge 9 commits into
meta:mainfrom
vjeux:uikit/c18-presentation-hold
Draft

vjeux wants to merge 9 commits into
meta:mainfrom
vjeux:uikit/c18-presentation-hold

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8763 and stacks on its head. Review only this PR's change: vjeux/astryx@a20b6c8...f41ae1e

This PR folds in #8630 (Dialog and Drawer) and #8374 (MobileNav), under the maintainers' section plan. Both close with a link here, and the commit credits their authors.

Why

On a phone, a tap or a drag can land while a Dialog, a Drawer or the MobileNav drawer is still entering. That touch reaches whatever is under the finger: a tap on the backdrop closes the dialog, a button runs mid-entrance, and a drag scrolls the page. The MobileNav drawer also takes taps while it slides in or out. UIKit delivers no touch until a presentation finishes.

What led to it

This is part of the touch-presentations work, which brings Astryx's touch behavior to iOS's (spec:AST-094). FR35 asks every touch presentation to take no pointer input while it enters. Keys and assistive technology stay live, and so does a mouse (DEC-16). Each component that needed this had started to grow its own hold. The maintainers' section plan lands the shared hold together with its first users, in this one PR.

Options and tradeoffs

  1. Each component blocks its own input, as components did before. That means several holds, each with its own edge cases, which drift apart.
  2. Block all input during an entrance. This is the simplest, but it also holds keyboard and screen-reader users and a mouse, which FR35 keeps live.
  3. One shared hold in the Layer module that decides by each event's pointer. This needs one implementation. A finger's or a pen's press reaches nothing until the presentation is done, while keys, assistive technology and a mouse stay live.

Why this choice

Option 3. Layer/inputHold (internal) adds holdInput(layer, {until, scope}):

  • The shield: while a hold runs, a transparent manual popover inside the layer covers the viewport.
  • The scopes: 'viewport' holds input while a presentation enters. 'layer' holds it while the presentation leaves, with the layer's content inert and focus moved out of it.
  • Decided per event: a finger's or a pen's press is held to its end, even past the release. A mouse passes once it moves with no button down (DEC-16).
  • Pinch: a touch's start is never cancelled, but a one-finger move is, so a pinch still zooms the page (FR10).
  • No gesture code in the root entry: the gesture host asks the hold about each touch (holdsTouch). The hold itself imports nothing from the gesture modules, so a component in the root entry can import it statically (architecture:react-component-runtime INV13).

Its first users:

  • Dialog and Drawer hold input for exactly as long as their own entrance animations run (holdEntranceInput, which reads them with getAnimations()). An AlertDialog enters through Dialog, so it is held too.
  • MobileNav holds the viewport until its slide-in transition ends. From a close request, it holds the drawer until the drawer closes, at most 250 ms later (FR5).
  • AppShell lets MobileNav's slide-out play to its end.

How to check it

In Chrome's or Safari's phone emulation:

  1. Dialog stories: open a dialog and tap its backdrop while it is still entering. Nothing happens, and it finishes opening; before this change, it closed. A tap on a button mid-entrance does nothing, and once the dialog has settled the button works.
  2. Drawer stories: the same, and a drag mid-slide scrolls nothing.
  3. AppShell stories: open the mobile nav and tap a link while it slides in. Nothing happens; once the nav is in, links work. Close it, then tap the page while it slides out: nothing happens.
  4. With a mouse: a click during an entrance acts as before. Escape always closes.

Evidence

  • Local gates at f41ae1e: typecheck, check:repo, eslint and Prettier clean; pnpm test:ui 16,903 passed; the Node tests 14,501 passed (2 expected failures); Storybook built; browser tests: the input hold, Dialog and Drawer entrance specs 54 of 54 in Chromium over two runs, and 23 in WebKit with 4 skipped (the native scroll and pinch cases WebKit's driver can't inject, and Chromium-only cases); MobileNav's touch-presentation spec 16 of 16 in Chromium over two runs.
  • Red-first: each case that claims a hold fails with that hold removed. For Dialog and Drawer, holdEntranceInput returns null. For MobileNav, its holds are removed. For the hold itself, it stops following a touch to its end.
  • One take each: every browser case lands its input at an exact point (an entrance held where it is, Playwright's clock, or an exact step of a drag), and none retries.

@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 Oct 31, 2026 12:09am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 31, 2026, 12:20 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from cf96937 to 5f964ec Compare October 25, 2026 02:40
@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from 5f964ec to bc6133a Compare October 25, 2026 03:53

@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 shared presentation hold in packages/core/src/Layer/presentationHold.ts and the draft spec:AST-094 requirements (FR1–FR23) have no current governing record, so an engineering owner needs to settle whether the Layer module owns one hold for every layer (today architecture:layer-runtime INV9 keeps pointer and touch handlers with their owning component), identify the record that carries it, and approve or reject AST-094 by commenting /approve-spec <full head SHA>; the author may do so as an engineering owner. Independently of that decision, these changes are needed:

  • Land the six dependency pull requests and rebase, so the compare against main holds only the presentation-hold commit instead of 37,287 authored lines.
  • Release the usePressFeedback text-selection lock with a non-experimental changeset, since spec:AST-017 FR22 keeps stable runtime behavior out of [experimental].
  • Remove the platform adaptation axis from packages/cli/assets/theme.template.ts, which defineTheme rejects.
  • Let the status effect in useAlertMotion release its own hold, so StrictMode's effect replay acquires it again.
  • Correct the two changesets that misstate the focus change and the theme md point.

[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

Choose a reason for hiding this comment

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

This lists a platform adaptation axis, but defineTheme refuses when: {platform: 'ios'} (packages/core/src/theme/expandMotionRoles.test.ts:137-145) and spec:AST-012 FR2 closes when to width, height, pointer, contrast and motion. A theme author who follows this gets a build error; drop the platform clause.

Comment thread .changeset/touch-selection-lock.md Outdated
'@astryxdesign/core': patch
---

[experimental] A held finger or pen no longer starts text selection or the iOS callout on a press-tracked control or cell, or on a recognizer created with the new `preventsTextSelection` option (`@astryxdesign/core/gestures/experimental`), as on UIKit controls. The gesture host turns them off for the contact and restores them when it ends, on a non-text input under the finger inside such an element too (WebKit's own style makes every input selectable), and only for recognizers that would take the touch; the document press controller (`usePressFeedback` rows) takes the same lock on any coarse pointer. Text fields keep their selection, a mouse keeps desktop selection, and other pans leave their content 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.

The usePressFeedback part of this changes stable runtime behavior on Button, Link, Item and every other press-feedback surface under a finger, which spec:AST-017 FR22 keeps out of [experimental]. Give that part its own [fix] or [feat] entry naming the stable change.

Comment on lines +204 to +211
// Unmounting lets the alert go at once, hold included.
useEffect(
() => () => {
releaseHoldRef.current?.();
releaseHoldRef.current = null;
},
[],
);

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 <StrictMode> this cleanup runs in the effect replay and releases the hold that the status effect below acquired; for an alert mounted open, the replayed status effect returns at was === now, so the hold never comes back in development (architecture:react-component-runtime INV3). Letting the status effect own the release of its hold would make the replay re-acquire it.

Comment thread .changeset/layer-presentation-hold.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: one presentation hold for every layer (`Layer/presentationHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates. AlertDialog's touch presentation now uses it, with no change in behavior. Not exported from the package entry point.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Focus now leaves the fading alert's content (its children become inert), as the pull request description says, so “no change in behavior” undersells it; name the focus change.

// and large touch displays stay anchored, phones in either orientation get
// the sheet. The query is the shared touch presentation's
// (utils/touchPresentation.ts).
export const COMPACT_TOUCH_PRESENTATION_QUERY = COMPACT_TOUCH_QUERY;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is the default compact definition, so a theme that overrides md still gets 768 px here, while .changeset/touch-presentation.md promises the theme's md point (spec:AST-012 FR9 asks for the effective md). Either read the active theme's definition or say 768 px in the note.

@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from bc6133a to 77d538e Compare October 25, 2026 05:30
@vjeux vjeux changed the title refactor(Layer): one presentation hold for every layer, AlertDialog first feat(Layer): one presentation hold for every layer Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
Under a finger AlertDialog held its own input during its presentation:
the action tracker dropped touches through an isInert predicate, and
render guards dropped a click, Enter or Escape while it left. It now uses
the Layer module's one hold (meta#8342):

- useAlertMotion holds the alert with scope 'viewport' from the
  presentation's start until UIKit's touch window after the start the
  browser reports (alertController.present.ignoresTouchesForMs, a
  cancellable timer), or until the reduced-motion cross-fade ends; and
  with scope 'layer' until the dismissal's fade ends. acceptsTouches
  reads the hold.
- AlertDialogTouch drops the tracker's isInert predicate: the gesture
  host's touch gate holds the alert's touches. AlertDialog drops its
  click guards; the Escape guard stays, since inert content does not
  stop the dialog's own cancel event.

One detail moves: while the alert fades out, focus leaves its inert
content, and Dialog still returns it to the opener at the end. The leave
test now also checks what a tap hits (the shield, on the alert and over
the opener) and a key during the exit (focus already out, nothing runs).
@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from d7f968b to 2eec9ff Compare October 25, 2026 07:27

@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. No current record states a shared presentation hold in the Layer module (packages/core/src/Layer/presentationHold.ts), and architecture:layer-runtime INV9 keeps backdrop, pointer and touch handlers with their owning component. An engineering owner needs to provide or identify the record that states the hold and its shield, and to record that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever that decision is, these changes are needed:

  • Land #8045, #8062, #8074, #8075 and #8336 and rebase, so that the compare against main holds only the presentation-hold commits instead of 32,819 authored lines.
  • Give the velocity tracker the stillness reset that spec:AST-100 FR2 requires, and drop its alternative filters and the velocityStrategy option (architecture:interaction-modality INV10); PanGestureRecognizer.test.ts currently expects 2,000 px/s after a still pause.
  • File .changeset/reduced-motion-record.md as an internal change, since the animator it describes is not exported.
  • Either limit the press-feedback selection lock to touch, or show the before-and-after pen evidence that spec:AST-017 FR59 asks for.

[Automated review]

* header describes, `uikit` by default.
* @experimental
*/
export type VelocityStrategy = 'uikit' | 'lsq2' | 'iosFling' | 'window';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-100 (current) says the first Core velocity source has "no selectable alternative filters", and architecture:interaction-modality INV10 keeps one release filter. FR2 also requires a release of 0 after a still finger, while the uikit filter "never decays" (lines 13-20) and PanGestureRecognizer.test.ts:77-92 expects 2,000 px/s after a 100 ms rest. Keeping only uikit, adding the stillness reset and removing velocityStrategy would conform.

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

Choose a reason for hiding this comment

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

The animator has no export path at this head, and uikit-motion-animator-curves.md already records this behavior as [chore] Internal. As a [fix] naming removed reducedMotion and crossfade options, this reads as a change to an API consumers could import. Filing it as [chore] Internal would match its siblings.

if (!isHeld || layer == null) {
return;
}
// Released by the cleanup alone.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV3 asks a new Effect to name the external system it synchronizes. A comment saying it synchronizes the layer's hold (its shield, its content's inert, the gesture host's gate) with isHeld would cover it; the acquire and release pairing is already right.

@@ -283,6 +301,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

Choose a reason for hiding this comment

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

This lock also runs for a pen (only mouse returns early at line 270), and a pen is a fine pointer under spec:AST-017 FR59, which asks for before-and-after fine-pointer evidence. Either limit the lock to pointerType === 'touch', or keep pen and show that evidence.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on a spec decision: no current record gives the Layer module an input hold for every layer, so an engineering owner needs to record its presenting scope beyond spec:AST-094 FR35's growth case and its dismissal scope (inert content, focus not given back), or accept both as filed by commenting /approve-spec d535664aec9b191e539ecfa9834f79549d4eaf83 on this pull request, which the author may do as an engineering owner. Separately, these need changes:

  • Land #8336 first and retarget, so the diff is the five input-hold files rather than 24,028 authored lines.
  • Fix the hold itself: keep the shield until a touch still down at the release ends (inputHold.ts:236-242), stop held presses reaching capture-phase listeners (:176-178), make useInputHold follow a replaced node (:317-327), and remove the touch gate with the last hold, with a gate test that fails without it (inputHold.test.tsx:189-204).

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on a spec decision: spec:AST-094 FR35 now covers holding input while a presentation enters, but no current record gives the Layer module one input hold for every layer or its dismissal scope (every touch dropped, content inert, focus not given back), so an engineering owner needs to record it, with or without that scope, or accept the module as filed by commenting /approve-spec with this pull request's full head SHA, which the author may do as an engineering owner. Separately, these need changes:

  • Land #8336 first and retarget, so the diff is the five input-hold files rather than 24,078 authored lines.
  • Keep the shield until a touch still down at the release ends (inputHold.ts:236-242), and make useInputHold follow a replaced or late node (:317-327).
  • Fix InputHoldScope's "the layer alone" (:68): both scopes cover the viewport, so FR35's non-modal sheet case has no scope.
  • Remove the touch gate with the last hold, with a test that fails without it (inputHold.test.tsx:189-204).

[Automated review]

@vjeux

vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Offer for this PR: the input hold decides by each event's pointer (spec:AST-094 FR35). Branch: https://github.com/vjeux/astryx/tree/offer/8342-per-event-pointer (2daeace, on this PR's ed17d69), touching Layer/inputHold.ts, its unit test and its browser spec, and the one line where the gesture host asks the hold (holdsTouch, its per-event answer, where this PR's host asks isInHeldLayer). Tests first: vjeux@2ad5763b86 (red on this PR), then the fix: vjeux@a6a3fb90ff; for FR24's resource budget, tests vjeux@a9f434495a, then the fix vjeux@b16ad3cf45, which also undoes the between-holds pointer watch of 4e367b7 and cfb40df; for a mouse press that begins on the shield, tests vjeux@e3e24e79dc, then the fix vjeux@f352dde08c; for errors the hold must not hide, tests vjeux@9198e5f39d, then the fix vjeux@081d48ecea.

What it fixes: the shield takes every hit, so on a touch screen a mouse or a trackpad loses its presses while a presentation enters. FR35 says "A mouse keeps today's behavior". Found in #8630's review.

What changes:

  • A finger's or a pen's press that lands while a layer is held is swallowed on the window, with its moves, release, click and compatibility mouse events, to its end, even past the release. A touch's start is never canceled, so a pinch still zooms. A move is canceled only while one finger is down.
  • During a presentation a mouse passes. Once it moves with no button down, the shield stops taking hits and the gesture host's gate lets it through. A finger's press makes the shield take hits again. A finger that lands while the shield is letting the mouse through is captured to it, so its release stays the hold's and a light-dismissing layer stays open. A mouse press that begins on the shield stays the shield's to its end: its release and click land on it, never on the dialog behind (which would read that click as a backdrop click; found by fix(Dialog, Drawer): under a finger, a tap or a drag during the entrance reaches nothing #8630's Dialog case).
  • A dismissal's shield still takes every hit, since its content is inert.
  • Clicks with detail 0 (keys, assistive technology) pass. A layer that leaves the document holds nothing more.
  • Errors: no part of the hold is wrapped in a catch or reads its input through a default. A finger is captured to the shield only for a trusted press (a synthetic one has no active pointer, and setPointerCapture throws for it). The unit file fails on any error a listener throws (jsdom reports it as a window error event and keeps dispatching), and the browser spec fails on any page error. One existing case changes for this: its synthetic touchstart now carries its touch lists and a touchend, as every touch does.
  • Resources (spec:AST-094 FR24): while any of its layers is held, a document has one gesture-host gate and one set of window listeners, however many layers are held. The gate goes when the last hold ends, and the listeners once the presses they hold have ended; nothing listens while nothing is held. The gate, which stayed registered for the page's lifetime, now goes with the last hold too.
  • Limit: nothing listens while nothing is held (FR24), so a hold cannot know a pointer used before it began: a mouse press made before the mouse has moved during the hold still meets the shield. A press is hit-tested before its pointerdown fires, so the shield cannot step aside mid-press, and starting the shield down would let a finger's first contact reach the control beneath (WebKit shows it as :active).

This also covers the touch still down at the release from my earlier offer here (it now reaches nothing to its end), so it supersedes that offer.

A test that observes a held touch listens on the window before the hold starts: the document no longer hears it.

Evidence:

  • Unit: 25/25, including 14 new cases that fail on this PR.
  • Chromium: 13/13. WebKit: 11 passed, 2 skipped. Four new cases fail in both engines on this PR: a mouse press reaches a held dialog's recognizer as with no hold; a mouse clicks a held popover's action and dismisses it with a click outside, while a finger's tap between them is held; a mouse press made before the mouse moves meets the shield to its end; a touch that lifts after the release reaches nothing.

The window logic comes from the Dialog and Drawer entrance hold built for #8630, which will call holdInput instead.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on a spec decision: spec:AST-094 FR35 covers holding pointer input while a presentation enters, but no current record gives the Layer module one input hold for every layer or its dismissal scope (every hit taken, content inert, focus not given back), so an engineering owner needs to record it, with or without that scope, or accept the module as filed by commenting /approve-spec with this pull request's full head SHA, which the author may do as an engineering owner. Whatever that decision, these need changes:

  • Land #8336 first and retarget, so the diff is the five input-hold files rather than 24,137 authored lines.
  • Let a mouse keep its path while a layer is held (FR35: "A mouse keeps today's behavior"), and hold a touch still down at the release to its end (inputHold.ts:129-138, :236-242).
  • Fix InputHoldScope's "the layer alone" (inputHold.ts:68), since both scopes cover the viewport and FR35's non-modal sheet case has no scope.
  • Make useInputHold follow a replaced or late node (inputHold.ts:317-327), and remove the touch gate with the last hold, with a test that fails without it (inputHold.test.tsx:189-204).

[Automated review]

// shield takes hits whatever its layer root opts out of (a full-viewport
// layer that is `pointer-events: none` and lets a backdrop child take the
// hits): inherited, they would let a held tap fall through to the page.
pointerEvents: 'auto',

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 pointer-events: auto the shield takes every pointer's hits, and SWALLOWED (lines 129-138) stops mouse presses and clicks, while the gesture host's gate also empties a held target's participants for every pointer type (gestures/GestureHost.ts:1413-1418). spec:AST-094 FR35 keeps the mouse path ("A mouse keeps today's behavior"), so a mouse or trackpad on a device whose primary pointer is coarse loses its presses while a layer enters. Deciding by each event's pointer, with a mouse case in the browser spec, would conform.

@cixzhang

Copy link
Copy Markdown
Contributor

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on a spec decision: architecture:react-component-runtime INV13 now names the input hold and spec:AST-094 FR35 covers its presenting scope, but no current record states its dismissal scope (scope: 'layer': a full-viewport shield, inert content, and focus blurred out of the layer while it dismisses). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or the dismissal scope is written into a current record, or the hold keeps only its presenting scope. Whatever is decided, these still need changes:

  • Land #8336 first and retarget, so this pull request is the five input-hold files rather than 28,479 authored lines.
  • Let mouse and trackpad presses through during a hold, since FR35 keeps the mouse path, and add a mouse case.
  • Hold a touch that is still down when the hold ends until it lifts, so its release cannot reach the page beneath.
  • Stop held presses before the page's capture listeners and add dblclick and auxclick, correct the 'layer' scope's documentation, follow node replacement in useInputHold, and remove the touch gate with the last hold, with a test that fails without it.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on two spec decisions: no current record states the input hold's dismissal scope (scope: 'layer': a full-viewport shield, inert content, and focus blurred out of the layer while it dismisses), and this head rewrites spec:AST-094 FR41 (a mouse or pen drag a recognizer claims takes no text selection) through the merged #8336 without an exact-head spec-owner approval. An engineering owner can record both decisions by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or the dismissal scope can be written into a current record or cut and the FR41 text can land with #8336. Whatever is decided, these still need changes:

  • Land #8336 first and retarget, so this pull request is the input hold's eight files rather than 29,687 authored lines.
  • Let mouse and trackpad presses through during a hold, since FR35 keeps the mouse path, and add a mouse case.
  • Hold a touch that is still down when the hold ends until it lifts, so its release cannot reach the page beneath.
  • Stop held presses before the page's capture listeners and add dblclick and auxclick, correct the 'layer' scope's documentation, follow node replacement in useInputHold, and update the changeset and test comments that still describe a touch gate.

[Automated review]

// shield takes hits whatever its layer root opts out of (a full-viewport
// layer that is `pointer-events: none` and lets a backdrop child take the
// hits): inherited, they would let a held tap fall through to the page.
pointerEvents: 'auto',

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 makes the shield take every hit whatever the pointer, and the gesture host's isInHeldLayer check (GestureHost.ts:1564-1569) empties participants for a mouse and a pen too, though its comment says only that a held layer "takes no touch". So a mouse or trackpad loses its presses while a layer is held, against spec:AST-094 FR35: "A mouse keeps today's behavior (DEC-16)." Deciding by each event's pointer, with a mouse case in the browser spec, would conform.

if (current.viewport === 0 && current.layer === 0) {
if (current.shield != null) {
liveShields.delete(current.shield);
current.shield.remove();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removing the shield here while a finger is still down on it sends that touch's pointerup to whatever lies beneath and its touchend to the detached shield, where no document listener hears it end. FR35 says a held tap or drag must not "reach anything beneath it". Holding that touch to its end would close the gap.

Comment on lines +167 to +169
for (const type of SWALLOWED) {
shield.addEventListener(type, swallow, {capture: true, passive: 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.

These listeners sit on the shield itself, so window, document and ancestor capture listeners see a held press first, with the shield as its target (a resting swipe row closes, a touch Tooltip hides). dblclick and auxclick are also missing from SWALLOWED and bubble on. Either stop held presses earlier or state the real boundary in the header.

import {useEffect, type RefObject} from 'react';
import {parentAcrossShadow} from '../utils/flatTree';

/** What a hold covers: the whole viewport, or the layer alone. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

'layer' does not cover "the layer alone": both scopes add the same full-viewport shield (:196-198), and 'layer' also makes the content inert. A non-modal sheet, whose page FR35 keeps live, has no scope that fits.

{isHeld, scope}: {isHeld: boolean; scope: InputHoldScope},
): void {
useEffect(() => {
const layer = ref.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ref.current is read once and the Effect depends on [ref, isHeld, scope], so a replaced or late-mounted layer node is never held (architecture:react-component-runtime INV6). useAttachment in this compare re-checks the node after every commit; the same shape here, with replacement and late-mount tests, would cover it.

Comment thread .changeset/layer-input-hold.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: one input hold for every layer (`Layer/inputHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act, and a pinch still zooms the page); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates, and `isInputShield` lets a hit test see past a hold's shield. Not exported from the package entry point; no component uses it yet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head the gesture host asks isInHeldLayer itself and the hold registers no touch gate, so "through its touch gates" describes a mechanism that is not there.

vjeux and others added 6 commits October 30, 2026 08:25
Three recognizers beside main's tap, long press and pan, each reading its
own UIKit table group: a swipe recognized from the path between events,
a pinch whose velocity settles, and a screen-edge pan from the leading or
trailing edge. The edge pan takes the horizontal axis only on a view that
lies within that edge's band. The sides its edges resolve to under the
view's writing direction and the edge zone are measured once, together,
for touch-action and the touch-down decision alike, and again when the
host reports a layout change; and it is a finger's only (a mouse, a
trackpad or a pen never starts it). meta#8061's change, rebased onto main as
one commit; its rotation and hover recognizers and its top and bottom
edges, which no component uses yet (and rotation's threshold is
unmeasured), land with their first users.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
… the gesture host

The browser negotiation and the gesture host on top of the recognizers:
touch-action derived from what is attached and never wider than the
element's own (spec:AST-094 FR18, DEC-8), a native scroll probe that
follows a slotted node into its shadow tree, the tap haptic, and the
host that claims a finger, suppresses the click a claimed touch would
send, and hands a touch to the browser when it takes over.

The host serves a mouse, a pen and a finger alike (FR19, FR43). Where
the primary pointer is fine, an element that uses a gesture keeps one
passive pointerdown listener and the page has none until a press, whose
listeners go once its contacts end (FR24); a right click, a pen's barrel
button and a ctrl-click on macOS start no press. It gives a screen-edge
pan its view's box and, through one ResizeObserver and no listener,
reports a resize of that box or of the viewport, so the pan's edge-zone
answer and the touch-action derived from it follow the layout (FR18,
FR19, FR24, DEC-22). Recognizers report their kind, which the host and
the touch-action rules read instead of checking a class
(architecture:react-component-runtime INV13).

meta#8071's and meta#8072's changes, with meta#8665's serving of every pointer,
rebased onto main after meta#8061 as one commit; the host's hover routing
lands with the hover recognizer's first user.

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

Core's internal touch foundation on the gesture recognizers (meta#8061) and the
gesture host (meta#8071 + meta#8072): UIControl press tracking with its press
profiles and hooks, built by createPressTracker; and the animator, which
starts a motion from rest at its commit, holds the value on screen inline
while a curve runs, and lets a call wait for a presented frame. It absorbs
meta#8073 (closed: the press tracking this rests on) and meta#8340 (the animator's
start and hold fixes), with the held dip's load fixes, and keeps meta#8641's
rule: a gesture that cancels itself while its finger moves keeps that
finger until it lifts. A gesture that ends on an unmount or a replaced node
calls no listener from the effect.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
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), and the gesture browser proof in Chromium and
WebKit with 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 section 2 as one commit; the touch-action
rule now comes with meta#8071, and meta#8072's claimed-touchend fix, which this
line never had, comes back with meta#8309.

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); a claimed pen's stylus touchend is
cancelled as a finger's (FR21); 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>
The scroll lock no longer pins <body>, which reset the page's offset to 0
(the page jumped and Safari's toolbar came back). It holds `overflow:
hidden` on the root and, through the gesture host's page hold, cancels only
a one-finger pan that nothing inside the overlay takes: a scroller, a range
input, a pinch or a zoomed page's pan still goes through. The root's scroll
styles become claim stacks (rootScrollStyles), so a lock and a pull to
refresh compose. The top-layer check asks CSS.supports for `:modal` and
`:popover-open` instead of catching errors from `matches`. The touch module
bundle test names useScrollLock and the imports that reach it as carrying
the page hold.
vjeux and others added 2 commits October 30, 2026 15:50
…come from pointer events

Where a finger comes from its pointer events, a claimed finger's
touchend is cancelled too (spec:AST-094 FR21), on the host's shared
blocking handler and only for an enabled recognizer; a lift's claim is
read after the lift, so a hold that claims with it cancels its touchend;
a blur-cancelled pen's lift is refused. A second finger fails a
one-finger pan wherever fingers come from, and the arena cancels a
moving recognizer the browser takes. meta#8309's and meta#8336's changes,
rebased onto meta#8098 as one commit.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…king ships only the press clocks

A gesture touch carries the modifier keys its press began with, so a
group can leave a Ctrl-, Cmd- or Shift-click on a link to the browser.
Press tracking reads only the timing table's press clocks, with a bundle
guard that keeps the host's module graph small
(architecture:component-style-authoring INV6), and the README documents
the recognizers' kind and the press tracker factory. meta#8763's and
meta#8797's changes, rebased onto meta#8309 as one commit; its press tracker
factory and recognizer kind now come with meta#8076 and meta#8071.
…av (spec:AST-094 FR35)

Layer/inputHold holds a layer's pointer input while its presentation
enters or leaves: holdInput(layer, {until, scope}) raises a shield (scope
'viewport' while it enters; 'layer' while it leaves, its content inert),
decides by each event's pointer (a finger's or a pen's press is held to
its end; a mouse passes once it moves with no button down, DEC-16), never
cancels a touch's start, and cancels a one-finger move so a pinch still
zooms (FR10). The gesture host asks the hold for each touch (holdsTouch),
so the hold imports nothing from gestures/ and a component in the root
entry may use it statically (architecture:react-component-runtime INV13).

Its first users: Dialog and Drawer hold a finger's tap or drag during
their entrance (holdEntranceInput). MobileNav's drawer takes no finger
input while it slides in (a 'viewport' hold until the slide-in's
transition ends) or out (a 'layer' hold from the close request until the
dialog closes), and AppShell lets the slide-out play to its end.

Every browser proof lands its input at an exact point (an entrance held
where it is, the page's clock, or an exact step of a drag) and plays once.

From meta#8342 and its per-event offer, meta#8630 (Dialog and Drawer) and meta#8374
(MobileNav).

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner: MobileNav's close-time input hold (its drawer inert, focus blurred and every hit taken for up to 250 ms), the page held around a non-modal Drawer while it slides in, and Dialog's and Drawer's finger hold under a fine primary pointer go beyond what spec:AST-094 FR35 and FR13 state, so an engineering owner either accepts them by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner) or has FR35, component:MobileNav and component:Drawer updated, or they are dropped. Independently of that decision, these still need changes:

  • Land the five stacked pull requests (#8360, #8098, #8309, #8906 and #8763) first and rebase, so this compare is the input hold alone rather than 35,733 authored lines.
  • A mouse press made before the mouse moves is swallowed during every Dialog and Drawer entrance, against FR35's "A mouse keeps today's behavior"; gating the hold on a coarse primary pointer, as MobileNav does, also avoids the 14 window listeners each entrance adds under a fine pointer (FR24).
  • .changeset/layer-input-hold.md, the 'layer' scope's doc comment and useInputHold's node handling need correcting (details inline).

[Automated review]

Comment on lines +321 to +331
if (state.shield == null) {
state.shield = addShield(layer);
// A presentation held while a mouse is the latest pointer heard lets it
// through from the start.
if (
scope === 'viewport' &&
latestPointer.get(layer.ownerDocument) === 'mouse'
) {
state.shield.style.pointerEvents = 'none';
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing is known about the pointer before the hold starts, so a presentation's shield takes every hit until the mouse moves with no button down, and holdsTouch (line 193) keeps a held target from every recognizer meanwhile. Dialog and Drawer call this on every entrance whatever the primary pointer, so a press where the mouse already rests (the second click of a quick double-click on an opener) lands on the shield and does nothing, against spec:AST-094 FR35's "A mouse keeps today's behavior". Holding only under a coarse primary pointer, as MobileNav does, and starting the shield lowered until a finger's or a pen's press is heard would keep the mouse path.

Comment on lines +41 to +53
export function holdEntranceInput(layer: Element): (() => void) | null {
if (typeof layer.getAnimations !== 'function') {
return null;
}
const entrance = layer.getAnimations().filter(isEntrance);
if (entrance.length === 0) {
return null;
}
return holdInput(layer, {
until: Promise.all(entrance.map(async animation => animation.finished)),
scope: 'viewport',
});
}

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 primary pointer with no touch screen, each held entrance still installs the press hold's 14 window capture listeners for its length, outside any press (inputHold.ts:655-663). spec:AST-094 FR24 has a component under a fine pointer add document listeners only during a press, and its verification row fails an idle one beyond the shared press controller and stray-click swallower. MobileNav gates on TOUCH_QUERY (FR13); the same gate at the two callers would conform.

import {useEffect, type RefObject} from 'react';
import {parentAcrossShadow} from '../utils/flatTree';

/** What a hold covers: the whole viewport, or the layer alone. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both scopes get the same full-viewport shield (lines 321-322), and 'layer' also makes the content inert (lines 334-341), as the header says at line 34. "the layer alone" would lead a caller to pick 'layer' to keep the rest of the screen live; "the viewport shield plus inert content" matches the code.

Comment on lines +752 to +762
useEffect(() => {
const layer = ref.current;
if (!isHeld || layer == null) {
return;
}
// Released by the cleanup alone.
return holdInput(layer, {
until: new Promise<never>(() => {}),
scope,
});
}, [ref, isHeld, scope]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ref.current is read once and the deps do not change when the node behind the ref is replaced or mounts later, so the new node is never held and the detached one stays held (architecture:react-component-runtime INV6), and the Effect names no external system (INV3). Nothing calls this hook at this head; dropping it until an adopter needs it, or following the node per commit as gestures/useAttachment.ts does, would cover it.

'@astryxdesign/core': patch
---

[chore] Internal: one input hold for every layer (`Layer/inputHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act, and a pinch still zooms the page); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates, and `isInputShield` lets a hit test see past a hold's shield. Not exported from the package entry point; Dialog's and Drawer's entrance hold uses it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two statements no longer match the code: a moving mouse passes a presentation's shield (inputHold.ts:488-501), so not every press or click "reaches nothing", and the gesture host asks holdsTouch (GestureHost.ts:1621) rather than holding through touch gates (inputHold.test.tsx:211 asserts nothing is registered). MobileNav uses the hold too.

This branch was successfully deployed

1 active deployment
Preview — f41ae1e7 Deployed Oct 31, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants