Skip to content

test(lab): a pen's or a mouse's trigger draws RefreshControl's indicator as a finger's does (spec:AST-094 FR43) - #8694

Draft
vjeux wants to merge 2033 commits into
meta:mainfrom
vjeux:uikit/c07-refresh-pen-drawing
Draft

vjeux wants to merge 2033 commits into
meta:mainfrom
vjeux:uikit/c07-refresh-pen-drawing

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8251 and #8688. Review only this PR's change: vjeux/astryx@304fec5...8fae365

Maintainer impact

Part of making Astryx match iOS under a finger (spec:AST-094), where every pointer may now pull to refresh (FR43). #8688 lets a pen or a mouse pull, and #8251 draws iOS's indicator, but no test covers the two together: #8688's tests use the old stand-in drawing, and #8251's pull with a finger. If the drawing went back to touch-only, or a mouse's pull ignored Reduce Motion, both would still pass.

Intended invariant

A pen's or a mouse's pull draws what a finger's does: at the trigger, a copy of the spokes fades out over them, then they step around. Under Reduce Motion the trail applies at once, with no copy (design:reduced-motion DR2, DEC-3).

Change

Two browser tests in RefreshControl.interaction.browser.spec.ts, each run with a pen and with a mouse on a page whose main pointer is fine: the pull draws what a finger's does, and under Reduce Motion it adds no copy.

Options: leave the combination untested, or add the cases to #8688 or #8251, each of which predates the other's change; a follow-up stacked on both keeps each reviewable. WebKit has no pen input, so the pen cases run in Chromium only; the mouse cases run in both.

Open with the owners: which drawing a fine pointer should show, since FR13 gives this one to coarse pointers; these tests pin today's.

Evidence

  • Failure before the change: with the copy limited to touch, both drawing cases fail; with a copy under Reduce Motion, both Reduce Motion cases fail.
  • Success after the change: all four pass.
  • Product/runtime behavior verified unchanged: only the test file changes.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

What I ran and what it showed:

  1. The RefreshControl browser tests in Chromium and WebKit: this PR's four cases pass in Chromium, and its two mouse cases in WebKit too.
  2. Each fault above, built into Storybook: its two cases fail and the other two pass.
  3. Known reds, not from this PR: touch-support-bundle's root and hooks cases, broken by fix(useScrollLock): keep the page where it is while an overlay is open #8098's page hold (test(core): a touch component's import carries its gesture code, and nothing else does #8671 rewrites that test per component); and under box load, one run of feat(lab): RefreshControl, pull to refresh as UIRefreshControl #8175's load-sensitive settle test, which allows no long task.

cixzhang and others added 30 commits October 26, 2026 22:28
CI's lint rejected the three Array<T> types the previous commit added
(@typescript-eslint/array-type); the test now names its record once
(WatchTransition) and reuses it.
…ient at once

The spokes' gradient changes where it is, so under reduced motion it is an
in-place value change: it applies at once on any pointer, with no added
element (design:reduced-motion DR2 and DEC-3, meta#8645). The trigger no longer
cross-fades a copy of the spokes under Reduce Motion; the stepping gradient
shows at once, as a refresh begun without a pull already does. The
cross-fade's keyframes lose their Reduce Motion branch, and with it the
reduced-motion fade import.

A unit test and a Chromium interaction test pin it: under Reduce Motion with a
finger, the trigger adds no copy and nothing cross-fades. Both failed against
the copy's 200 ms fade.
…e arena's reset

The arena resets a recognizer to possible after it ends, as UIKit does,
and the transition listener records that too, so the last transition is
possible: the test now asserts the ended-then-possible tail.
…sign/core/internal/motion

Lab no longer reaches Core's modules by path: Core's internal entries are
the route. The import-rewrite codemod moves refreshPull.ts's Bézier,
rubber-band and refresh-table imports onto @astryxdesign/core/internal/motion.
The entry names the refresh table among its re-exports from the uikit
barrel (a shared module may not import one component's table directly), and
the entry coverage test lists it.
…finger, meta#8589's watcher test, its lint fix; meta#8076 3bfd7c3; the coarse-pointer query in utils/) into meta#8293
…nter query in utils/) into meta#8356

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
#	packages/core/src/gestures/fingerScroll.ts
#	packages/core/src/gestures/pageHold.ts
#	packages/core/src/gestures/selectionLock.ts
#	packages/core/src/utils/pressFeedback.ts
… FR19, FR24)

Under a fine primary pointer: each element that uses a gesture keeps one
passive pointerdown listener and the page none while idle; a mouse or a
pen pans, its page listeners lasting only the press; a press consumer
takes a mouse press with no feedback-only mode (DEC-21); a pan writes its
touch-action for a touchscreen laptop's finger (FR18); perEventPointer is
taken as any attach, with no warning; a secondary button starts nothing.
All but the last fail until the host attaches for every pointer.
…design/core/internal/motion

Lab no longer reaches Core's modules by path: the import-rewrite codemod
moves RefreshControl.tsx's refresh-table import onto Core's internal motion
entry, which re-exports it by name.
…h the internal entries

Lab no longer reaches Core's modules by path. The import-rewrite codemod
moves the hook's gesture, motion, scroll-style and touch-presentation imports
onto @astryxdesign/core/internal/touch and internal/motion, and adds by name
what those entries did not re-export yet (useGestureRecognizer, the page's
scroll-style owner, resolveMotion, useAnimator), with their lines in the entry
coverage test. The gestures barrel's re-export of the scroll-style owner,
which only served Lab's old path, goes, and its changeset now names the
internal entries.
… the scroll brake for a finger only (spec:AST-094 FR11, DEC-21)

A mouse or a pen press paints at once, inside a scroller too; a mouse
press on a scroller a flick set moving still presses, as the brake is a
finger's. The brake case fails until the tracker brakes only a touch.
… (spec:AST-094 FR43)

A drag from the edge band begins a screen-edge pan for a finger only,
unless the caller allows another pointer. The mouse and pen case fails
until the recognizer defaults to touches.
…ong tasks only where the engine reports them

The settle is a Web Animation made in the lift's own task: the browser
starts it at the next frame the main thread commits, before a busy task that
follows (the compositor then plays it through the block) or after it. The
proof read where the content sat in the first frame after the block, which
told the two apart only because that frame usually carries the time it was
due; on a loaded machine it carries the current time and shows the
compositor's real progress, so the proof failed with nothing wrong on screen
(reproduced every time by dropping frames stamped before the block's end).
It now reads the animation's start against the moment the main thread first
saw it, which a start dated back by a busy task fails.

WebKit has no Long Tasks API, and the harness's log now refuses to read
where it never ran: the page proof checks long tasks in Chromium only.
… FR19, FR24, DEC-21)

Recognizers and press consumers attach for a mouse, a pen and a finger
alike. Where the primary pointer is fine, each element with an enabled
recognizer or consumer keeps one passive pointerdown listener and the page
has none: a press on it (a mouse's primary button, a pen, or a finger)
installs the host's page listeners until that press's contacts end. The
action group's own fine-pointer path folds into this one, so a group is
served like every attachment.

- touch-action is written for every pointer, a kept press's manipulation
  too (FR18); the element's blocking touchmove stays a coarse pointer's,
  so under a fine one the element keeps its one listener (FR24).
- A press consumer takes a mouse press as a press (DEC-21): no
  feedback-only mode; PressTracker brakes only a finger's press (FR11).
- The screen-edge pan allows touches only unless its caller allows another
  pointer (FR43).
- perEventPointer is accepted and does nothing; the fine-pointer warning
  goes.

Tests that encoded the coarse-only design now state FR43's; a finger's
pointerdown comes before its touchstart in them, as in a browser.
…a#8076 3bfd7c3

# Conflicts:
#	packages/lab/src/RefreshControl/refreshTouchController.ts
…ts internal entry

The drawing's tests imported the refresh table, the timing table, the spring
solver and the settle threshold by Core module path; they now import them from
`@astryxdesign/core/internal/motion`, which re-exports each by name, so no Lab
file imports a Core path that Core doesn't export.
…th real mouse drags (spec:AST-094 FR43, FR24)

Under a desktop's mouse the host still writes touch-action, leaves the
page no listener while idle and warns for nothing; a real mouse drag pans
both the bare page's pan and the fixture's horizontal pan, and the page's
touch listeners are gone again after the release.
…(spec:AST-094 FR18, DEC-13)

A screen-edge pan attached to a stack container, as wide as the screen,
with a touch 4 px from its edge: inside the 40.2 px band the pan decides at
UIKit's 15 px whatever the element's width, and then tracks the travel past
it. From several edges too. Both fail while a wider element decides at
FR19's 10 px.
…'s width (spec:AST-094 FR18, DEC-13)

FR18: inside the 40.2 px band a screen-edge pan decides at UIKit's 15 px
screen-edge hysteresis; outside it the author's scrolling is untouched and
FR19's 10 px governs every other claim. The pan decided at 10 px on any
element wider than the band, so NavigationStack's edge pan, attached to the
stack container, began 5 px early. The edge zone still decides only the
touch-action; a touch outside every band still fails at once. The two
cases that pinned the 10 px decision go.
… 15 px whatever the element's width) into the experimental touch exports
vjeux added 24 commits October 29, 2026 10:24
…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.
…6c02fc, the harness README, spec and index.ts as in 801dfa6750 (meta#8666 on meta#8360)
jsdom serves no cross-origin sheet, so the catch around the indicator tests' stylesheet reader could only hide a real error.
…nto the pen and mouse pull

The harness README and browser spec take the helper's verified versions.
…eencast delivered

A loaded machine's screencast skips frames the compositor still presents, so the proof no longer counts delivered frames against the screencast's cadence. As in the harness's busyWindowProof, every delivered frame must show the fade progressing, and a gap between two changed frames is measured between the compositor's own frames, recorded with startCompositorTrace. A fade driven from the main thread still fails it.
…he page presented

Two proofs failed on a loaded machine with no defect in the control:
- the busy-main-thread settle counted the screencast's delivered frames
  against its cadence. It now hands the compositor's own frame record to
  busyWindowProof, whose verdict alone judges the settle; a settle paused
  before the block fails it.
- the catch read the settle's position before the touch, which lands later
  under load. A capture listener now records where the settle stood as the
  finger landed; a catch that restarts from zero travel fails it.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39

@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 engineering-owner decisions: whether a pen's or a mouse's pull on a page whose primary pointer is fine should draw RefreshControl's finger indicator (the fading copy of the spokes, with no copy under Reduce Motion), which spec:AST-094 FR43 does not settle, and whether FR41 should gain the rule, carried here from #8665, that a mouse or pen drag a gesture claims takes no new text selection. An engineering owner records each decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or by landing a spec on main that states it. Separately, against main this branch carries about 38,600 changed lines from #8251, #8688 and other open pull requests, including scroll-lock code that fails the root and hooks cases of scripts/touch-support-bundle.test.mjs, so once those land it needs a rebase that leaves only the 169-line test change.

[Automated review]

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 claimed-drag rule is new normative text that main's FR41 does not state (main ends at a mouse "otherwise keeps its released behavior"). It arrives from #8665 and needs an engineering owner's approval, through #8665 landing on main or /approve-spec <full head SHA> here.


import {useEffect} from 'react';
import {claimRootScrollStyle, reportScrollLock} from './rootScrollStyles';
import {holdPageScroll} from '../gestures/pageHold';

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 static import puts gestures/pageHold.ts and gestures/hostRegistry.ts into the bundles of Core's root and hooks entries, so the root and hooks cases of scripts/touch-support-bundle.test.mjs fail here while they pass on the base. The fix belongs in #8098; for this pull request it leaves with the rebase.

This branch was successfully deployed

1 active deployment
Preview — 8fae365c 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