Skip to content

feat(NumberInput): one press tracker serves every pointer on the steppers (spec:AST-094 FR43) - #8680

Draft
vjeux wants to merge 2001 commits into
meta:mainfrom
vjeux:uikit/c04-stepper-every-pointer
Draft

vjeux wants to merge 2001 commits into
meta:mainfrom
vjeux:uikit/c04-stepper-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8218, #8665 and #8666 (the pen driver). Review only this PR's change: vjeux/astryx@2d1f7b4...d217b02

User need

What led to it: in Astryx's touch-presentation work (spec:AST-094), the owner decided that gestures work for every pointer, not only a finger (FR43, merged in #8646). The steppers from #8205 and #8218 still set up their press handling only on phones: a mouse took a second code path, and on a touchscreen laptop a finger got the browser's defaults, so quick taps could zoom the page and a long press selected text.

Design and alternatives

  • Keep the mouse path beside the touch one: the gesture host now serves every pointer, so a mouse press would be handled twice.
  • Chosen: one press handler for every pointer. On a touchscreen laptop, quick taps no longer zoom and a held finger or pen selects no text. A mouse or pen press shows its pressed half at once. A right click, a pen's barrel button or a macOS ctrl-click presses nothing.

Tradeoffs: a held mouse keeps stepping within 70 px of the stepper, as a finger does, instead of pausing as soon as it leaves; a click more than 400 ms after a release counts as a new press (#8218 allowed 1000 ms).

Current specification

spec:AST-094 FR43, FR41, FR19, FR24, FR18 and FR11 with DEC-21; component:NumberInput unchanged. Public-API gaps (spec:AST-107 FR7): press timing, the selection lock, the host's clock, UIKit's tables.

Public delta and caller burden

No API change.

Testing

Open Storybook's NumberInput "With number steppers" story:

  1. On a touchscreen laptop, tap + quickly several times. It steps each time; the page doesn't zoom.
  2. Hold + with a finger or a pen. It repeats; no text is selected.
  3. Hold + with a mouse. It repeats and settles when you release.
  4. Right-click +. Nothing steps.

Phones behave as on #8218. Unit tests cover each pointer, and a Chromium test presses with a real pen; the new ones fail on the previous code. Two node checks fail through this stack's bases, not this change: a bundle check that #8671 replaces, and a List row check that #8085 fixes.

Evidence

Recorded on the iOS 26.2 simulator and the Android emulator.

Size (the PR size report's measure, gzip): NumberInput 78.1 → 77.7 KB, CSS unchanged.

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.

vjeux added 30 commits October 27, 2026 00:37
…an the edge band decides at 10 px again, DEC-22) into the experimental touch exports
…ouch decides at 15 px on an element within the band and at 10 px beyond it, DEC-22; meta#8641 bec2283) into meta#8270
…8641's moving-touch claim kept on this line's internals delivery) into meta#8315

# Conflicts:
#	packages/core/src/gestures/GestureArena.ts
The case walked ContextMenu, DropdownMenu, useLongPress and useMenuPress's
imports and failed on the UIKit gestures table: it kept that table out of
the menus' bundles, the lazy design the no-lazy-load ruling and direct
imports retire. The long-press numbers keep one source, still pinned by the
table, recognizer and hooks case and by the hooks reaching longPress.ts.
…8589's watcher test reads its end; the gestures-table gate retired) into meta#8360
…8589's watcher test reads its end; the gestures-table gate retired) into the every-pointer host
… fix and the coarse-pointer query's move to utils/)

The coarse-pointer query moved from gestures/coarsePointer.ts to
utils/coarsePointer.ts in F2's line, so NumberInputTouch.tsx imports it
from there.
It adds meta#8336 03155c3 to the previous helper base; merging it changes no
file, so a compare from it shows exactly this PR's change.
…xposes no ontouchstart is taken once

Chromium fixes ontouchstart when the page is created, so turning touch
emulation on later sends Touch Events without it: the host reads Pointer
Events for the finger and must not read its Touch Events too. A one-finger
long press fails here, the finger tracked twice.
…from their pointer events

install() reads a finger from its pointer events when the window has no
ontouchstart, but the four touch handlers still took the Touch Events a
browser sends anyway (Chromium after touch emulation turns on), tracking
the finger twice, so a one-finger long press never recognized. Each touch
handler now returns while usesTouchEvents is false, after noting the
fingers down, as install()'s comment promised.
…n before the click guard

internal/motion.ts (meta#8562) re-exports UIKIT_CLICK from the barrel, so a
tree carrying both fails typecheck and the Storybook build since this
pull request dropped it. The barrel keeps it, with the click guard's
window, and the table of every gesture group still leaves it out. Fails
until the barrel exports it.
…'s window

This pull request dropped UIKIT_CLICK when the click guard took the
stray-click window over, but importers written before it read the barrel's
UIKIT_CLICK (internal/motion.ts in meta#8562 re-exports it), so a tree carrying
both failed typecheck and the Storybook build. UIKIT_CLICK stays in the
barrel, deprecated, with the guard's 400 ms (CLICK_GUARD_WINDOW_MS), and
UIKIT_GESTURES still leaves it out.
…ter proofs

spec:AST-094 FR43 has gestures attach for every pointer, so each FR43 proof needs a pen and a wheel as real engine input, not only a finger. webInputDriver(page) adds wheel({x, y, dx, dy, ctrl}) and pen(points): in Chromium through CDP Input.dispatchMouseEvent, with a wheel step's deltas scaled back so the page receives exactly its CSS px (Chromium divides them by the device scale factor times the visual viewport's scale: a raw 120 px step reached a phone page as 40), and a pen stroke with pointerType pen, pressure, tilt and scripted timestamps; in WebKit through page.mouse.wheel in a desktop context. WebKit's phone emulation has no wheel and Playwright has no pen for WebKit: the driver's wheels and pens flags say so, with WHEEL_SKIP and PEN_SKIP. The input log also records wheel steps and pointer pressure.
vjeux added 26 commits October 29, 2026 10:32
… attached, in this host's terms

meta#8360's action-group lifetime case pins that an element stays a group
while any attachment asks and stops with the last. Here every element
that uses a gesture is served as a group under a fine pointer, so the
same invariant reads as the element's one passive pointerdown: with two
gestures attached, detaching either keeps it, and detaching the last
removes it. It passes on this head.
meta#8360 carries this pull request's predecessors and the review fixes of
the foundation it holds; this pull request's own diff is now its six
commits (a second finger fails a one-finger pan wherever fingers come
from, and the check reruns once that finger is delivered; a press's
selection lock gets what it landed on; a pinch's second finger on another
pan claims neither; the wider-band edge pan's real-touch proof; text).
Two conflicts:
- GestureHost.ts: the selection lock's target takes meta#8360's presented
  target, which is what the press landed on unless an element under it
  is moving, so it keeps this pull request's fix and meta#8293's correction.
- .changeset/gesture-press-tracking.md: meta#8360's text, which names both
  writers of the press attribute.
ListItem declares --_hit-border-bottom (the divider's width, 0 on the
last row) for the touch hit layer, and List.doc.mjs did not list it, so
derivedVarRegistry.test.ts failed: "List has undocumented CSS vars in
source: --_hit-border-bottom". It is now in List.doc.mjs theming.vars[]
as a private var, and in VARS_WITHOUT_DERIVED_MAPPING with its reason:
it is the ::after layer's geometry, which follows --border-width, not a
property a theme author sets (as with --_thumbnail-hit-inset and
--_input-clear-hit-inset).

Red first: "List: all source vars are in doc file" failed on the change
before this one; it passes now (122 of 122).
…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.
… name their measured causes

The hold proofs that need a tap's click skip in Playwright's WebKit with a
reason that blamed this story. Measured against a plain button: that
WebKit synthesizes no click after a scripted touch on any element, while
its own tap path clicks; these proofs script the touch, so the reason now
says that.

The steppers cancel a touch's pointerdown, not only its mousedown.
Canceling the mousedown alone was tried: iOS Safari, Chromium and
WebKit's tap path then all step on the click, but Chrome on Android
focuses a held stepper at its long press (focusin about 500 ms in,
measured on the emulator), which would blur an editing field. So the
cancel stays, and the comments name why; WebKit's Linux build, outside
spec:AST-013's support, sends no click after a tap there.
…cause

Its reason blamed this story. Measured: WebKit's Linux build clicks a
plain button after a tap, but sends no click once the touch's pointerdown
is canceled, unlike iOS Safari and Chrome. The steppers cancel it because
Chrome on Android otherwise focuses a held stepper at its long press
(measured on the emulator), so the proof runs in Chromium and on the iOS
simulator; that engine is outside spec:AST-013's support. The comment
that documents open() moves back above it.
…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.
This pull request now sits on meta#8665, so a stack carrying it and meta#8665's
other dependants (meta#8676) needs no second GestureHost resolution. Two
hunks in GestureHost.ts's pointerdown, both this pull request's:
- the second-finger check where fingers come from pointer events stays,
  followed by meta#8665's rule that a right click, a pen's barrel button or
  a macOS ctrl-click starts no press;
- the check that runs again once the new finger is delivered stays,
  followed by meta#8665's selection lock call, which no longer takes the
  window.
…6c02fc, the harness README, spec and index.ts as in 801dfa6750 (meta#8666 on meta#8360)
…eta#8666 08a2a28) into the web-input drivers helper

The harness files are 8d35612's verified union, byte for byte, plus meta#8535's change to the backward self-test, which this line carries; the two helpers hold the same content apart from that change.

This line's own harness additions leave it, as no open pull request builds on them: the earlier busy-window control and pooled proof (now meta#8906), openStory waiting for Storybook to finish a render, the touch driver's synthesizesClicks and SCRIPTED_CLICK_SKIP, and the wheel-at-DPR-3 self-test.
utils/pressFeedback.ts is taken byte for byte from git's clean merge of
those two heads; nothing in this pull request changes it.
…events, as a browser sends them

The gesture host now reads a finger from its pointer events once it has
seen the pointerdown (meta#8336, through meta#8360). This suite's touch helper
sent a move or a lift as a touch event alone, which no browser does, so
the two slide tests lost the finger and the touched half's paint stayed
on. The helper now sends pointermove or pointerup first, as a browser
does; the browser proofs, which use real input, pass unchanged.
…as the repeat arms it

Under a loaded machine one WebKit run of "a slide onto the other half
carries the paint and the repeat" failed with its third step 68 ms off
the schedule. The schedule was timed from touch-down, but the hold starts
from the press's delivery and the repeat arms each beat from the moment
its step ran (stepperTracking.ts). So a slow touch dispatch, or one late
beat, moved every later step, and the proof charged it to each of them.
Instrumented over repeated runs at load 83 to 105, WebKit's first step
sat up to 38 ms after touch-down plus 500 ms, while every later interval
stayed within 17 ms of its beat.

Each step is now checked against its own beat: the first 500 ms after the
press's first paint, which is written when the hold is armed, and each
later one its interval after the step before. The leave-and-return
proof's resumed beat is timed from the second step the same way. The
slack per step is unchanged.
…2a28)

internal/interaction-harness/README.md is taken byte for byte from that
helper's merge; meta#8665 and meta#8336 leave it as meta#8360 has it.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
… armed-press touchmove (spec:AST-094 FR24)

Where the primary pointer is fine, the gesture host now registers a kept
press's blocking touchmove only once a press is armed, since that
press's pointerdown comes before any touch it brings (meta#8665). The proof
expected it on the idle column. It now expects the passive pointerdown
alone while idle, and the blocking touchmove on the column once a press
is held.

@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 review is on hold for a spec decision: the every-pointer stepper press matches spec:AST-094 FR43, but this diff also rewrites component:NumberInput FR4 (the hold-repeat, with a pause-on-leave rule the code does not follow) and FR5 (the coarse-pointer capsule and focus rule) and adds claimed-drag text-selection sentences to spec:AST-094 FR41, none of them approved for this head. An engineering owner needs to approve or amend those requirements and record it by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these need changes:

  • The diff against main carries the whole stack (40,009 authored lines); land #8218, #8665 and #8666 first, or retarget this PR onto them, so it shows its own seven-file change.
  • A mouse holding + that drifts below the stacked steppers keeps stepping as − and commits the lower value, because a held press tracks 70 px past the column and takes the nearer half (packages/core/src/NumberInput/NumberInputTouch.tsx, around line 286).
  • The coarse-pointer capsule widens the steppers to 94 px with 47 × 28-36 px halves and no hit area, which spec:AST-094 FR42 rules out for the default look.

[Automated review]

event === 'touchDragInside' ||
event === 'touchDragEnter' ||
event === 'touchUpInside';
const half: StepperHalf | null = isInside ? halfUnder(touch) : 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.

Inside the press tracker's 70 px tracking area this takes the half whose centre is nearer, so in the stacked column a point below the column is on −. In Chromium on "With Number Steppers", a mouse holding + reached 4, then drifted 29 px below the column: the − half lit, the value counted down and the release committed 1. Head FR4 says moving off the stepper pauses the repeat. Stepping only while the pointer is over a half (and pausing outside both), or keeping the last half outside the column, would fix it; a test that holds + and moves 29 px below the column would catch it.

This branch was successfully deployed

1 active deployment
Preview — d217b028 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 needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants