Skip to content

feat(core): the internal touch foundation: press tracking and the animator - #8076

Merged
cixzhang merged 4 commits into
meta:mainfrom
vjeux:wip/uikit-pr-experimental
Oct 31, 2026
Merged

cixzhang merged 4 commits into
meta:mainfrom
vjeux:wip/uikit-pr-experimental

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Behavior-module PR: internal touch foundation, animator, press tracking

Stacked on #8071, one commit on main, with no merge commits: review only this pull request's two commits, vjeux@0ddfa8937e and its test vjeux@91d49e1438.

Why

Touch components need Core's animator, press tracking and gesture host without making them public.

What it absorbs, and why

One solution, the internal touch foundation, so these close with a link here:

Left out

What changes

  • Press tracking follows UIControl and is built by createPressTracker: the press clocks and profiles, usePressTracker, and release velocity on UIKit's filter (spec:AST-100 FR1, FR2).
  • The animator holds the value on screen inline while a curve runs, starts a motion from rest when the browser commits it, offers start: 'presented' and 'commit', additive reversals and handle.curve. A browser proof counts no Layout or Paint while a spring plays, on the harness's rendering trace.
  • The motion modules and recognizers load in plain Node, with no StyleX module in their graph.

How to check it

  1. Run pnpm exec vitest run scripts/motion-node-load.test.mjs: the motion modules and recognizers load in a plain Node process.
  2. In a component using usePressTracker, change highlightDelay while mounted: the next touch uses the new delay.
  3. Run pnpm test:interaction animator.interaction.browser.spec.ts in Chromium and WebKit: a motion started right before a blocked main thread plays from its start after the block.

Evidence

  • Local gates at 91d49e1: build, type check, pnpm test:ui (16,704), the node suites, lint, Storybook, and the motion and gesture interaction specs in Chromium (103) and WebKit (96); the held-dip, block and no-Layout-or-Paint proofs pass six times each at eight workers.

Credit

Co-authored-by: Cindy Zhang (her commits in #8073 and #8076).

Scope

  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Public text and artifacts contain no internal Meta context.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Error Error Oct 31, 2026 4:30am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 4:48 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@vjeux
vjeux force-pushed the wip/uikit-pr-experimental branch from 1efff6c to c929a3b Compare October 24, 2026 04:38

@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 needs a maintainer decision before it can be approved: the two module-level experimental subpaths are not a boundary spec:AST-017 FR21 states today (it names the owning component's /experimental subpath), and what they expose is specified only by the draft spec:AST-094. Approval needs a maintainer to provide or identify the governing spec for module-level experimental subpaths and to accept or reject AST-094's draft requirements; an engineering owner can record that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these still need changes:

  • Land the stacked pull requests first or retarget this one, so its diff is only the subpath commit and the stable adaptive-breakpoint change is reviewed in its own pull request.
  • The four experimental hook docs produce docsite pages and ShadCN items that import from @astryxdesign/core at unmarked routes, which fails the docsite, registry and CLI search checks.
  • useGestureRecognizer delivers onGesture from a layout effect when isEnabled turns off, usePressTracker calls onHighlightChange from its cleanup, and both lose their element when it is replaced behind the same ref.

[Automated review]

}, [ref, recognizer, manageStyle]);

useIsomorphicLayoutEffect(() => {
recognizer.setEnabled(isEnabled);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Turning isEnabled off mid-gesture cancels it here, so the caller's onGesture receives cancelled from inside this layout effect (the hook's own test records ['began', 'cancelled'] on that rerender). A consumer callback should fire from the change that caused it, not from an effect reacting to it; either cancel where isEnabled flips or document this as the external sync and stop listener delivery for a disable.

);

useIsomorphicLayoutEffect(() => {
const element = ref.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ref.current is read once, in an effect keyed on the ref object, so replacing the element behind the same ref ({open ? <div ref={ref}/> : <span ref={ref}/>}) leaves the recognizer on the detached node; a pan on the new element yields no states at all. A callback ref, or tracking the node and re-attaching when it changes, would follow the replacement. usePressTracker below has the same shape.

);
return () => {
detach();
tracker.dispose();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

dispose() calls setHighlighted(false), which calls the caller's onHighlightChange(false) when the control unmounts while highlighted, so a consumer callback runs from effect cleanup. Making dispose() silent on unmount avoids a state update into an unmounting tree.

],
},
relatedHooks: ['usePressTracker', 'useTouchPresentation'],
importPath: '@astryxdesign/core/gestures/experimental',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The docsite generator ignores this importPath: it resolves the hook to @astryxdesign/core, because only a ./gestures export would match, and publishes hooks/use-gesture-recognizer with a ShadCN item that writes export * from '@astryxdesign/core';. With the other three hook docs, this adds four routes missing from the ShadCN route lock (docsite-data, docsite-browser and registry-contract fail) and changes the astryx search experimental goldens. The generator needs to honor this path, and the hooks need marked experimental routes or none, before the lock is refreshed.

Comment thread packages/core/src/gestures/README.md Outdated
A gesture recognizer system with UIKit's semantics, for the touch
presentations of Astryx core components. It is internal to
`@astryxdesign/core`: components import it from `../gestures`, and nothing
here is exported from the package entry.

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 now contradicts gestures/experimental.ts, which exports the system from @astryxdesign/core/gestures/experimental and asks for this README to be updated with it; gestures/index.ts says the same in its header.

where a switch outside any scroll view is needed.

Done (iOS 26.2 simulator, Oct 23 2026; in the switch's own table,
`uikit/switch.ts`): M-SW02, M-SW03, M-SW04, M-SW05, M-SW06, M-SW09 (UISwitch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uikit/switch.ts does not exist at this head, and neither does uikit/contextMenu.ts, cited at line 254. Either point at where those values live or drop the references until those tables land.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This moves the stable adaptive boundary for five menus and for DropdownMenu sub-menus, whose default is adaptive (useMenuDrillIn.tsx reads this constant). A coarse-pointer viewport 744 to 768px wide (an iPad mini in portrait) now gets the popover or flyout instead of the sheet or drill-in, and a landscape phone wider than 768px gets the reverse. That contradicts the description's "Stable callers see no change", and it belongs in its own pull request with its own decision.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.

The exact-head preview is not available yet.


Generated by PR Enrichment workflow | View current CI run

@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 needs an engineering owner's decision before it can be approved: spec:AST-017 FR21 places experimental exports on a component's /experimental subpath, so the two module-level subpaths need a governing spec, and what they expose rests on the draft spec:AST-094 (FR1–FR23, DEC-1, DEC-2). An engineering owner can record that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or can name the record that should state module-level experimental subpaths. Whatever that decision, these still need changes:

  • Land the stacked pull requests first or retarget this one, so its diff is only the subpath commit and the stable adaptive boundary change is decided in #8062.
  • The four experimental hooks' docsite pages and ShadCN items resolve to @astryxdesign/core, which does not export them, at unmarked routes, and the refreshed route lock would publish them that way.
  • useGestureRecognizer and usePressTracker call onGesture and onHighlightChange from effects, and keep their recognizer on the old node when the element behind the ref is replaced.

[Automated review]

"aliases": []
},
{
"name": "hook-use-animator",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Locking these four hook routes publishes the pages and ShadCN items as the generator builds them today: resolveImportPathForPkg (apps/docsite/scripts/generate-data.mjs:366–375) gives them @astryxdesign/core, which exports none of the four, so the item installs only export * from '@astryxdesign/core';. spec:AST-017 FR27 also asks for marked experimental routes. The generator needs to honor the docs' importPath (or map <dir>/experimental exports) before these routes are locked.

@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.

Changes are needed before this can be approved: the diff still carries its open dependency stack (14,048 authored lines against about 670 lines of its own boundary guards), and the gesture host it folds in contradicts current spec:AST-094. Please land the stack or retarget onto its top so this diff carries only the boundary work, and fix the points below in the pull requests that own them.

  • GestureHost listens only under a coarse primary pointer, while AST-094 FR19 and FR43 and architecture:react-component-runtime INV13 attach gestures for every pointer; it also lets one scroll take a finger that has not moved (FR20) and writes touch-action over a narrower author value (FR18).
  • Under a fine primary pointer, a finger press on an action group never ends its touch and breaks the group's next press (family:action-groups FR5).
  • useGestureRecognizer calls onGesture from an Effect when its element unmounts or is replaced mid-gesture (INV2), and recognizers are told apart by instanceof while PressTracker is a class (INV13).
  • utils/pressFeedback.ts and .changeset/gesture-press-tracking.md still call writePress the press attribute's only writer, and the gestures README and barrel still describe a Lab import route the new guard forbids.

[Automated review]

}

private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer this returns before looking at any registration, so a mouse, trackpad or pen never reaches a pan, swipe or long press. spec:AST-094 FR19 and FR43 and architecture:react-component-runtime INV13 now attach gestures for every pointer, listening on the element under a fine pointer and adding document listeners only during a press (FR24).

element.style.getPropertyValue(property),
);
}
element.style.setProperty(property, value);

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 writes the derived value over whatever the element had; nothing reads the author's computed touch-action. A tap recognizer on an element styled touch-action: none ends up with manipulation. spec:AST-094 FR18 (DEC-8) requires the derived value to be intersected with the author's and never wider than it.

) {
continue;
}
if (touch.target instanceof Node && scroller.contains(touch.target)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any scroll above an unclaimed finger hands it to the browser here, with no travel check, so a resting finger's tap fails and its touchend is cancelled. spec:AST-094 FR20 counts a native scroll only once that finger has moved the 10 px scroll take distance (DEC-8).

if (
this.isInstalled ||
this.groupPressPointer != null ||
!isPointerKind(event.pointerType) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isPointerKind accepts touch, but this group path runs only while the host is not installed, so usesTouchEvents is still its initial true and onPointerUp's touch branch never ends the finger's arena touch. Under a fine primary pointer one finger press leaves a live touch and the group's next mouse press gives no tap (family:action-groups FR5 covers a finger as well as a mouse and pen).

) {
return;
}
current?.detach?.();

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 detach (and the unmount one at line 254) cancels a recognizer mid-gesture inside a layout Effect while the listener added at line 75 is still subscribed and unguarded, so onGesture hears cancelled from the Effect. architecture:react-component-runtime INV2 and the comment at line 63 rule that out; a guard like usePressTracker's isReleasingRef would keep it out.

);
}

export class PressTracker implements TouchConsumer {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV13 keeps classes to the recognizer family (base, arena, host) and builds every other module from create… factories, and it tells recognizers apart by kind, never by instanceof (line 150 here, and GestureHost.ts lines 160, 344 and 361, touchAction.ts line 118).

}

/**
* Write `next` on `element` if its state is one of `onlyFrom`: THE ONE

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hooks/actionGroupTracker.ts still sets and removes data-astryx-press directly (lines 252, 259, 266, 272 and 442), as the gestures README now says, so this is not the one writer for every painter; the same claim is in .changeset/gesture-press-tracking.md.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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.

Changes are needed before this can be approved: the diff still carries the open #8061 and #8071 commits plus a 7,765-line commit of its own (13,299 authored lines in all), Core's type check fails on this head, and the gesture host contradicts current spec:AST-094. Please land the stack and #8548 or retarget onto them, split the internal entries from the foundation, and fix the points below in the pull requests that own them.

  • GestureHost listens only under a coarse primary pointer (FR19, FR43, architecture:react-component-runtime INV13) and writes its touch-action over a narrower author value (FR18).
  • ScreenEdgePanGestureRecognizer accepts a pen or a mouse, which FR43 keeps off.
  • useGestureRecognizer calls onGesture from a layout-effect cleanup when its element unmounts or is replaced mid-gesture (INV2), and PressTracker is a class told apart with instanceof checks (INV13).
  • animator.interaction.browser.spec.ts imports countBetween and traceRendering, which the harness does not export until #8548 lands, and the press-writer comments, the changeset and the gestures README misdescribe this head.

[Automated review]

Comment on lines +627 to +630
private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer the host installs nothing, so a mouse, trackpad or pen never reaches a pan, swipe or long press. Current spec:AST-094 FR19 and FR43 and architecture:react-component-runtime INV13 attach gestures for every pointer (listening only on elements that use one, with document listeners only during a press, FR24).

Comment on lines +549 to +551
const style: GestureHostStyle = this.isPrimaryPointerCoarse()
? gestureHostStyle(registration.recognizers.filter(r => r.isEnabled))
: {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The derived value is written over the element's own touch-action without reading it, so an author's none becomes manipulation. FR18 requires the result to be intersected with the author's value and never wider than it.

Comment on lines +73 to +77
super({
maximumNumberOfTouches: 1,
hysteresis: UIKIT_SCREEN_EDGE_PAN.hysteresis.value,
...options,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No pointer restriction here, so on a coarse-primary tablet a pen or a mouse dragging from the edge band begins and ends this pan; FR43 keeps the screen-edge pan off under a mouse, a trackpad or a pen. Defaulting allowedPointerTypes to ['touch'] would match it.

Comment on lines +252 to +255
useIsomorphicLayoutEffect(
() => () => {
attachedRef.current?.detach?.();
attachedRef.current = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This detach (and the replacement path at line 247) cancels a recognizer mid-gesture while the listener from line 75 is still subscribed, so onGesture hears cancelled from a layout-effect cleanup (INV2). Running it under a guard, as usePressTracker does with isReleasingRef, keeps the callback out of the Effect.

Comment on lines +28 to +31
countBetween,
markElement,
startScreencast,
traceRendering,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

countBetween and traceRendering are not exported by @astryxdesign/interaction-harness on this head, so tsc for Core fails with TS2305 twice until #8548 lands.

);
}

export class PressTracker implements TouchConsumer {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

INV13 keeps classes to the recognizer family and builds every other module from create… factories; this class (and the instanceof check at line 150) contradicts it.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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.

Changes are needed before this can be approved: the diff still carries the open #8061 and #8071 commits plus a 7,549-line commit of its own (13,894 authored lines in all), Core's type check fails on this head, and the gesture host contradicts current spec:AST-094. Please land #8061, #8071 and #8548 first or retarget onto them, split the internal entries from the foundation, and fix the points below.

  • GestureHost installs no listener where the primary pointer is fine, so a mouse, trackpad or pen gets no gesture (FR19, FR43, architecture:react-component-runtime INV13).
  • animator.interaction.browser.spec.ts imports countBetween and traceRendering, which the interaction harness does not export until #8548 lands (TS2305).
  • usePressTracker's default scroll brake also drops a mouse or pen press after a finger-driven scroll, where FR11 keeps the brake touch-only.
  • gestures/index.ts still says Lab imports the barrel through internal workspace imports, the resolveMotion comments cite DEC-11 where DEC-15 now applies, and internal/touch re-exports the iOS style gate that no Lab file uses.

[Automated review]

Comment on lines +747 to +750
private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer this keeps the whole host off, so a mouse, trackpad or pen never reaches a recognizer (a 96 px mouse drag gives a pan no transition). Current spec:AST-094 FR19 ("Recognizers attach for every pointer, with FR43's exceptions"), FR43 and architecture:react-component-runtime INV13 ("no touch-only query") attach gestures for every pointer, with FR24's budget: listen only on an element that uses a gesture, and add document listeners only during a press.

Comment on lines +28 to +31
countBetween,
markElement,
startScreencast,
traceRendering,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

countBetween and traceRendering are not exported by @astryxdesign/interaction-harness on this head (they arrive with #8548), so tsc --project packages/core/tsconfig.json --noEmit fails with TS2305 twice and Core's typecheck fails. Landing #8548 first, or leaving the trace case out until it lands, clears it.

if (
touchId != null ||
options.isEnabled?.() === false ||
options.isBraking?.(touch) === true

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 brake applies to every pointer: with usePressTracker's default (GestureHost.isScrollBraking), a mouse or pen press within 150 ms of a finger-driven scroll gets no touchDown, no highlight and no events. spec:AST-094 FR11 keeps the scroll brake touch-only (DEC-21); checking touch.pointerType === 'touch' before braking would conform.

Comment thread packages/core/src/gestures/index.ts Outdated
Comment on lines +7 to +8
* @position Core's own barrel. Not exported from the package; Lab imports it
* through internal workspace imports.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lab no longer reaches this barrel through internal workspace imports: scripts/lab-core-imports.test.mjs rejects any Lab import outside Core's exports, and the README now names @astryxdesign/core/internal/touch. Worth matching that here.

Comment thread packages/core/src/internal/touch.ts Outdated
Comment on lines +67 to +68
IOS_SUPPORTS_QUERY,
IOS_TOUCH_STYLE_KEYS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No Lab file imports the iOS style gate, and an engineering owner asked earlier on this pull request that the iOS idiom exports (IOS_SUPPORTS_QUERY, IOS_TOUCH_STYLE_KEYS) be dropped. Leaving these two off the entry keeps it to what Lab builds on.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

  • The press tracker asks for the scroll brake only for a finger, so a mouse or pen press after a scroll still presses (FR11, DEC-21; new test).
  • The motion notes cite DEC-15, and the gestures barrel no longer names a Lab route.
  • internal/touch no longer re-exports the iOS style gate.
  • The internal entry points move to the first Lab component that uses them, per the maintainers' first-user rule.

@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.

Changes are needed before this can be approved: the diff still carries the open #8061 and #8071 commits plus a 7,122-line commit of its own (13,725 authored lines), Core's type check fails on this head, and the gesture host contradicts current spec:AST-094. Please land #8061, #8071 and #8548 first or retarget onto them, split press tracking from the animator, and fix the points below.

  • GestureHost installs no listener where the primary pointer is fine, so a mouse, trackpad or pen gets no gesture and no fine-pointer press (FR19, FR43, architecture:react-component-runtime INV13).
  • animator.interaction.browser.spec.ts imports countBetween and traceRendering, which the interaction harness does not export until #8548 lands (TS2305).
  • A screen-edge pan adds a window resize listener even where the primary pointer is fine, where FR24 keeps a gesture's listeners on its own element.
  • animator.ts still cites DEC-11 where DEC-15 now applies, .changeset/reduced-motion-record.md describes helpers that already exist, and usePressTracker's defaultVeto replaces the tracker's kind-based tap check.

[Automated review]

}

private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer this keeps the whole host off, so a mouse, trackpad or pen never reaches a recognizer (a 96 px mouse drag gives a pan no transition), and usePressTracker never gets a mouse press for FR11's fine-pointer profile. Current spec:AST-094 FR19 ("Recognizers attach for every pointer, with FR43's exceptions"), FR43 and architecture:react-component-runtime INV13 ("The gesture host attaches for every pointer, with no touch-only query") require the opposite; under a fine pointer FR24 lets a component listen on its own element and add document listeners only during a press.

Comment on lines +28 to +31
countBetween,
markElement,
startScreencast,
traceRendering,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

countBetween and traceRendering are not exported by @astryxdesign/interaction-harness on this head (they arrive with #8548), so tsc --project packages/core/tsconfig.json --noEmit fails with TS2305 twice and Core's typecheck fails. Landing #8548 first, or leaving the trace case out until it lands, fixes it.

}
const view = this.doc.defaultView;
if (this.layoutWatched.size === 0 && view != null) {
view.addEventListener('resize', this.onLayoutChange, PASSIVE_CAPTURE);

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 window listener (and the observer below) is added as soon as a screen-edge pan attaches, under every pointer, so on a fine-pointer page it is the one idle gesture listener while the host otherwise listens for nothing. spec:AST-094 FR24: "Under a fine pointer a component that uses a gesture listens only on its own element and adds document listeners only during a press". Adding it only while the primary pointer is coarse, and re-measuring on the pointer-kind change the host already watches, would match.

Comment thread packages/core/src/motion/animator.ts Outdated
* resolveMotion.ts, which names the motion role it plays (`springs.settle`
* once `registerSpringsTable()` has run, the Reduce Motion fade's curve,
* which the animator registers alone). While touch motion stays internal
* (`spec:AST-094` DEC-11), a role plays its table value: nothing reads a CSS

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DEC-15 superseded DEC-11's "all internal"; resolveMotion.ts and the motion README now say "the per-role tables stay internal (DEC-15)". This line and the comment at line 2348 still cite DEC-11.

}

/** PressTracker's own default: an ancestor's single-finger single tap. */
function defaultVeto(recognizer: GestureRecognizer): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The hook always passes vetoesAncestorRecognizer, so this shape check replaces the tracker's own kind-based vetoesSingleTap on every hook-built tracker, though the comment calls it the tracker's default. INV13 tells a recognizer apart by its kind field; letting the tracker's default apply when the caller gives no veto keeps one rule.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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.

Changes are needed before this can be approved: the gesture host still listens for nothing where the primary pointer is fine, which contradicts current spec:AST-094 FR19 and FR43 and architecture:react-component-runtime INV13, and the diff still carries the open #8061 and #8071 commits plus a 7,123-line commit of its own. Please conform GestureHost to FR43 (under a fine pointer, listen only on elements that use a gesture and add document listeners only during a press, per FR24), or keep the host out of this pull request until #8665 lands. Then land #8061 and #8071 first or retarget onto them, and split press tracking from the animator so each piece is reviewed on its own.

[Automated review]

Comment on lines +845 to +848
private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer this returns before any document listener is installed, so a mouse, trackpad or pen never reaches a recognizer or usePressTracker outside an action group. Current spec:AST-094 FR43 says gestures attach "for every pointer, a mouse, a pen, and a finger, not only a coarse primary pointer", and architecture:react-component-runtime INV13 says the host attaches "with no touch-only query". Under a fine pointer FR24 lets a component that uses a gesture listen on its own element and add document listeners only during a press, which is what the action-group path already does.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

A recognizer that waits on one being disabled no longer calls its listener from the Effect: one count of Effect-driven passes keeps every gesture hook quiet (INV2; new test).

@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.

Changes are needed before this can be approved: the gesture host still listens for nothing where the primary pointer is fine, which contradicts current spec:AST-094 FR19 and FR43 and architecture:react-component-runtime INV13, and the diff still carries the open #8061 and #8071 commits plus a 7,243-line commit of its own. Please conform GestureHost to FR43 (under a fine pointer, listen only on elements that use a gesture and add document listeners only during a press, per FR24), or keep the host out of this pull request until #8665 lands. Then land #8061 and #8071 first or retarget onto them, split press tracking from the animator, and add the real-touch browser cases FR18–FR22 ask for (a narrower author touch-action, the 9 and 10 px take distance, the screen-edge band) where CI runs them.

[Automated review]

Comment on lines +851 to +853
if (!this.isPrimaryPointerCoarse()) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer this returns before anything is registered, so a mouse, trackpad or pen never reaches a recognizer outside an action group. Current spec:AST-094 FR19 and FR43 attach recognizers for every pointer, and architecture:react-component-runtime INV13 says the host has no touch-only query; FR24 lets a component that uses a gesture listen on its own element under a fine pointer and add document listeners only during a press.

Comment on lines +431 to +470
it('registers no document listener where the primary pointer is fine, or while nothing enabled is attached (spec:AST-094 FR19, FR24)', () => {
const {box} = mount('<div id="box"></div>');
const added = vi.spyOn(document, 'addEventListener');
const removed = vi.spyOn(document, 'removeEventListener');
added.mockClear();
removed.mockClear();
const types = (spy: typeof added) =>
spy.mock.calls
.map(call => call[0])
.filter(t => /^(touch|pointer)/.test(t));
// A fine primary pointer: nothing is registered, an element's touchmove
// listener included.
const fine = new GestureHost(document, {
clock,
isPrimaryPointerCoarse: () => false,
});
const boxAdded = vi.spyOn(box, 'addEventListener');
const detachFine = fine.attach(box, new PanGestureRecognizer());
expect(types(added)).toEqual([]);
expect(boxAdded).not.toHaveBeenCalled();
detachFine();
// A coarse one, with only a disabled recognizer: still nothing.
const coarse = new GestureHost(document, {
clock,
isPrimaryPointerCoarse: () => true,
});
const pan = new PanGestureRecognizer({isEnabled: false});
const detach = coarse.attach(box, pan);
expect(types(added)).toEqual([]);
expect(boxAdded).not.toHaveBeenCalled();
// Enabling it registers the listeners; disabling removes them.
pan.setEnabled(true);
expect(types(added)).toContain('touchstart');
expect(boxAdded).toHaveBeenCalledWith('touchmove', expect.any(Function), {
passive: false,
});
pan.setEnabled(false);
expect(types(removed)).toContain('touchstart');
detach();
});

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 case pins the touch-only host that FR19 and FR43 reject. Under FR24 it would become the fine-pointer resource count: one listener on the element that uses a gesture, and document listeners only during a press.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

The gesture browser proof now runs in CI's interaction proofs (gestures.interaction.browser.spec.ts, Chromium and WebKit), with real-touch cases for the 9 and 10 px take, a narrower author touch-action and the screen-edge band, and mouse and pen cases under a fine pointer.

@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.

Changes are needed before this can be approved: the gesture host now serves a mouse, a pen and a finger as spec:AST-094 FR19 and FR43 require, but the diff still carries the open #8061 and #8071 commits plus a 7,682-line commit of its own (14,669 authored lines in all), and FR20's 10 px scroll take still has no real-touch browser case. Please land #8061 and #8071 first or retarget onto them, move the gesture browser proof to the host's commit, and split press tracking from the animator. Then add a case to the gesture browser proof where a native scroll takes an unclaimed press: a still finger with sub-pixel jitter, 9 and 10 px straight, and (7, 7) and (8, 8) diagonally, as FR18–FR22's verification lists.

[Automated review]

}
});

test('measures a pan’s travel per axis: a free pan stays possible at (7, 7) and (8, 8) and begins at (10, 10) (FR19)', async ({

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 proves the pan's per-axis claim (FR17, FR19): at (8, 8) the pan stays possible. FR18–FR22's verification also lists (7, 7) and (8, 8), with a still finger and 9 and 10 px straight, for FR20's straight-line scroll take, where a native scroll takes an unclaimed press once the finger has moved 10 px (the host's onScroll). That take is proven only in jsdom with a synthetic scroll event (GestureHost.test.ts:1189-1213). A press inside #scroller dragged through those states, surviving under 10 px and cancelled at 10, would close it.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

The gesture browser proof moved to #8071, the host's change, with a real-touch case for a scroll's 10 px take of a held press: a still finger, 9 px and (7, 7) keep it; 10 px and (8, 8) lose it.

@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 looks ready from the review's side: every change matches the current touch-presentation and runtime specs, nothing new is exported, and the unit suites, type check, lint and Chromium browser proofs pass, including the new real-touch case for the 10 px scroll take. The size falls under the behavior-module exemption, and merging still needs the required checks, which were skipped on this draft. One optional addition: a regression case for keyboard activation passing the click swallower (FR21's detail === 0 rule), which works today but has no test.

[Automated review]

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #8522: this pull request's own diff is unchanged from the approved 8f4854c apart from #8522's pseudo-element part, which is now on main.

@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.

Changes requested, for test evidence only: nothing new is exported, every change matches the current touch-presentation and runtime specs, and the unit suites, type check, lint and the Chromium and WebKit interaction proofs pass here. spec:AST-094 FR20 was rewritten on main (only a finger's first uncancelable move, judged on its main axis, can hand the touch to the browser), and its new verification row asks for Chromium touch cases of a vertical pan-y drag over a scroller with room and of one where no scroller has room; gestures.interaction.browser.spec.ts covers only the horizontal drag. Please add those two cases beside it; in Chromium both vertical drags also end in pointercancel, so the no-room case should show which signal ends the press. The size is covered by the behavior-module exemption.

[Automated review]

expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false);
});

test('a horizontal drag on a passive pan-y control keeps its press: uncancelable moves the browser cannot pan are not a claim', async ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-094 FR20's verification row also lists "a vertical drag under pan-y over a scroller with room" and "a vertical drag where no scroller has room" for Chromium phone emulation. Two cases beside this one, on the same #passive press, would cover the row. In Chromium both vertical drags also end in pointercancel, which FR20 counts, so the no-room case should show which signal ends the press.

vjeux and others added 2 commits October 30, 2026 18:38
… the gesture host

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

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

The gesture system's real-touch browser proof runs with it, under
playwright.interaction.config.ts on a phone in Chromium and WebKit:
touch-action per element, claims and cancelled moves, click suppression,
pinch, a second finger, a long press under a blocked main thread, the 10
px take, the screen-edge band in both writing directions, a scroll's
take of a held press only once the finger is 10 px from where it landed,
and the browser taking a passive control's press by its scroll or, where
nothing can scroll, its pointercancel (FR18-FR22); and, under a fine
pointer, a mouse and a pen and the page listeners a press adds and
removes (FR43, FR24).

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

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

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

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

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

The two vertical-drag cases FR20's verification asks for are in the gesture browser proof in #8071: over a page with room to scroll the browser takes the press, and where no scroller has room the browser's pointercancel ends it.

@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.

Changes requested, for one test: every change matches the current touch-presentation, runtime and export specs, nothing new is exported, and the two vertical FR20 browser cases now pass in Chromium. The vertical branch of the first-uncancelable-move rule (GestureHost.ts:1259) can be replaced with true while every gesture unit test and Chromium case still passes, although a host changed that way cancels a press on a pan-x control under a vertical drag that this head keeps to the lift. Please add a case that fails in that situation, for example the horizontal pan-y case in GestureHost.test.ts turned 90°: a press under pan-x pinch-zoom in an overflow-x list, a vertical uncancelable touchmove, and the press not cancelled. The new no-room browser case cannot catch it, because Chromium sends pointercancel before any uncancelable move there.

[Automated review]

Comment on lines +1257 to +1259
return Math.abs(dx) >= Math.abs(dy)
? touch.scroll.canScroll(dx, 0)
: touch.scroll.canScroll(0, dy);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Replacing touch.scroll.canScroll(0, dy) with true here keeps every gesture unit test and every Chromium case in gestures.interaction.browser.spec.ts green, yet in Chromium that host cancels a press on a pan-x pinch-zoom element under a vertical drag (uncancelable moves arrive with no pointercancel), where this head keeps the press to the lift. A jsdom case like keeps a horizontal drag under pan-y when Chromium stops asking after one unconsumed move, turned 90° (a pan-x row in an overflow-x list, vertical uncancelable moves, no cancelled), would make this branch fail when it breaks (spec:AST-094 FR20).

… stops asking (spec:AST-094 FR20)

The jsdom case for a horizontal drag under pan-y, turned 90 degrees: a
pan-x pinch-zoom row in an overflow-x list, dragged down, whose later
moves arrive uncancelable. Its first uncancelable move is judged on its
main axis, vertical, where pan-x lets the browser pan nothing, so the
press is kept to the lift. A host that counts every vertical first
uncancelable move as the browser taking the touch fails it; no other
gesture unit test or browser case does.
@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Added the 90° turn of the horizontal pan-y case to GestureHost.test.ts: a vertical drag on a pan-x pinch-zoom row in an overflow-x list, whose later moves arrive uncancelable, keeps its press to the lift. With the vertical branch of the first-uncancelable-move rule replaced by true, it is the only gesture test that fails.

@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.

Looks ready from the review's side: the new pan-x case in GestureHost.test.ts fails when the vertical branch of FR20's first-uncancelable-move rule is replaced by true, which closes the last evidence gap. Every change matches the current touch-presentation, runtime and export specs, and nothing new is exported. What remains sits outside the review: the pull request is still a draft, so its required checks have not run, and #8071 is planned to land first. Separately, five doc comments in GestureHost.ts and GestureArena.ts sit above the wrong declarations, if you want to tidy them.

[Automated review]

# Conflicts:
#	packages/core/src/gestures/__tests__/GestureHost.test.ts

@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 looks ready from the review's side: press tracking, the gesture hooks and the animator's new start and hold modes match the current touch-presentation, runtime and export specs, nothing new is exported, and the unit suites, type check, lint and the Chromium animator proof pass at this head. Two doc comments landed out of place and are worth moving: GestureArena.ts:474 now sits above deferResolution instead of stop, and animator.ts:294-296 above SetOptions instead of AnimatorOptions. The pull request is still a draft, so its required checks have not run yet.

[Automated review]

@@ -457,12 +472,42 @@ export class GestureArena {
}

/** Cancel an active recognizer, or fail a possible one. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is stop’s doc comment; deferResolution was inserted below it, so it now sits above deferResolution’s own doc and stop (line 507) has none. Moving it down to stop fixes both.

@@ -231,6 +294,21 @@ export interface MotionCurve {
/**
* Options of `createAnimator`.

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 doc belongs to AnimatorOptions (line 312); the new SetOptions was inserted between them, so it now reads as an orphan above SetOptions’ own doc.

This branch had an error being deployed

1 failed deployment
Preview — 3325331c Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants