Skip to content

feat(core): a pan follows a trackpad where its owner allows it, never a mouse wheel; a pinch takes a ctrl-wheel (spec:AST-094 FR43) - #8676

Merged
cixzhang merged 14 commits into
meta:mainfrom
vjeux:uikit/f2-wheel
Nov 2, 2026
Merged

cixzhang merged 14 commits into
meta:mainfrom
vjeux:uikit/f2-wheel

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

On main: fourteen commits (the feature's four, then ten from review and the owners' rulings).

This behavior-module pull request adds the gesture host's wheel and trackpad module and the recognizer options that opt in; no component or entry point changes. Of 3,076 changed lines, 1,031 are source and 1,965 proofs.

Why

A trackpad swipe over an Astryx pan, such as a sheet, never reaches it: the page scrolls. The merged record: a pan follows a trackpad's two-finger scroll where its record allows it, never a mouse wheel's notches (spec:AST-094 FR43, DEC-24).

Records checked: spec:AST-094 FR43, DEC-24, FR16, FR19, FR20, FR24; architecture:react-component-runtime INV13; architecture:public-component-api INV25; architecture:knowledge-contracts INV29. The record gains one FR43 sentence, in the owners' words, and its Verification case.

Why this choice

One host decides each gesture by the pan's own direction rule, after every scroller under the pointer reaches its edge, and latches it through the shared input stream. Only the trackpad signature of Chromium and WebKit counts. One splitter reads a pan's stream (DEC-24): a 180 or 220 ms pause ends it, the fourth shrinking delta marks the lift, and momentum never moves a surface. A ctrl-wheel pinch ends 100 ms after its last wheel. A blur or a hidden page cancels a trackpad pan; the rest of that stream is the page's.

API changes

Internal only: ScrollType, allowedScrollTypes (default none; a pan takes only 'continuous'), pinch in RecognizerFamily, createNativeScrollProbe's {readsTouchAction}, and the internal canElementScroll and RecognizerInternals.classify.

What the review fixes change

Each fix is a commit with cases that fail before it:

  1. Momentum after a lift stays the pan's, prevented, until the stream ends.
  2. A stream a scroller or the page keeps ends on a pan's pause.
  3. A ctrl-wheel pinch stays one pinch until 100 ms after its last wheel.
  4. A pan detached mid-swipe releases at its last wheel.
  5. A latched wheel an app's window-capture listener stops stays the pan's.
  6. A scroller 0.5–1 px short of its end keeps the stream, as for a finger.
  7. A blur or a hidden page mid-swipe cancels a trackpad pan, never a pinch.
  8. A non-default axisRatio reads a trackpad's direction as a finger's.

A pan's own swipe keeps its release velocity.

Evidence

  • Unit, red first: each fix; notches never drive a pan; Shift+wheel scrolls what is under it.
  • WebKit, real trackpad wheels: no stall at the lift; no wheel of a fling reaches the page.
  • Chromium: its driver sends notches; the trackpad-only cases skip, named.

Testing

Local gates at 5a38c0c, on main 1667ea8 (14 commits; 20 files):

  • tsc, strict ESLint (with no-comment-provenance), Prettier, the private-attribute baseline and check:* (check:i18n-runtime included): clean. The provenance baseline check still reports drift in four files this change does not touch (ChatComposer.tsx, Item.tsx, layerSurfacePress.ts, Token.tsx), as it does on main.
  • The gesture, utility and public StyleX namespace unit tests: 81 files, 1,400 tests passed. The record-reading tests: 5 files, 301 tests passed.
  • The gesture and presented hit-testing browser proofs, at e8c3a28, which differs from this head only in the blur's stream and pinch rules and their unit cases: Chromium 61 passed and 4 skipped (the trackpad-only cases). In the WebKit CI job's profile (4 CPUs, CI=1, LP_NUM_THREADS=4, one worker), ten runs: 550 passed and 100 skipped (the Chromium-only cases), none failed.
  • Carried from cb17088108, the same feature on main 1f6919c; this head adds the momentum-latch cases, the comment, doc and baseline edits, the blur ruling and the direction rule:
    • pnpm test:ui: 720 files, 17,880 tests; node: 845 files. The reds were main's own: motion/roles.test.ts (spec(ContextMenu): touch-opened menus lift with a tint, grow from the finger, and use named springs #9423, fixed by chore(core): the touch motion tables register FR4's context-menu springs as roles #9432) and fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520's two release/cache.test.mjs cases.

    • Ten runs each: WebKit at 3 CPUs (LP_NUM_THREADS=3) and one worker, 550 passed; Chromium pinned to 2 cores, 610 passed at four workers and 790 (with the animator's busy-main-thread proofs) at one.

    • At 16 workers on 3 CPUs (LP_NUM_THREADS=3; not a required profile), 36 of 550 runs fail, all in four trackpad cases: momentum after the lift 10, absorbing the stretch 10, a real trackpad scroll 9, the latch 7. Run side by side at that profile, those cases failed 29 of 40 times on 9f9dc80 and 26 of 40 on this code, so the fixes do not change the rate.

    • The cause, measured on 9f9dc80: the runner's wheel steps reach the page further apart than the end window in force, and the splitter ends the gesture there, as recorded. A run that logged when each step reached the page found such a gap in every failing run and in no passing run:

      Case Window Failed runs' largest gap Passed runs' largest gap
      The latch 220 ms 271–296 ms (4) 114–198 ms (6)
      Absorbing the stretch 220 ms 241–273 ms (3) 156–204 ms (7)
      A real trackpad scroll 220 ms 298–425 ms (6) 4 passed; their largest gaps are the test's own waits between phases
      Momentum after the lift 50 ms 162–278 ms (10) none passed

      Sending each stream as one burst on a frame's schedule only renamed it: WebKit coalesced the steps the starved page was still handling.

    • Accessibility, both suites whole: WebKit, in CI's container profile, 138 passed and 10 skipped; Chromium 2,278 passed, and one TableCellMenus long-press case failed, which passed 20 of 20 side by side on main and on this code. Core's and Lab's entries bundle none of this change's files.

Writing rubric results

R1–R6: none. The FR43 sentence is in the present tense, the Verification row names cases, and the owners ruled the question.

@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 2, 2026 9:07pm UTC

Request Review

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

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 9:19 PM UTC

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…p (left: the base, meta#8386 with meta#8676; right: the sheet's wheel pull), Chromium desktop
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…the web input drivers) into the base of the every-pointer follow-up

# Conflicts:
#	packages/core/src/motion/README.md
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…allows; the wheel latch; FR43) into uikit/c25-toast-every-pointer
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…ft: the base; right: this change), Chromium desktop

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold for an owner decision: spec:AST-094 on main has no FR43, so neither the wheel and ctrl-wheel input nor the every-pointer gesture host it relies on has a governing spec yet (today's FR19 keeps recognizers off fine pointers). An engineering owner needs to decide whether this waits until #8646 lands FR43 with its wheel questions answered, or to accept now the readings it takes: a pan opts in through allowedScrollTypes, every pinch takes a ctrl-wheel at exp(-deltaY / 100), and a wheel gesture ends 100 ms after its last wheel. They record that decision by commenting /approve-spec followed by this pull request's full head SHA, and the author may do so if they are an engineering owner. Whatever that decision, these changes are needed:

  • Reduce the diff to this change: about 28,700 of its 30,372 authored lines are the open #8665 stack (land it first or retarget onto it), and the astryxStylex fix belongs in its own pull request.
  • Make scrollsBy in GestureHost.ts RTL-aware: it compares Math.abs(scrollLeft) against LTR bounds, so in RTL an opted-in pan takes the wheels a horizontal scroller should get.
  • Resolve the wheel's target with landedOn so that scrollers inside a shadow tree count, and update useLongPress.doc.mjs, which still documents the per-axis rule.

[Automated review]

Comment on lines +453 to +456
const position = Math.abs(
isHorizontal ? element.scrollLeft : element.scrollTop,
);
return delta > 0 ? position < room - 1 : position > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In an RTL scroller, scrollLeft runs from 0 at the start edge down to -room, so comparing Math.abs(scrollLeft) against LTR bounds inverts both edges. At the start edge, a wheel to the right (deltaX > 0) reads as scrollable, so the pan never takes it. A wheel into the content (deltaX < 0) reads as blocked, so an opted-in pan takes and prevents it, and the list never scrolls. canMoveX in nativeScroll.ts already handles this with entry.isRtl, and isRtlElement is imported at the top of this file:

const value = isHorizontal ? element.scrollLeft : element.scrollTop;
const [min, max] =
  isHorizontal && isRtlElement(element as HTMLElement) ? [-room, 0] : [0, room];
return delta > 0 ? value < max - 1 : value > min;

An RTL case beside the scroller test in wheel.test.ts would cover it.

const isHorizontal = Math.abs(dx) > Math.abs(dy);
// Every scroller under the pointer, the page included, as a finger's
// probe reads them, without touch-action, which a wheel ignores.
const scroll = createNativeScrollProbe(wheel.target, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seen from a listener outside an open shadow tree, wheel.target is the shadow host. So this probe, the walk below and scrollerTakes never see a scroller inside that tree, and an opted-in pan around a web component with its own scroller takes the wheels meant for it. The pointer and touch paths already resolve this with landedOn(event); starting beginWheel from landedOn(wheel) would match them.

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
) {
// A distance, as UIKit measures `allowableMovement`: a (7, 7) drift
// keeps the press, a (7.2, 7.2) one ends it.
if (Math.hypot(point.x - from.x, point.y - from.y) > moveCancelPx) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This makes moveCancelPx a distance, but the hook's public docs in useLongPress.doc.mjs (lines 48 and 97) still say "Movement past this distance on either axis cancels the press". Could they be updated to the distance rule so the docs match the hook?

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 5a38c0c

Storybook · Sandbox · built from this PR's head

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

API changes: none in this pull request

Committed API report behind this build: cli 2 rows, core 23 rows; run pnpm api:report.

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


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold for an owner decision: spec:AST-094 on main has no FR43, so the wheel and ctrl-wheel input, and the every-pointer gesture host it relies on, have no governing spec yet (today's FR19 keeps recognizers off fine pointers). An engineering owner needs to decide whether this waits until #8646 lands FR43 with its wheel questions answered, or to accept now the readings it takes: a pan opts in through allowedScrollTypes, every pinch takes a ctrl-wheel at exp(-deltaY / 100), and a wheel gesture ends 100 ms after its last wheel while following macOS momentum; they record that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever that decision, these changes are needed:

  • Reduce the diff to this change: about 29,000 of its 30,737 authored lines are the open #8665 stack (land it first or retarget onto it), and the astryxStylex fix belongs in its own pull request.
  • Mirror the edge tolerance in scrollsBy (GestureHost.ts): at the end of an RTL list, where Chromium stops a pixel short of scrollWidth - clientWidth, it reads the list as still scrollable, so an opted-in pan never gets the wheel there; an RTL end-edge test would cover it.
  • Reconcile .changeset/gesture-host-owner.md and the host's header comment with the every-pointer behaviour that ships.

[Automated review]

const min = isRtl ? -room : 0;
const max = isRtl ? 0 : room;
const position = isHorizontal ? element.scrollLeft : element.scrollTop;
return delta > 0 ? position < max - 1 : position > min;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In RTL the end edge is min, which gets no tolerance here, while an LTR list's end (max) gets 1 px. Chromium stops an RTL list a pixel short of scrollWidth - clientWidth (a 200 px list of 300.6 px content ends at scrollLeft -100 with a computed room of 101), so at that list's end this reads as still scrollable and an opted-in pan never gets the wheel, while the mirrored LTR list reads as at its edge. Mirroring the tolerance reads both correctly:

return delta > 0 ? position < max - 1 : position > min + 1;

Comment on lines +21 to +23
* once that press's contacts end (FR24). There a finger's claim rests on
* `touch-action`: an element's blocking `touchmove` listener stays a
* coarse pointer's, so the element keeps its one listener.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This says an element's blocking touchmove listener stays a coarse pointer's, but refresh (lines 1015-1031) adds it for every pointer once a recognizer claims while moving. Could the comment follow the code?

Comment thread .changeset/gesture-host-owner.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: the gesture host becomes the one document-level touch owner: the finger count, touch gates, and a kept press for continuous-tracking controls, whose element gets `touch-action: manipulation` where the pointer is coarse. A scroll lock's page hold and the finger-scroll watch keep their own listeners and ask the host only about the touches it owns. A second finger fails a one-finger pan that has not claimed, so the page can pinch. A scroll-edge pan holds a lone finger from the first move that pulls away from an edge every scroller under it is at, so those moves are cancelled before it begins; with a second finger anywhere on the screen the page keeps its pinch. Where the primary pointer is fine, the host attaches nothing and registers no listener, following the pointer when it changes; a recognizer that reads each event's pointer itself opts out with `perEventPointer`, and a press tracker with it paints a finger's press feedback only, with no control event and no mouse or pen press. A press tracker whose hold acts (a stepper's hold-repeat) can bring a selection lock for its presses, as a recognizer can. Internal, for the touch components that adopt them; not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This says that where the primary pointer is fine the host attaches nothing and registers no listener, while gestures-every-pointer.md in the same release says gestures attach for every pointer and that perEventPointer does nothing. [chore] entries publish under Other Changes, so both would reach the changelog; could this one be amended to match?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request waits on an engineering owner's decision: it carries three spec sentences from #8360 that the current records do not state yet (spec:AST-094 FR11's tap on moving content, FR19's document touchmove during compositor motion, and architecture:react-component-runtime INV13's shared window listener). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. These fixes are needed whatever the decision:

  • Land #8360 first or retarget onto it, so the diff is the wheel change (19,564 changed lines today, 17,100 of them from #8360).
  • Keep a followed pan's post-lift momentum prevented, end a stream a scroller keeps on FR43's 180 or 220 ms pause rather than 100 ms, and keep ctrl-wheel pinches out of the trackpad splitter.
  • Date the release of a pan detached or disabled mid-swipe at its last wheel, not 180–220 ms later, so the next press keeps its own clock (FR16).
  • Route the wheel latch and the motion touchmove through subscribeInput, let useFingerScroll call useAttachment, retitle to the trackpad-only pan, and update the comments that still call the ends provisional.

[Automated review]

) {
// Every recognizer that took it has left (detached, disabled or
// cancelled): the rest of the gesture is the page's.
this.endWheel();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After the lift ends the pan (:2202-2212), its recognizer is back in possible, so the next momentum wheel fails wheelHasTaker and reaches this branch: the rest of the stream becomes a kept gesture and is never prevented, and the f.lifted branch at :2199 never runs. A fling of four 10 px wheels then shrinking ones, 16 ms apart, left all twelve wheels after the 112 ms lift unprevented. FR43: “momentum after the lift never moves a surface or continues a gesture”. Could a lifted follow stay latched and prevented until a splitter boundary or its end timer, with defaultPrevented asserted on post-lift momentum in wheel.test.ts?

gesture.timer = this.clock.setTimeout(() => {
this.segmenter.end();
this.endWheel();
}, gesture.follow?.endMs ?? WHEEL_END_MS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A stream the page keeps has no follow, so this timer (and the stale check at :2137) ends it 100 ms after its last wheel, and the next wheel lets beginWheel decide again. A slow vertical drag a list kept for three wheels, paused 130 ms at the list's edge, then had its next three wheels prevented and taken by the pan. FR43 ends a moving gesture only after 180 ms (vertical) or 220 ms (horizontal). Could a kept trackpad stream use the axis's splitter bound too?

horizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS,
);
this.wheelSegment = seg;
if (seg.boundary != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every wheel reaches the splitter here, a ctrl-wheel pinch's included, so a pinch that slows for four deltas reads as momentum and a reversal or a 50 ms rest ends it. A pinch of −3, −4, −5, −4, −3, −2.5, −2, −1.5 then +1…+4 ended at the reversal at scale 1.189 and a second pinch began from 1; resting 60 ms ended it too. FR43: “A pinch takes a ctrl-wheel and ends 100 ms after its last event”; DEC-24 gives the splitter to every pan. Could the splitter read only a pan's (non-ctrl) stream?

// move that input's times too (FR16).
const end = gesture.last + (gesture.follow?.endMs ?? WHEEL_END_MS);
const time =
endTime == null ? end : Math.max(gesture.last, Math.min(end, endTime));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The detach path (:894-903) and the taker-left path (:2149) call endWheel() with no time, so this stamps the release at the last wheel plus 180 or 220 ms and moves the arena's clock there. After a wheel at 0 ms and a detach at 10 ms the arena read 180 ms, and a long press landing at 20 ms fired at 680 ms instead of 520 ms (FR16). Could those two paths pass the last wheel's (or the current wheel's) time?

/** Route every wheel on the page to the live wheel gesture, from its claim. */
private latchWheel(): void {
if (!this.isWheelLatched) {
this.doc.addEventListener('wheel', this.onWheel, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a document listener outside subscribeInput (utils/inputStream.ts, “the one entry for document-wide input listeners in core”), which the host's other page input uses and this diff's INV13 sentence names. Could the latch subscribe with subscribeInput(view, 'wheel', this.onWheel, {cancels: true}) and unsubscribe in endWheel?

return true;
this.isBlockingForMovers = needed;
if (needed) {
this.doc.addEventListener('touchmove', this.onBlockingTouchMove, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The compositor-motion touchmove is also a document listener beside the shared input stream; pageHold.ts routes its cancelling touchmove through subscribeInput(…, {cancels: true}). Could this go the same way, so an app's window-capture stopPropagation affects all of the host's input alike?

const attachedRef = useRef<{node: Node; detach: () => void} | null>(null);
// Follows the node on every commit: a ref that now points at another
// element moves the watch to it.
useIsomorphicLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These two layout Effects (here and :70-76) copy useAttachment (useAttachment.ts:80-98: compare the node, release the old attachment, attach the new one, release at unmount). Could useAttachment take a Node or a document, so this hook makes that one call and a node-replacement fix reaches both?


/**
* How long after its last event a wheel gesture ends, ms: the web reports no
* wheel phases, so a quiet wheel is a lifted one. Provisional (owner

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These ends are no longer provisional: current FR43 and DEC-24 state the 180 and 220 ms pauses and the pinch's 100 ms end. Could this cite them, and could onWheel's (:2117-2119) and endWheel's (:2470-2472) comments and wheel.test.ts:10-11 describe the 180 and 220 ms ends and the still release?

@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebuilt as four commits on #8360's new head 5337c27: 310f1fb. This change's own lines are unchanged.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request waits on an engineering owner's decision: it carries three spec sentences from #8360 that the current records do not state yet (spec:AST-094 FR11's tap on moving content, FR19's document touchmove during compositor motion with its verification row, and architecture:react-component-runtime INV13's shared window listener). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. These fixes are needed whatever the decision:

  • Land #8360 first or retarget onto it, so the diff is the wheel change (19,585 changed lines today, 17,121 of them from #8360).
  • Keep a followed pan's post-lift momentum prevented, end a stream a scroller keeps on FR43's 180 or 220 ms pause rather than 100 ms, and keep ctrl-wheel pinches out of the trackpad splitter.
  • Date the release of a pan detached or disabled mid-swipe at its last wheel, not 180–220 ms later, so the next press keeps its own clock (FR16).
  • Route the wheel latch through subscribeInput, let useFingerScroll call useAttachment, retitle to the trackpad-only pan, and update the comments that still call the ends provisional.

[Automated review]

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #8360 is merged: 9f9dc80, four commits with the same changed lines. The Testing section has this head's gates.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed: the wheel path follows spec:AST-094 FR43 and DEC-24 in shape, but five behaviours on this head contradict current spec, each reproducible with a short jsdom case.

  • Keep a followed pan's post-lift momentum prevented; today every momentum wheel after the lift reaches the page (FR43).
  • End a trackpad stream a scroller keeps on FR43's 180 or 220 ms pause rather than 100 ms, and keep ctrl-wheel pinches out of the trackpad splitter so they end 100 ms after their last wheel.
  • Date the release of a pan detached or disabled mid-swipe at its last wheel, so a press right after keeps its own clock (FR16).
  • Route the wheel latch through subscribeInput as architecture:react-component-runtime INV13 requires, and call nativeScroll.ts's edge test instead of the copied scrollsBy.
  • Retitle to the trackpad-only pan this ships, declare the 2,476-line change a behavior-module PR in the description, and update the comments that still call the wheel ends provisional.

[Automated review]

Comment on lines +2150 to +2157
if (
gesture != null &&
gesture.ids.length > 0 &&
!this.wheelHasTaker(gesture)
) {
// Every recognizer that took it has left (detached, disabled or
// cancelled): the rest of the gesture is the page's.
this.endWheel();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After the lift ends the pan (:2210-2221), its recognizer is back in possible, so the next momentum wheel fails wheelHasTaker and lands here: the rest of the stream becomes a kept gesture and is never prevented, and the f.lifted branch at :2207 never runs. A fling of four 10 px wheels and then shrinking ones, 16 ms apart, leaves all twelve wheels after the 112 ms lift unprevented. FR43: “momentum after the lift never moves a surface or continues a gesture”. Could a lifted follow stay latched and prevented until a splitter boundary or its end timer, with defaultPrevented asserted on post-lift momentum?

gesture.timer = this.clock.setTimeout(() => {
this.segmenter.end();
this.endWheel();
}, gesture.follow?.endMs ?? WHEEL_END_MS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A stream the page keeps has no follow, so this timer (and the stale check at :2145) ends it 100 ms after its last wheel and resets the splitter; the next wheel lets beginWheel decide again. A slow vertical drag a list kept for three wheels, paused 130 ms at the list's end, then had its next three wheels prevented and taken by the pan. FR43 ends a moving gesture only after 180 ms (vertical) or 220 ms (horizontal). Could a kept trackpad stream use the axis's bound too?

horizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS,
);
this.wheelSegment = seg;
if (seg.boundary != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every wheel reaches the splitter here, a ctrl-wheel pinch's included, so a pinch that slows for four deltas reads as momentum and a reversal or a 50 ms rest ends it. A pinch of −3, −4, −5, −4, −3, −2.5, −2, −1.5 then +1…+4 ends at the reversal at scale 1.189, and a second pinch begins from 1; resting 60 ms ends it too. FR43: “A pinch takes a ctrl-wheel and ends 100 ms after its last event”. Could the splitter read only a pan's (non-ctrl) stream?

// move that input's times too (FR16).
const end = gesture.last + (gesture.follow?.endMs ?? WHEEL_END_MS);
const time =
endTime == null ? end : Math.max(gesture.last, Math.min(end, endTime));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The detach path (:897-903) and the taker-left path (:2157) call endWheel() with no time, so this stamps the release at the last wheel plus 180 or 220 ms and moves the arena's clock there. After a wheel at 0 ms and a detach (or a disable) at 10 ms, a long press landing at 20 ms fires at 680 ms instead of 520 ms (FR16). Could those two paths pass the last wheel's time?

/** Route every wheel on the page to the live wheel gesture, from its claim. */
private latchWheel(): void {
if (!this.isWheelLatched) {
this.doc.addEventListener('wheel', this.onWheel, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a document listener outside subscribeInput (utils/inputStream.ts), while architecture:react-component-runtime INV13 says the gesture host hears document-wide input through one capture listener per event type on the window, before any document listener. An app's window-capture stopPropagation hides a latched wheel from this listener, so it scrolls the page. Could the latch subscribe with subscribeInput(view, 'wheel', this.onWheel, {cancels: true}) and unsubscribe in endWheel?

}

/** Whether `element` scrolls natively by `delta` along one axis now. */
function scrollsBy(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This restates canMoveX/canMoveY in nativeScroll.ts (overflow, room, RTL range, edge tolerance) with different numbers: 1 px at the ends and any room above 0 here, 0.5 px and more than 1 px of room there. Both feed beginWheel's decision (:2357-2359), and a finger reads the probe's, so a scroller 0.5 to 1 px from its end reads differently to a finger and a trackpad. Could nativeScroll.ts export the per-element edge test (or let the probe stop at a boundary) and scrollerTakes call it?

vjeux added 10 commits November 2, 2026 11:04
…trl-wheel (spec:AST-094 FR43)

meta#8676's reviewed change, rebuilt as one commit on meta#8360's head: a wheel
gesture drives the recognizers that allow its scroll type as a contact
moving with the content, a pan along its own axis once every scroller
between it and the page is at its edge, and a ctrl-wheel drives a pinch.
A screen-edge pan stays a finger's.
…ain input reads as a mouse wheel, and a pan can take a swipe as one step (spec:AST-094 FR43)

The round-4 trackpad behaviors, as a draft for the owner to try each one:

- wheelSegmenter.ts splits the wheel stream into gestures and reads a
  trackpad's lift: once a run of shrinking deltas has lost a fifth of its
  size, momentum began. A pan the host follows ends there at the fingers'
  travel, released at their last move with their speed, and the momentum
  after it never moves the pan. Fingers that stop release still, 180 ms
  (vertical) or 220 ms (horizontal) after their last wheel.
- Input the deltas cannot place reads as a mouse wheel: a delta a hair off a
  whole pixel, a scroll with no legacy wheelDelta (an engine that sends
  none), a legacy wheelDelta that is not three times the delta, or a 120
  tick over a 40 px delta.
- A pan created with wheelStep takes a trackpad swipe as one step: a short
  flick once the swipe travels 24 px, a slow swipe's travel carried across
  pauses, its slow tail absorbed, and one undo back to rest.

A mouse wheel's gesture and a pinch keep their 100 ms end. In Chromium the harness's wheels arrive as notches (wheelDeltaY -120 whatever the delta), which now read as a mouse wheel, so the two trackpad-only browser cases run in WebKit, whose driver sends a trackpad's precise deltas. The lift's
release is stamped at the fingers' last move: stamped at the lift, three
held wheels later, it read as a stop and lost the fling.
…speed until the splitter decides, so it never stalls before a lift (spec:AST-094 FR43)

The splitter reads a lift only after four shrinking deltas, so a pan that
held them stood still for that stretch (about 64 to 130 ms) and then let go
at the fingers' speed: a stop, then a go. Through the undecided stretch the
pan now moves on from where it last was at the fingers' last speed, never
past what that speed projects for the time since. At the lift it keeps that
travel and lets go there, still moving at that speed, and nothing more
moves. When the fingers carry on, their moves absorb what the stretch showed
past their travel or short of it, half a move at a time, so the pan never
steps back and ends on their travel; fingers that stop without a lift end on
their travel where it runs ahead.
…t, and never a mouse wheel (spec:AST-094 FR43, DEC-24)

The merged record decides it: a trackpad's two-finger scroll is direct
manipulation, so a pan follows it where its record allows it; a mouse
wheel's notches are a scrolling control, so a pan takes none, and a scroll
without the trackpad signature, Shift+wheel included, counts as a wheel's.

- A pan's allowedScrollTypes takes only 'continuous', and none by default,
  as UIKit's allowedScrollTypesMask: the sheet, the pager and swipe rows opt
  in. The host never feeds a wheel's notches to a pan, whatever a caller
  passes. ScrollType keeps 'discrete' for the pinch, which takes a
  ctrl-wheel from a trackpad or a mouse.
- A scroll with Shift held reads as a mouse wheel's.
- wheelStep goes: DEC-24 rejects a discrete step per trackpad swipe for rows
  and paging, and nothing used it.
- The splitter's verification streams: ten swipes that each start in the last
  one's momentum, ten whose momentum runs out, and a slow drag with 150 ms
  pauses count ten, ten and one gestures.
After a trackpad's lift released the pan, the pan was back in possible, so
the next momentum wheel read as every taker gone and the rest of the stream
went to the page unprevented. A lifted follow now stays latched and
prevented until the splitter's next gesture or the end timer, and moves
nothing (spec:AST-094 FR43). A fling of four 10 px wheels and sixteen
shrinking ones 16 ms apart left all twelve wheels after the 112 ms lift
unprevented before; they are prevented now, and the fingers' next swipe is
a new gesture. In WebKit, the trackpad proof now also checks that no wheel
of the stream reached the page, the momentum after the lift included; it
saw four after the lift before.

The latch never strands the page: it lets go with the stream's end window
(220 ms after its last wheel), after which a wheel is decided afresh, and
when the pan it released is detached. A case guards each.
…nd a ctrl-wheel pinch stays out of the splitter

A trackpad stream the page or a scroller kept had no follow, so it ended
100 ms after its last wheel and the next wheel was decided afresh: a slow
vertical drag a list kept for three wheels, resting 130 ms at the list's
end, had its next three wheels taken by the pan. Every wheel gesture now
carries its own end window: a trackpad pan stream the bound for its axis
(180 ms vertical, 220 ms horizontal) whoever keeps it, and 100 ms for a
pinch or a mouse wheel's notches (spec:AST-094 FR43).

Every wheel also reached the trackpad splitter, a ctrl-wheel pinch's
included, so a pinch that slowed read as momentum, and a reversal or a
50 ms rest split it in two. The splitter now reads a pan's stream only; a
pinch ends 100 ms after its last wheel. Both cases failed before. The
right-to-left scroller case takes its second stream after the 220 ms
bound, as the bound now applies to the stream the scroller kept.
…ast wheel

The detach path and the taker-left path ended the wheel gesture with no
time, so its release was stamped at the last wheel plus the end window
(180 or 220 ms) and moved the arena's clock there: after a wheel at 0 ms
and a detach, or a disable followed by the fingers' next wheel, a long
press landing at 20 ms fired at 680 ms instead of 520 ms
(spec:AST-094 FR16). Both paths now stamp the release at the last wheel.
A detached and a disabled case failed before.
…stream, and a scroller's ends read as a finger's

The latch was a document capture listener outside subscribeInput, so an
app's window-capture stopPropagation of a wheel that left the moved element
hid it from the host and the page scrolled. It now subscribes to the
window's wheel stream with {cancels: true}, as the page hold does, and
leaves it when the gesture ends (architecture:react-component-runtime
INV13).

The wheel path also restated nativeScroll.ts's edge test with other numbers
(1 px at the ends, any room above 0), so a scroller 0.5 to 1 px short of its
end read as at its end to a trackpad and as scrollable to a finger. The
wheel now calls nativeScroll's canElementScroll, the probe's own per-element
test. The right-to-left case uses the measured end instead: WebKit scrolls
a fractional scroller a pixel past its reported overflow, which both tests
read as its end; Chromium's end is the reported overflow (neither stops a
pixel short of it). The latch, its window listener at a detach, and the
0.75 px edge cases failed before.
…them

The comments on the three wheel end windows still called them provisional,
pending an owner question; spec:AST-094 FR43 and DEC-24 now state them (100
ms for a pinch or a mouse wheel's notches, 180 ms vertical and 220 ms
horizontal for a trackpad scroll, whoever keeps it). No code changes.
…s it is, in short sentences, without new record citations

Main's no-comment-provenance rule refuses a record or claim citation in a
new or changed source comment, and source comments use short, one-topic
sentences. The comments this change added or changed in the host's wheel
path, the stream splitter, the recognizers' scroll options, native
scrolling's edge test and the scroll types cited the record they follow,
and many ran long. They now keep only the reason, in short sentences. The
screen-edge pan keeps the one citation that is its only link to the
record, and the provenance baseline drops the four citation entries the
other edited comments carried. The README and the wheel handler's doc now
describe the latch as the shared input stream's window-capture listener,
and the end window as the stream's, whoever keeps it. Comments and docs
only.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

All five are fixed, one commit each, red first, on main 1667ea8. The feature's four commits have the same changed lines. A last commit, comments and docs only, drops the record citations from the comments this change added or edited and shortens their sentences.

  1. Post-lift momentum (8edb2ef). A followed pan's own lift no longer reads as every taker gone: its momentum stays latched and prevented, and moves nothing. On your fling (four 10 px wheels, then sixteen shrinking ones 16 ms apart), all twelve wheels after the 112 ms lift went unprevented. Now none does. WebKit's trackpad proof checks the same with real wheels; it saw four unprevented after the lift before. Two cases guard that the latch never strands the page: it lets go with the stream's end window, and when the pan it released is detached.

  2. End windows (b5e6cd5). Every wheel gesture carries its own end window:

    • a trackpad pan stream: 180 or 220 ms, whoever keeps it;
    • a pinch or a mouse wheel's notches: 100 ms.

    The splitter reads a pan's stream only. Three cases now stay one gesture: the list kept for three wheels with a 130 ms rest at its end; a slowing pinch that reverses; a pinch that rests 60 ms. The right-to-left scroller case takes its second stream after the 220 ms bound.

  3. Release time (15e75a0). The detach and taker-left paths date the release at the last wheel. The long press fires at 520 ms in both the detach and the disable case.

  4. Latch and edge test (2512632).

    • The latch subscribes to the window's wheel stream with {cancels: true} and leaves it in endWheel.
    • The wheel path calls nativeScroll's canElementScroll, the probe's own per-scroller test. A scroller 0.75 px short of its end keeps the stream.
    • The right-to-left case now uses measured ends: WebKit leaves a fractional scroller a pixel past its reported overflow, which both tests read as its end, and Chromium ends at the reported overflow.
  5. Comments, title and description (b132538, 2071e89). The comments no longer call the wheel ends provisional, and the README and the wheel handler's doc describe the shared-stream latch. The title names the trackpad-only pan, and the description declares a behavior-module pull request and lists each user-visible change before and after.

A trackpad pan has no lift, so a window blur or a hidden page left it
mid-gesture: its end timer released it later, and its latch kept taking
the page's wheels until then. Under a fine pointer the host is not
installed during a wheel gesture, so its own blur and visibilitychange
listeners were absent. The latch now listens for both while it holds a
gesture, and the host's cancel of every touch also cancels a wheel gesture
a recognizer still drives. The pan reads cancelled, never ended, and
releases without momentum, as a cancelled touch does; the next wheel is
decided afresh. A pan its lift already released keeps its momentum latched
until the stream ends. spec:AST-094 FR43 says so in one sentence, and its
Verification row lists the case. A blur, a hidden page, and a blur under a
coarse primary pointer each failed before.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: the wheel path now follows spec:AST-094 FR43, DEC-24 and FR16, and the earlier momentum, end-window, pinch, release-stamp and latch findings are fixed with regression cases. One item remains: panTakesMove in GestureHost.ts keeps its own copy of the pan's direction rule and ignores axisRatio, so a pan with a non-default axisRatio reads a trackpad's direction differently from a finger's. Please call the pan's own rule (for example through recognizerInternals, as family is) and add a wheel case with a non-default axisRatio; the two comment notes inline are optional.

[Automated review]

}

/** Whether a pan's `direction` takes a contact moving by (cx, cy). */
function panTakesMove(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This restates the pan's own direction rule (PanGestureRecognizer.classify, PanGestureRecognizer.ts:308-326) without its axisRatio. With axisRatio: 0.5, a 50° mouse drag pans while the same trackpad stream scrolls the page; with axisRatio: 2, a 35° stream's first wheel is prevented before the pan fails. Could the host ask the pan through recognizerInternals (as it does for family) and drop this copy, with a wheel case for a non-default axisRatio?

: 'continuous';
}

/** Whether a recognizer is a scroll-edge pan. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this restates the function's name. If anything, keep the reason, for example: “Read without the pan class, which a page with no pan never ships.”

return 'pinch';
}

/** A pinch takes a trackpad's pinch and ctrl and a mouse wheel. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): “a trackpad's pinch and ctrl and a mouse wheel” reads ambiguously. Suggested: “A ctrl-wheel drives a pinch, from a trackpad or a mouse.”

vjeux added 2 commits November 2, 2026 12:02
…isRatio included

The host chose a trackpad stream's pan by its own copy of the pan's
direction rule, which ignored axisRatio. So with an axisRatio of 0.5, a
50 degree mouse drag panned while the same trackpad swipe scrolled the
page; with 2, a 35 degree swipe's first wheel was prevented before the pan
failed. The host now asks the pan's own rule through the gesture system's
internal view, and a swipe the rule leaves undecided stays the page's. A
wheel case for each axisRatio failed before. Two comments say less: the
scroll-edge read gives its reason, and the pinch's scroll types read
plainly.
… to the page

After a window blur or a hidden page cancelled a trackpad pan, the next
wheel of the same stream started a new gesture: the splitter was reset,
the pan was possible again, and it began within 10 px. The fingers' later
wheels and the momentum after their lift could then move it. The rest of
a cancelled pan's stream is now the page's until the stream ends, as a
touch the browser takes is; only the next stream is decided afresh. The
blur, hidden-page and coarse-pointer cases now send the rest of the
stream, fingers and momentum, and failed before. A new case moves focus
between elements mid-swipe and cancels nothing.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at 1a0cd94 (commit e8c3a28). panTakesMove no longer keeps its own copy of the direction rule: the host asks the pan's own classify, axisRatio included, and a swipe the rule leaves undecided stays the page's. Two wheel cases fail before it and pass now: with an axisRatio of 0.5, a 50° trackpad swipe is the pan's, as a 50° drag is; with 2, a 35° swipe's first wheel is not prevented, and the page keeps the stream. Both comment notes are taken.

This head also carries the spec owners' ruling on a blur: a window blur or a hidden page cancels a trackpad pan, as the browser taking a touch cancels a finger's. That is one spec:AST-094 FR43 sentence and its Verification case. The rest of a cancelled pan's stream is the page's, and a pan its lift released keeps its momentum latched. A blur, a hidden page, and a blur under a coarse primary pointer each fail before it.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1a0cd94

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request waits on one engineering-owner decision; everything else checks out, including the earlier panTakesMove finding, now fixed with axisRatio cases.

  • The spec:AST-094 FR43 sentence approved at this head says a window blur or a hidden page cancels a trackpad pan, but dropDrivenWheel in GestureHost.ts also cancels a ctrl-wheel pinch mid-pinch and leaves the rest of that ctrl-wheel stream to the page, which then zooms.
  • If an engineering owner wants that for the pinch, FR43 should say so, and an engineering owner records the decision by commenting /approve-spec <full head SHA> on that head (the author may do this as an engineering owner); otherwise, limit the cancel to pans and add a pinch case beside the blur cases.

The comment trims marked non-blocking are optional.

[Automated review]

* is. A gesture the page keeps stays as it is. So does a pan its lift
* released: its momentum stays latched and prevented until the stream ends.
*/
private dropDrivenWheel(): ReadonlyArray<number> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This also drops a driven ctrl-wheel pinch. On this head, a mouse ctrl-wheel pinch reads cancelled at a window blur, and the next ctrl-wheel is not prevented, so the page zooms for the rest of that stream. The approved FR43 sentence names only a trackpad pan. Either record the pinch reading in FR43, or return [] here for a pinch gesture and add a case beside the blur cases in wheel.test.ts.

*/
private fingerTargets: ReadonlyArray<Node> = [];
private nextId = 1;
/** The wheel gesture in progress. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this restates the field name wheel; it can go.

private stopWheelLatch: (() => void) | null = null;
/** Splits the wheel stream into gestures and reads a trackpad's lift. */
private readonly segmenter = new WheelSegmenter();
/** The latest wheel's segment. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this restates the field name wheelSegment; it can go.

… keeps its stream

The blur and hidden-page cancel also cancelled a ctrl-wheel pinch
mid-pinch and left the rest of that stream to the page, which then zoomed
the whole page. It now cancels only a trackpad pan, as the spec:AST-094
FR43 sentence says; a pinch keeps its stream and ends 100 ms after its
last wheel. A pinch case for a blur, a hidden page and a blur under a
coarse primary pointer failed before.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Done at 5a38c0c: a blur or a hidden page now cancels only a trackpad pan, as the FR43 sentence says. A ctrl-wheel pinch keeps its stream through a blur and ends 100 ms after its last wheel, so the page never zooms for it. A pinch case for a blur, a hidden page, and a blur under a coarse primary pointer fails before it and passes now.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 5a38c0c

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Recommend approval: the wheel and trackpad path follows spec:AST-094 FR43 and DEC-24, and the FR43 blur sentence is owner-approved for this head. A blur or a hidden page now cancels only a trackpad pan, and the new pinch cases cover the ctrl-wheel stream. Optionally, drop the few new comments in GestureHost.ts that restate their names (see the inline notes).

[Automated review]

hasTouchEndListener: boolean;
/** Whether the element carries the host's shared blocking touchmove handler. */
hasTouchMoveListener: boolean;
/** Whether the element carries the host's wheel handler. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style, INV29): this restates hasWheelListener; it can go.

*/
private fingerTargets: ReadonlyArray<Node> = [];
private nextId = 1;
/** The wheel gesture in progress. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style, INV29): this restates the field name wheel; it can go.

private stopWheelLatch: (() => void) | null = null;
/** Splits the wheel stream into gestures and reads a trackpad's lift. */
private readonly segmenter = new WheelSegmenter();
/** The latest wheel's segment. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style, INV29): this restates the field name wheelSegment; it can go.

Comment on lines +2198 to +2199
// A trackpad scroll waits out a slow drag's pauses, whoever
// keeps it; a pinch or a mouse wheel's notches 100 ms.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style, INV29): wheelEndMs's doc and the endMs field doc already state this end-window rule; this third copy can go.

This branch was successfully deployed

1 active deployment
Preview — 5a38c0c7 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants