Skip to content

feat(lab): PageControl takes every pointer (spec:AST-094 FR43) - #8705

Draft
vjeux wants to merge 1846 commits into
meta:mainfrom
vjeux:uikit/c23-fr43-page-control
Draft

vjeux wants to merge 1846 commits into
meta:mainfrom
vjeux:uikit/c23-fr43-page-control

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

On #8174 and #8665, with X1's harness helper. Review only this PR's change: vjeux/astryx@7013f89...4d93749

User need

On the web a page control should work with whatever the person holds (spec:AST-094 FR43). With a finger, a tap to either side of the current dot steps one page and a slide across the dots scrubs to the page under it. With a mouse, only a click works today, and a drag pages nothing.

Problem and solution fit

  • The tap, the scrub and the hold attach for every pointer. A mouse tap steps one page toward its side of the current dot, as drawn. A drag scrubs to the page whose dot is nearest the pointer on the full row, with the dot centres read once when the scrub begins; nothing jogs past the row's ends.
  • The touch presentation stays on a coarse pointer: no platter, dimmed dots, window slide or fades under a mouse. The Non-goals of spec(AST-094, AST-057): gestures work on the web for every pointer (FR43) #8646 keep pointer presentations, and FR43 extends only gestures and press tracking to every pointer.
  • The click path is removed: the tap recognizer reads the mouse, so the click after it pages nothing more.

Current specification

spec:AST-094 FR43 and its Non-goals, current since #8646.

Public delta and caller burden

With a mouse, a drag across the dots now scrubs; it paged nothing before. A click still steps one page. No API change; Lab and experimental, so no changeset.

Design and alternatives

  • A mouse scrub maps onto the full row as drawn, not onto the touch window's slots, because the row is what a mouse sees.
  • Left for the owner: a mouse's slop. The merged FR43 is silent on it, and AST-094 lists no open question for it. It keeps the touch thresholds: 10 px to decide, 16 px to scrub.

Evidence

Browser proofs with a real mouse (a desktop context, no touch emulation), in Chromium and WebKit:

  • a click on either side of the current dot steps one page, once;
  • a drag from the first dot to the last scrubs there, then back to the middle one;
  • a press held past 155 ms shows no platter and dims nothing.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

  • Red first (5a51ac0): a mouse tap through the tap recognizer and a mouse scrub failed on the touch-only control.
  • pnpm exec vitest run packages/lab/src/PageControl packages/core/src/motion packages/core/src/gestures packages/core/src/hooks packages/core/src/internal: 1,677 passed. New: a mouse tap through the tap recognizer, a mouse scrub, a tap that pages once, a mouse hold with no platter.
  • pnpm test:interaction packages/lab/src/PageControl: Chromium 50 passed (twice); WebKit 72 passed, 3 skipped, three runs. The full interaction config: 181 passed, 1 skipped.
  • Root build and typecheck, CI=true pnpm lint, the internal-entry and touch-bundle tests, changeset checks, check:repo, the sandbox build, and the registry contract (1,036 items, 1,040 routes): clean.
  • Size: 5 files, 277 lines added, 86 removed.

Stack: #8164, #8174, then this PR, with #8665 and X1's harness helper as dependencies.

vjeux and others added 30 commits October 26, 2026 20:42
Here the click guard (utils/clickGuard.ts) owns clicks, so cixzhang's
meta#8589 syncClaimingListeners, ported at meta#8356, switches only the touchend's
cancelability and the contextmenu subscription; the group press subscribes
no click of its own. Her meta#8072 5c59af1 (a finger on an action group
under a fine pointer reaches its recognizers) arms the click guard where it
armed the swallower. Her watcher test reads the touchend and the
contextmenu on the window, and no click listener: the guard's comes only
once a gesture acts.
… the lift, under real touch input

spec:AST-094 FR20's "follows the scroll to the lift" had only a jsdom
test with a synthetic takeover; the FR18-FR22 verification row asks for
real touch input. In Chromium with injected touches, a vertical drag on a
list a watcher pan is attached to scrolls the list natively, no touchmove
is cancelled, and the watcher goes from began to ended at the lift.
… at the edge stays the page's

spec:AST-094 FR10: two fingers before a claim are the page's pinch. The
scroll-edge hold (DEC-18) counted any finger, so with two fingers down it
cancelled the pinch's moves before any drag was claimed. It now holds only
while a single finger is down. The new two-finger test fails on the
previous head.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…s watcher browser test) into the arena fixes
…dge pan holds a lone finger only; meta#8589's watcher browser test) into meta#8293
… is down off the sheet (spec:AST-094 FR10)

The lone-finger hold counts only the fingers the pan tracks, so a second
finger on plain page content left the sheet's finger held, and the moves
of a pinch across the sheet's edge were cancelled. This test fails until
the host counts every finger on the screen.
…is down anywhere (spec:AST-094 FR10)

The lone-finger hold counted only the fingers the pan tracks, so a second
finger on plain page content left the sheet's finger held, and a pinch
across the sheet's edge had its moves cancelled. The host now applies a
recognizer's hold only while the move's event reports one finger on the
screen; the pan's own count still covers two fingers on the sheet.
An attach reads the element's own will-change in a task of its own. A
shared animator (animatorFor) is never detached, so its node can leave the
document with that read still waiting, and the read then forces a style
pass on a node with no style: a test run that mounts and unmounts many
pagers piled up such reads until a later wait timed out. The read now
skips a node out of the document; a promotion that needs it reads it then,
as one on a node attached in the same task does. A detach and dispose
already cancel it.

The new test fails on the previous code (one read of a removed node); the
in-task read test now puts its element in the document, where the read
happens.
This pull request carried facebook#8561's earlier heads as a dependency. facebook#8561 is
on main now, so the lint rule's files (eslint.config.js, the plugin's
README, index.js, no-core-internal-import.js and its test) are main's,
byte for byte; this pull request's own change (the internal entries, their
tests, the export map, sync-exports and two changesets) is unchanged.
…ter the browser takes the touch

A host that stopped feeding the finger at the takeover and still delivered
the lift would have passed. Each transition now records the list's
scrollTop, and the test expects a 'changed' while the list is scrolled
(spec:AST-094 FR20: it follows the scroll to the lift).
…her browser test) into the arena fixes

The host-owner changeset keeps this PR's own listeners for the scroll lock
and finger-scroll watch, with meta#8336's lone-finger scroll-edge hold.
meta#8076 closes Lab's module-path route to Core (Lab reaches core only
through core's exports), and meta#8562 adds Core's internal entries,
`@astryxdesign/core/internal/touch` and `/internal/motion`
(`architecture:react-component-runtime` INV13). The lab-internal-imports
codemod moved PageControl's four imports of Core module paths onto them.
The two names this branch uses that the entries do not carry yet join them
as named re-exports, each with its line in
scripts/core-internal-entries.test.mjs: `useGestureRecognizer` in
internal/touch, and the `pageControl` table this pull request adds in
internal/motion. The table joins the entry's existing named re-exports
from the `motion/uikit` barrel: `motion/uikit/tableImports.test.ts` lets
a module outside a component reach a component's table only that way.

scripts/lab-core-imports.test.mjs passes: no Lab file imports a Core path
that Core's exports do not list.
…ernal entries

The lab-internal-imports codemod moved the seven imports of Core module
paths in this pull request's PageControl files onto
`@astryxdesign/core/internal/touch` and `/internal/motion` (meta#8562,
`architecture:react-component-runtime` INV13), and added named
re-exports of the two names this pull request uses that the entries do
not carry yet, `animatorFor` and `reducedMotionFade`, with
`motion/reducedMotion`'s line in scripts/core-internal-entries.test.mjs.

scripts/lab-core-imports.test.mjs passes: no Lab file imports a Core path
that Core's exports do not list.
vjeux added 26 commits October 29, 2026 10:16
…core/internal/touch

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

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

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

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

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

meta#8360's action-group lifetime case pins that an element stays a group
while any attachment asks and stops with the last. Here every element
that uses a gesture is served as a group under a fine pointer, so the
same invariant reads as the element's one passive pointerdown: with two
gestures attached, detaching either keeps it, and detaching the last
removes it. It passes on this head.
…esture host's subscriptions

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

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

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

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

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

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

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

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.

@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. This branch amends spec:AST-094 FR41 so that a mouse or pen drag a recognizer claims, such as a PageControl scrub, clears the page's text selection, while FR41 on main says a mouse otherwise keeps its released behavior; an engineering owner can approve or reject that amendment for this head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner), or it can settle in #8665 first. These changes are needed either way:

  • Land the open pull requests this branch is stacked on (at least ten), so the diff against main shrinks from 33,633 authored lines to the five-file PageControl change.
  • On a cropped row of nine or more pages, a mouse scrub selects a page up to four dots behind the pointer, because the row re-centres while the scrub keeps its starting dot positions; holding the row still until the lift, with a drag test on the Many Pages story, would fix and cover it.
  • FR43 says a pan also takes wheel and trackpad scrolling, but a horizontal trackpad or wheel scroll over the control changes no page; that path, and a trackpad and a pen case in the PageControl tests, are still needed.
  • The header of PageControlTouch.ts still says a mouse runs none of it.

[Automated review]

) {
g.isScrubbing = true;
g.geo = geometry();
g.centres = isTouch ? [] : rowCentres();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a cropped mouse row (nine or more pages) these centres go stale after the first page change: PageControl.tsx (lines 327–340) moves the strip to croppedRowOffset(pages, current) on every change, so the drawn row slides under the pointer while this mapping does not. Measured in Chromium on the 50-page story, dragging from page 26's dot: the pointer over the last drawn dot (34) selects page 30, and page 50 needs the pointer 343 px past the control's edge. Holding the row still until the lift keeps these centres true, and a Many Pages drag in the interaction test would cover it.

Comment on lines +9 to +14
* @output usePageControlTouch: PageControl's touch layer, the scrub (and its
* jog past the window's edge), the held platter, the window's slide and
* the fades
* @position Internal to PageControl: its one touch module, imported once. A
* coarse primary pointer turns it on; with a mouse the control pages by tap
* and keys (PageControl.tsx) and nothing here runs

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This header still calls the module the touch layer and says that with a mouse "nothing here runs". After this change these recognizers are a mouse's only pointer input, so @output and @position should match the JSDoc at lines 82–87.

Comment on lines +471 to +475
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
takes no new text selection for that drag only. A claim made after the
drag has moved clears the page's selection; a press claimed at its own
pointerdown, before it moves, keeps it. An unclaimed mouse drag keeps its
released behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This adds a rule FR41 on main does not state: a claimed mouse or pen drag clears the page's selection, where today a mouse "otherwise keeps its released behavior" and the verification row fails any change to mouse selection. A PageControl mouse scrub triggers it. It needs an engineering owner's decision for this head (/approve-spec <full head SHA>), or it can settle in #8665 first.

This branch was successfully deployed

1 active deployment
Preview — 4d93749b Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants