Skip to content

feat(core): swipe actions come from createSwipe and run on the shared drag path; a trackpad's swipe follows the fingers (spec:AST-057 DEC-8; spec:AST-094 FR43, DEC-24) - #8690

Draft
vjeux wants to merge 26 commits into
meta:mainfrom
vjeux:uikit/c12-swipe-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #9261 (on #8658, #8655, #8653 and #8676). Builds the swipe constructor that spec:AST-057 records (DEC-8). This pull request's own commits: vjeux/astryx@0aa861f...e870248

Status: draft. CI is running at this head. Already passed locally at this head: unit tests, lint, typecheck, check:repo, check:knowledge, the CLI and codemod suites (but for two release-cache cases that fail the same way at the base), Storybook's build, the docsite's preview build, the accessibility contracts (3 repeats), and the interaction proofs in Chromium (pinned to two cores at their default of one worker, every case; at 4 workers, every case but the animator's busy-main-thread proof, which starves there as at #9261's head) and in WebKit (four CPUs: the swipe proofs 10 times, and the animator, snap-point drag and gesture proofs).

User need

A list row with swipe actions is a touch accelerator: a finger, a pen or a trackpad's two-finger swipe drags the row aside to uncover its actions.

  • An app should pay for the swipe only where it uses it, and a row should be swipeable as soon as it paints. Today the swipe reaches every import that renders an Item row through Item itself, and the gesture goes live only once a lazily loaded chunk arrives.
  • On a trackpad, the row should move with the fingers and, when they lift, settle at their speed. Today it trails the fingers by 10–14 px and comes to a stop before it settles.
  • On a finger, the row should keep UIKit's feel: a dead zone, a tracking spring, and UIKit's release rule.

Sheets and pagers already run one shared drag path. A row that runs its own copy of that machinery drifts from them, so the row should run the same path and declare only what is its own: its stops and its actions.

Problem and solution fit

  • Swipe actions come through a constructor (spec:AST-057 DEC-8): swipe={createSwipe({leading, trailing, swipeBehavior})}. The constructor's module, @astryxdesign/core/Swipe, carries every module the swipe runs on, so an app that never imports createSwipe ships none of it, and a row given swipe is swipeable at its first paint (FR10).
  • The row ran its own pan, its own frame loop and its own springs, so the trackpad behavior that spec:AST-094 FR43 gives every followed pan (follow from the first event past 8 px, settle by position and velocity, keep the fingers' speed) never reached it.
  • The row now runs the shared path: the trackpad splitter, the pan, the snap-point drag and the animator (spec:AST-094 DEC-24). It declares its stops (rest, the open width, and out past its edge for a full swipe) and its actions, and the drag draws its travel into the one custom property every part of the panel reads (spec:AST-057 FR2).
  • The finger's UIKit model is the one behavior the shared path does not have, so the row supplies it through the shared drag's model hooks instead of running its own drag.

Current specification

  • spec:AST-057:
    • DEC-8 and FR1: one swipe prop, whose value createSwipe makes; Swipe is opaque.
    • FR2: the travel as one custom property. FR3: swipeBehavior, with a development warning for several entries on a commit side.
    • FR4 and DEC-7: a finger, a pen or a trackpad swipes; a mouse drag never does.
    • FR5: UIKit's release rule; a full swipe by position only; an action fires once the row's content has left its box. FR7: an open row's closes. AR2.
    • FR10: every module the swipe runs on is reachable only from createSwipe's module. FR11: an inline createSwipe call is fine.
    • DEP-0017: the released swipeActions and swipeBehavior are deprecated and keep working.
  • spec:AST-094 FR43 and DEC-24: a followed trackpad pan follows the fingers one for one from its first event past 8 px and settles by position and velocity from their last speed; within one gesture an open row only closes; one shared path, and a component declares only its detents and actions.
  • spec:AST-017 FR28, FR29 and DEC-17: a deprecation in a patch adds no runtime warning and changes no caller's behavior.
  • spec:AST-107 FR7: Core uses the public layer except for the known public-API gaps that architecture:react-component-runtime INV13 lists. This pull request names the row's jobs there.

Public delta and caller burden

  • API (packages/core/api-report.md shows each line):
    • adds createSwipe(options: CreateSwipeOptions): Swipe, from @astryxdesign/core/Swipe and the package root, with CreateSwipeOptions (leading?, trailing?, swipeBehavior?), the opaque Swipe, SwipeAction, SwipeActionVariant and SwipeBehavior;
    • adds swipe?: Swipe on Item and ListItem;
    • deprecates swipeActions, swipeBehavior and the types ItemSwipeAction, ItemSwipeActions, ItemSwipeActionVariant and ItemSwipeBehavior (DEP-0017). They keep working as released, with static @deprecated metadata and no runtime warning, until 0.8.0 removes them (CLN-0025). When swipe is set too, swipe wins, and a development-only note says the deprecated props are ignored.
    • The theme targets (item-swipe-panel, item-swipe-action) are unchanged.
  • Behavior:
    • a row given swipe is swipeable at its first paint; a row given the deprecated props loads the swipe the first time it has them, as released;
    • a trackpad's two-finger swipe moves a row with the fingers and lets it go at their speed;
    • a mouse drag and a mouse wheel's notches never swipe a row;
    • a fast flick from an open row settles open, since speed alone never fires an entry (spec:AST-057 FR5), and FR43's long fast swipe fires only where spec:AST-057 allows a full swipe.
  • Callers: nothing to change in this release. astryx upgrade rewrites static swipeActions and swipeBehavior on Item and ListItem to swipe={createSwipe(…)}, with a codemod staged in transforms/next/. It's an opt-in early migration from this release, and 0.8.0's upgrade runs it again with the removal. It adds the import, renames the four types, and drops a merged swipeBehavior="reveal" (the default). It reports computed values, every row that spreads props, and code that mutates the leading or trailing array of a value it retypes as CreateSwipeOptions, since those arrays are readonly.

Design and alternatives

Where the swipe's code lives (spec:AST-057 DEC-8, FR10). packages/core/src/Swipe/ holds createSwipe, the panels (SwipeActionsLayer.tsx), the row's gesture code (useSwipeAction.ts, swipeRelease.ts) and, through them, the shared drag path and the animator.

  • A row given swipe. Item reads the value through swipeHost.ts, a module with no imports, and hands the runtime the value carries its root and the facts it already has: its role, whether it is disabled, whether it is the enlarged target of a control inside it, and whether its root is a link. The runtime decides whether it serves the row (DEC-3). If it does, it returns the root's style, the root's handlers and the layer, and Item renders them. Item holds no swipe code for this path:
    • the root's keys reach the row's swipe through a registry keyed on the root element, inside the runtime; the handlers are the same functions for every row and every value, so a new value from an inline call changes nothing on the root (FR11);
    • while the row is open, the runtime holds its root's press with a private attribute (privateAttributes/pressHeld.stylex.ts). The press controller treats a held surface like a disabled one: it takes the press and paints nothing, a pressable inside it keeps its own press, and the touch still counts for the brake. So a tap on an open row's content only closes it (FR7);
    • the gesture host swallows the click the browser synthesizes after a swipe.
  • Swipe is opaque: a frozen brand with nothing to read. Only createSwipe records what a value carries, so a value built by hand swipes nothing.
  • DEP-0017's deprecated path is the only swipe code in Item, for the deprecated props only, and leaves with them (CLN-0025):
    • the released loader, which brings the layer with import() the first time a row has the deprecated props; that layer brings the released commit warning;
    • the released row style, which such a row takes from its first render (spec:AST-017 DEC-17), and the released role gate;
    • the bridge the loaded layer fills, and the forwarder of the root's key events into it;
    • the coexistence diagnostic.
    • The record's "click guard" has no separate piece: the gesture host swallows the synthesized click on both paths.
  • The bundle guard names the loader as the one import() of swipe code and counts it out of Item's import; without that exception, the 13 imports that render an Item row would carry the gesture core. Swipe joins the imports that carry it.

Internal options the shared primitives gain (no public API; they land with this, their first user):

  • createPan, under the public createPanGesture: which pointers and scrolls start a drag, its claim distance, a begin gate and the selection lock; each report says what drove the drag.
  • createSnapDrag, under the public createDragToSnapPoints, which keeps its behavior and tests:
  • A settle-passes-offset callback: settleTo({onPass}) calls back on the first animation frame whose presented value has passed the offset, read on the settle's own curve. It uses no timer, and never fires for a settle that lands short, is replaced, or is destroyed.
  • The animator draws a channel into one registered custom property, with an optional companion: a second custom property drawn from that value by a pure mapping, in the same keyframes, so it cannot drift from the travel.
  • The press controller reads a held surface (privateAttributes/pressHeld.stylex.ts) as a disabled one.

What the row's own code holds, and why. useSwipeAction.ts is 675 code lines (comments and blank lines excluded):

  • before the callback and the companion moved into the shared drag, it was 778;
  • those two moves took it to 669, adding 21 lines to the shared drag and 29 to the animator;
  • passing the start option on the row's input-driven settles added 6.

The row builds no pan recognizer, runs no frame loop and solves no spring. What remains:

  • its stops and actions: measuring the entries, and the side geometry the panel's CSS reads;
  • the finger's UIKit model: the dead zone, the tracking spring, the never-past-rest clamp, the confirm point and run-ahead, and UIKit's release rule (swipeRelease.ts);
  • the full swipe's action timing (spec:AST-057 FR5);
  • the open row's closes (spec:AST-057 FR7);
  • the panel itself (SwipeActionsLayer.tsx).

Known limitation, inherited from main's released design

A long main-thread task during a drag or settle freezes the row, then it jumps; the released swipe behaves the same. spec:AST-057's current-state impact records it in this pull request.

  • spec:AST-057 FR2 draws the travel into one registered custom property that every part reads, and Chromium animates a custom property on the main thread. The drag's tracking spring draws into the same property. Main's released swipe has the same design.
  • Measured with a 700 ms block during a settle, in Chromium pinned to two cores at one worker, 10 runs:
    • the row moved on none of the 38–40 frames in which a compositor-only control moved, and held one position for the whole 660 ms judged;
    • the control's longest stall was 18–55 ms;
    • in a traced run, the row then jumped from 191 px to 97 px in one frame.
  • spec:AST-057 keeps FR2 as approved, so each part's transform does not move on the compositor (fix(Item): swipe actions move on the compositor with UIKit springs #8097's approach, superseded by this pull request).

Evidence

  • The constructor:

    • createSwipe makes a frozen value with nothing to read, carries the entries per side and swipeBehavior (reveal by default), keeps the entries it was given, and warns in development for several entries on a commit side (createSwipe.test.ts);
    • a value built by hand swipes nothing (Item.test.tsx);
    • importing and calling it in a fresh Node process, with no window or document, does no DOM work (createSwipe.server.test.ts);
    • the runtime serves a role-less row and a listitem, and no option or menuitem* row, disabled row, delegating row, link root or row without entries; it gives every row and every value the same root handlers and layer type.
  • The press hold (red first, pressFeedback.test.ts): a touch on a held surface paints nothing and lights nothing around it; a pressable inside it keeps its own press; a mouse press on it paints nothing; it presses again once the hold is gone. On the row, the hold is written from the release that opens it and freed when it closes, or when the row loses its swipe while open.

  • An inline createSwipe call (spec:AST-057 FR11): a new value keeps a closed row closed and an open or settling row open, fires the newest handler, repaints a changed entry list, creates no second drag and starts no second settle.

  • The deprecated props: a row still rests open and fires a tapped entry, and still slides out under commit; it has the same root style as a row given swipe from its first render, before its chunk loads; the released role gate serves a listitem and not an option; swipe wins, with a development note; no runtime warning on their own; the released commit warning stays. Their tests wait for the deprecated layer's own load, never a clock: a test helper awaits the module the loader brings, inside act, and one test's mock loads it 1.5 s late.

  • The import graph: Item statically imports only swipeHost.ts and the released row style from the Swipe module, and reaches the layer only through DEP-0017's loader; createSwipe brings the layer, and the layer the gesture, statically (Item.source-build.test.mjs, and the touch-support bundle guard).

  • Module attribution (spec:AST-057 FR10). Each public import is built with the size report's esbuild options (minified ESM, code splitting, metafile). Every byte of the JavaScript it loads eagerly, its entry output and the chunks it reaches by static import, is attributed to its source module from the metafile's bytes in output. A module's gzipped share is its part of the eager bytes times the eager output's gzip size. The swipe modules are the gesture core, the animator, the snap-point drag, the panels' rendering and the row's gesture code. The touch-support bundle guard runs this attribution for Item alone and ListItem alone and fails on any byte of a swipe module; Item with createSwipe is its positive control, and a build where Item uses createSwipe fails it. Base (feat(core): createDragToSnapPoints and useDragToSnapPoints settle an element on snap points #8658's head) against head:

    Import Base (gzip) Head (gzip) Swipe modules, head (min / gzip share) swipe handoff and row style, head (min / gzip share) Item's own modules, base → head (min)
    Item alone 13,531 B 13,527 B 0 B / 0 B 134 B / 47 B 7,612 → 7,140 B
    ListItem alone 51,980 B 51,986 B 0 B / 0 B 134 B / 45 B 7,599 → 7,127 B
    Item with createSwipe — 54,606 B 113,005 B / 37,111 B 227 B / 74 B — → 7,140 B
    ./List 52,757 B 52,743 B 0 B / 0 B 134 B / 45 B 7,599 → 7,127 B
    ./CheckboxList 54,875 B 54,848 B 0 B / 0 B 134 B / 45 B 7,597 → 7,120 B
    ./RadioList 50,440 B 50,449 B 0 B / 0 B 134 B / 44 B 7,596 → 7,127 B
    ./DropdownMenu 74,275 B 74,386 B 0 B / 0 B 134 B / 45 B 7,586 → 7,109 B
    ./ContextMenu 75,372 B 75,433 B 0 B / 0 B 134 B / 45 B 7,639 → 7,146 B
    ./MoreMenu 72,474 B 72,597 B 0 B / 0 B 134 B / 45 B 7,590 → 7,113 B
    ./Selector 76,657 B 76,652 B 0 B / 0 B 134 B / 43 B 7,647 → 7,163 B
    ./Table 222,107 B 222,676 B 0 B / 0 B 134 B / 43 B 7,613 → 7,128 B
    ./Breadcrumbs 68,379 B 68,491 B 0 B / 0 B 134 B / 44 B 7,639 → 7,146 B
    ./Pagination 85,883 B 85,719 B 0 B / 0 B 134 B / 43 B 7,631 → 7,148 B
    ./PowerSearch 185,843 B 186,065 B 0 B / 0 B 134 B / 42 B 7,608 → 7,123 B
    ./Markdown 147,227 B 147,193 B 0 B / 0 B 134 B / 45 B 7,635 → 7,139 B
    • No eager output without createSwipe holds a byte of a swipe module. From the Swipe module, each import that renders a row keeps only the swipe handoff (swipeHost.js, 57 B) and the deprecated path's row style (swipeHost.stylex.js, 77 B). Item's own modules shrink by about 470 B.
    • The press controller reads the press hold from privateAttributes/pressHeld.stylex.js, 73 B in every import that renders a row (FR7).
    • The deprecated loader's import() target is its own chunk. Bundling moves the parts of a module that this chunk shares with the eager code into the eager output, so the row importers carry 57 B more of utils/touchQueries.js and 36 B more of the press hold, and Table and PowerSearch also carry about 1,260 B more of motion/uikit/springs.js and 79 B more of motion/uikit/timing.js. Built without that import(), those modules match the base byte for byte. None of them is a swipe module, and the bytes leave with the loader (CLN-0025).
    • ListItem alone is 345 B smaller minified and 6 B larger gzipped.
    • Item.tsx is 944 lines (base 973), and ListItem.tsx 474 (base 475).
  • The codemod: an inline object merged with swipeBehavior; a default swipeBehavior="reveal" dropped; a name guarded, and spread beside a swipeBehavior expression; aliases and namespace imports followed, other libraries left alone; an existing import reused, and a free alias taken; the deprecated props removed from a row that sets swipe; computed values and spreads reported once; mutations of a retyped value's leading or trailing array reported once; the four types renamed; idempotent.

  • Trackpad, WebKit:

    • the row moves one for one on every wheel;
    • the release keeps the fingers' speed with no stop;
    • it never passes rest in one gesture;
    • a long fast swipe fires the outermost action;
    • a fast flick from open settles open;
    • Reduce Motion lands at once on a fine pointer;
    • a click closes a row a trackpad opened and reaches nothing.
    • Chromium's synthetic wheels carry a fixed wheelDelta, which FR43 reads as a mouse wheel's, so the trackpad cases skip there.
  • A mouse wheel's notch never swipes a row (Chromium).

  • Fingers, by real touch in both engines:

    • the tracking spring and its catch-up, read on the curve the row played;
    • UIKit's settle, its keyframes and every frame drawn;
    • a flick hands the row's speed to the settle;
    • a catch;
    • the release rule;
    • the full swipe;
    • the Reduce Motion cross-fades;
    • FR7's closes.
  • A close held back by a later listener's block starts after the block, and every frame drawn lies on its curve: the row's input-driven settles pass start: 'next-frame'.

  • On a loaded page:

    • the frame-by-frame settle proof is judged against the frames the page drew during the settle (its animation-frame times): at least 4 to judge, a new travel on each, each on UIKit's curve. A count at 60 Hz (more than 10) failed on a loaded runner that drew 5. Red first: with the page's main thread busy 150 ms at a time from the lift, the page drew 8 frames; the count fails 3 of 3, and the judgment per frame passes 3 of 3;
    • the accessibility proof's fling stamps its touches with the script's times, as a digitizer stamps a finger's. The gesture reads the touches' own times, but each dispatch returns only once the page has handled the touch, so unstamped, a busy page stretched the dispatches. Red first: with each touchmove costing the page 60 ms, the last two moves and the lift went out 108 ms and 100 ms apart, the lift fell past the velocity filter's 40 ms stillness, and the row closed (3 of 3); stamped, the same run opens it (3 of 3).
  • Unit:

    • the row runs the shared drag and declares its stops there (Item.sharedDrag.test.tsx);
    • the shared drag's internal options, each red first (createSnapDrag.test.ts);
    • a disabled entry stays focusable and inert on an open row (spec:AST-085 FR1, FR2).

Supersedes #8069, #8096, #8097, #8132 and #8134

They were closed in its favor. Each one's tests, changeset and doc changes were checked against this branch:

  • chore(core): swipe actions' UIKit values and release rule #8069 (UIKit values and release rule):

    • swipeActions.ts, swipeRelease.ts, its MEASUREMENTS.md rows and its motion/README.md row are here.
    • Of its 11 test titles, 4 are here unchanged and 1 is renamed (the lab batch numbers became "UIKit lab, iOS 26.2 simulator, Oct 23 2026"). fix(Item): a swipe row's full swipe confirms, runs ahead and fires like UIKit's #8134 replaced the other 6: the commit point became the confirm point, and one release case was reworded. Those replacements are here.
    • Its changeset is here, reworded for that model.
  • fix(Item): swipe actions on the gesture system #8096 (the gesture system):

    • 14 of its 18 test titles are here unchanged.
    • Its test that other recognizers stay inactive under a fine pointer is updated for spec:AST-094 FR43: gestures attach for every pointer.
    • Its two swipe-layer render tests are here: a row given swipe renders its layer at its first render, and a row given the deprecated props renders it once the loader has brought it.
    • Its pins that the layer imports with the row are replaced by pins that createSwipe brings the layer and Item imports none of it.
    • Its changeset is here, with the trackpad added.
  • fix(Item): an open swipe row closes on a tap on its content or a scroll #8132 (an open row's closes): 11 of its 12 test titles are here unchanged; one is renamed for the focus rule ("inside its open panel"). Its changeset is here, reworded the same way.

  • fix(Item): a swipe row's full swipe confirms, runs ahead and fires like UIKit's #8134 (the full swipe):

    • 19 of its 30 test titles are here unchanged.
    • 4 release-rule tests lost their "(FR5: …)" departure notes, and the describe that grouped them is gone, since spec:AST-057 FR5 now records the measured rule.
    • Its swipeCurves unit tests (one describe, four cases) covered per-part curves. Under spec:AST-057 FR2 every part reads one custom property, so those per-part curves no longer exist; the browser proofs check every part against the travel on every frame.
    • Dropped on purpose: its proof that the slide-out advances through a main-thread block, for the reason under the known limitation above.
    • Its changeset is here unchanged.
  • fix(Item): swipe actions move on the compositor with UIKit springs #8097 (swipe actions on the compositor with UIKit springs):

    • 20 of its 31 test titles are here unchanged; 3 are renamed for spec:AST-057 FR2's single travel property ("moves only by transforms that read one travel", "gives the root back its own styles when the swipe actions go", "fires on the next frame when the release is already past the row's edge").
    • Its slide-out fire cases (a slide-out the browser starts late; a pending fire dropped when the row leaves) are covered by the shared drag's settle-passes-offset tests in createSnapDrag.test.ts: it calls back once, on the first frame whose presented value has passed the offset, and never for a settle that lands short, is replaced, or is destroyed.
    • Its per-part tests (swipeParts, one describe and three cases, and the one-clock case) covered per-part transforms, which spec:AST-057 FR2 replaces with one custom property; the browser proofs check every part against the travel on every frame.
    • Dropped on purpose: its compositor change and its proof that the settle advances through a main-thread block (see the known limitation).
    • Its other behavior (UIKit's springs, a flick that opens or closes, Reduce Motion, a catch) is here, with its changeset line minus the compositor sentence.

Scope

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

Testing

  • pnpm exec vitest run packages/core, plus the CLI, registry, codemod and touch-bundle guards.
  • pnpm check:repo and pnpm check:knowledge. packages/core/api-report.md changes by exactly the lines pnpm api:report changes between the base's build and this head's.
  • Interaction proofs: packages/core/src/Item/__tests__/ItemSwipeActions.interaction.browser.spec.ts, with the animator, snap-point drag and gesture specs, in Chromium and WebKit.
  • Accessibility contracts: ItemSwipeActions.a11y.chromium.spec.ts and ItemSwipePinchZoom.a11y.chromium.spec.ts.
  • Sizes: each public import built with the size report's esbuild options from a build of packages/core, base against head.
  • The docsite's preview build (apps/docsite/vercel.json's build command). createSwipe's doc is a new hook page, so the ShadCN route lock (internal/shadcn-registry/routes.lock.json) gains one entry, hook-create-swipe at hooks/create-swipe; no existing name or path moves.

Writing rubric results

Records edited:

  • architecture:react-component-runtime INV13: the known public-API gaps gain the row's jobs, for spec-owner review;

  • spec:AST-057: the contract table's readiness cell and the current-state impact drop the implementation gap this pull request closes; the current-state impact says ListItem passes swipe to its Item, and records the long-task line above;

  • component:ListItem (ListItem.spec.md): its swipe gap goes, and the readiness cell counts the two that remain.

  • R1 narration: none. Each edit states what the system does, in the present tense; the removed lines described what was not yet built.

  • R2 research: none. The long-task measurements stay in this description; the record keeps one sentence.

  • R3 evidence: INV13's addition cites spec:AST-057 AR2, FR2 and FR5; no evidence cell names a pull request.

  • R4 rejected alternatives: none added.

  • R5 change references: none added; the records name no pull request or issue.

  • R6 open questions: none.

@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:29am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 2, 2026, 9:44 AM UTC

@github-actions github-actions Bot added needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval labels Oct 27, 2026

@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 needs a spec decision before it can be approved: spec:AST-057 on main lists two-finger trackpad swipes as a non-goal and its AR2 keeps the swipe off pointers that can hover, while the DEC-7 cited here exists only in the open #8646, whose FR4 would also make a pen swipe, which this change forbids. An engineering owner records that decision by commenting /approve-spec 6df8dd02f0913da1c01e72453a6415dffd3b9cec on this pull request, and the author may do so as an engineering owner. Whatever that decision, two changes are needed:

  • Land or retarget the dependency pull requests, so this diff carries only its own 381 lines instead of 53,279.
  • When a trackpad has opened a row, a click on its content should close it without running the row's action, as a finger's tap already does.

[Automated review]

// A trackpad's horizontal scroll swipes the row as a finger does, and
// a mouse wheel's horizontal notches the same way (`spec:AST-057`
// DEC-7): the same contact, moving with the content.
allowedScrollTypes: ['continuous', 'discrete'],

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.

Spec hold: spec:AST-057 on main lists two-finger trackpad swipes as a non-goal (line 138), and its AR2 keeps the gesture's paint, tab order and accessibility tree untouched on a pointer that can hover (lines 250–252). The DEC-7 cited here exists only in the open #8646, so this opt-in waits on an engineering owner's decision on that record.

rootRef,
() =>
new TapGestureRecognizer({
allowedPointerTypes: ['touch'],

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.

Now that a trackpad can open a row, this dismissal still takes only a finger. In Chromium, a click on the content of a trackpad-opened row reaches the row's onClick and the row stays open, while a finger's tap closes it and activates nothing (Item.test.tsx:1178). Letting any pointer's press dismiss while the row is open, and swallowing its click through the click guard, would match the finger path; a test for a wheel-opened row would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 08e6916: the dismissing tap now takes every pointer and the click guard swallows the click, so a mouse click on a trackpad-opened row closes it; tests added in both engines.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

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


Generated by PR Enrichment workflow | View current CI run

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026

@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 needs a spec decision before it can be approved: spec:AST-057 on main lists two-finger trackpad swipes as a non-goal and keeps the swipe off pointers that can hover (AR2, DEC-4), while the DEC-7 cited here exists only in the open #8646, whose FR4 would also make a pen swipe, which this change forbids. An engineering owner records that decision by landing the spec:AST-057 amendment with the pen settled, or ruling otherwise, and commenting /approve-spec 08e6916d0e698009bca4d50efdd10c6824a0c381 on this pull request; the author may do so as an engineering owner. Whatever that decision, two changes are needed:

  • Land or retarget the dependency pull requests, so this diff carries its own 458 lines instead of 53,360.
  • Under Reduce Motion on a fine pointer, a trackpad or wheel swipe's settle should complete at once, as design:reduced-motion DR3 requires, instead of cross-fading like a touch release (useSwipeAction.ts:777, :834).

[Automated review]

Comment on lines +775 to +777
// Under Reduce Motion the animator cross-fades or lands at once
// (design:reduced-motion); what a release started always cross-fades.
const touchRelease = cause === 'release';

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.

Under Reduce Motion this treats every release as a touch release, so when a trackpad or wheel gesture ends on a fine primary pointer the row's settle cross-fades for up to 200 ms. design:reduced-motion DR3 requires any other transition on a fine pointer to complete at once (DR2's exception covers only the release of a touch drag), and #8646's FR5 asks the same of a trackpad-started swipe. Passing touchRelease only when a finger's contact ended the drag, here and in the slide-out fade at line 834, with a Reduce Motion test for a wheel-opened row, would fix it.

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

The trackpad, wheel and pen swipe now matches spec:AST-057 on main (FR4, FR5, DEC-7), but approval needs an engineering owner’s decision on two things this head adds without current spec: the new component:BottomSheet element job for the input hold’s shield (BottomSheet.spec.md, lines 232–236), and whether a mouse or pen press on an open row’s own content should close it and activate nothing (useSwipeAction.ts, lines 1308–1332). An engineering owner records that decision by commenting /approve-spec bfef65e146dc5a8089b53dd84dd6c6dfafb1d1bd on this pull request, or by ruling otherwise in a comment; the author may do so as an engineering owner. Whatever that decision, two changes are needed:

  • Land or retarget the dependency pull requests, so this diff carries its own 637 lines instead of 54,294.
  • In the swipeActions doc and JSDoc, say a swipe-only verb is unreachable “by keyboard and by a mouse drag” and that an open row closes on a press elsewhere (Item.doc.mjs:202, Item.tsx:280 and :283).

[Automated review]

rootRef,
() =>
new TapGestureRecognizer({
name: 'item-swipe-dismiss',

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.

Spec hold: with no pointer gate, a mouse or pen press on an open row’s own content now closes it and its click is swallowed. spec:AST-057 on main states no content-press dismissal for any pointer (FR7 lists a pointer outside, Escape, firing an entry and unmount; DEC-7 covers reveal and fire), so this waits on an engineering owner’s decision.

Comment thread packages/core/src/Item/Item.tsx Outdated
* the side's panel; a mouse never starts the drag, and a mostly vertical
* drag stays the scroller's. The gesture is
* a touch accelerator and never a row's only path to a verb: a verb
* reachable only by swipe is unreachable by keyboard and by mouse, so every

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 mouse’s horizontal wheel now reaches a swipe-only verb (DEC-7), and a press from any pointer outside closes an open row (FR7). Saying “unreachable by keyboard and by a mouse drag”, as spec:AST-057 asks the docs to, and “on a press elsewhere” on line 283 would match; the same two phrases are in Item.doc.mjs:202.

Comment on lines +232 to +236
- while a modal sheet enters under a finger, the layer's input hold adds a
shield inside the dialog: a transparent cover of the viewport that takes
a tap or a drag landing during the entrance (`spec:AST-094` FR35). It is
removed when the sheet lands. A non-modal sheet adds no shield: it holds
only input on itself, and the page beside it stays live.

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.

Spec hold: this adds a named element job that the record on main does not have, and its verification row fails an element in the open sheet’s frame with no named job. An engineering owner approves or rejects it, ideally with the pull request that adds the shield.

@vjeux vjeux changed the title feat(Item): a trackpad's horizontal scroll swipes a row as a finger does, and a mouse drag never does (spec:AST-057 DEC-7, spec:AST-094 FR43) feat(Item): a pen or a trackpad's horizontal scroll swipes a row as a finger does, and a mouse drag never does (spec:AST-057 FR4, DEC-7; spec:AST-094 FR43) Oct 27, 2026

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

The pen, trackpad and wheel swipe matches spec:AST-057 on main (FR4, FR5, DEC-7), but approval needs an engineering owner's decision on two things this head adds without current spec: whether a mouse or pen press on an open row's own content should close it and activate nothing (useSwipeAction.ts, lines 1308–1333), and the new component:BottomSheet element job for the input hold's shield (BottomSheet.spec.md, lines 232–236). An engineering owner records that decision by commenting /approve-spec 90bf79fdcdaa1bba8793bb0ba4d610bae4c3977b on this pull request, or by ruling otherwise in a comment; the author may do so as an engineering owner. Whatever that decision, three changes are needed:

  • Land or retarget the dependency pull requests, so this diff carries its own 644 lines instead of 54,742.
  • In the swipeActions doc and JSDoc, say a swipe-only verb is unreachable "by keyboard and by a mouse drag" and that an open row closes on a press elsewhere (Item.tsx lines 280 and 283, Item.doc.mjs lines 202 and 257).
  • Add the physical-device check spec:AST-057's verification asks for: a real trackpad (a slow swipe and a quick flick) and a real pen on the swipe-actions story.

[Automated review]

rootRef,
() =>
new TapGestureRecognizer({
name: 'item-swipe-dismiss',

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.

Spec hold: with no pointer gate, a mouse or pen press on an open row's own content now closes it and its click is swallowed. spec:AST-057 on main states no content-press dismissal for any pointer (FR7 lists a pointer outside, Escape, firing an entry and unmount; AR3 keeps the primary's activation), so this waits on an engineering owner's decision.

Comment thread packages/core/src/Item/Item.tsx Outdated
* the side's panel; a mouse never starts the drag, and a mostly vertical
* drag stays the scroller's. The gesture is
* a touch accelerator and never a row's only path to a verb: a verb
* reachable only by swipe is unreachable by keyboard and by mouse, so every

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 mouse's horizontal wheel now reaches a swipe-only verb (DEC-7). spec:AST-057 asks the docs to say "unreachable by keyboard and by a mouse drag"; the same phrase is in Item.doc.mjs:202 and :257.

Comment thread packages/core/src/Item/Item.tsx Outdated
* entry must also be reachable through the row's own content (a menu or a
* button in `endContent`).
* button in `endContent`). An open row closes on a tap on its own content,
* which activates nothing and paints no press, on a touch elsewhere, when

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.

FR7 closes an open row on "a pointer landing anywhere outside it", which this head does for every pointer; "on a press elsewhere" would match here and in Item.doc.mjs:202.

Comment on lines +232 to +236
- while a modal sheet enters under a finger, the layer's input hold adds a
shield inside the dialog: a transparent cover of the viewport that takes
a tap or a drag landing during the entrance (`spec:AST-094` FR35). It is
removed when the sheet lands. A non-modal sheet adds no shield: it holds
only input on itself, and the page beside it stays live.

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.

Spec hold: this names an element job the record on main does not have, and its verification row fails an element in the open sheet's frame with no named job. An engineering owner approves or rejects it, ideally with the pull request that adds the shield.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
… the input drivers helper 4c0e0a5) into meta#8690

# Conflicts:
#	packages/core/src/Item/useSwipeAction.ts
#	packages/core/src/motion/README.md

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

The pen, trackpad and wheel swipe matches spec:AST-057 on main (FR4–FR6, DEC-7), but approval needs an engineering owner's decision on three spec deltas this head carries without current authority: a mouse or pen press on an open row's own content closing it without activating anything (useSwipeAction.ts, lines 1300–1324), the new component:BottomSheet element job for the entrance shield (BottomSheet.spec.md, lines 233–237), and the new spec:AST-094 FR41 rule for claimed mouse and pen drags (docs/specs/AST-094-touch-presentations/spec.md, lines 471–475). An engineering owner records that decision by commenting /approve-spec 6c08e005cc6ac6102d2a33aba151779fa96a843a on this pull request, or by ruling otherwise in a comment; the author may do so as an engineering owner. Whatever that decision, three changes are needed:

  • Land or retarget the dependency pull requests, so this diff carries its own 596 lines instead of 54,299.
  • In the swipeActions JSDoc and doc, say a swipe-only verb is unreachable “by keyboard and by a mouse drag” and that an open row closes on a press elsewhere (Item.tsx lines 280 and 283, Item.doc.mjs lines 202 and 257).
  • Add the physical-device check spec:AST-057's verification asks for: a real trackpad (a slow swipe and a quick flick) and a real pen on the swipe-actions story.

[Automated review]

rootRef,
() =>
new TapGestureRecognizer({
name: 'item-swipe-dismiss',

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.

Spec hold: with no pointer gate, a mouse or pen press on an open row's own content now closes it and its click is swallowed. spec:AST-057 on main states no content-press dismissal for any pointer (FR7 lists a pointer outside, Escape, firing an entry and unmount; AR3 keeps the primary's activation), so this waits on an engineering owner's decision.

Comment thread packages/core/src/Item/Item.tsx Outdated
* the side's panel; a mouse never starts the drag, and a mostly vertical
* drag stays the scroller's. The gesture is
* a touch accelerator and never a row's only path to a verb: a verb
* reachable only by swipe is unreachable by keyboard and by mouse, so every

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 mouse's horizontal wheel now reaches a swipe-only verb (spec:AST-057 DEC-7), and the record asks the docs to say “unreachable by keyboard and by a mouse drag”; the same phrase is in Item.doc.mjs (the swipeActions description and the guidance).

Comment thread packages/core/src/Item/Item.tsx Outdated
* entry must also be reachable through the row's own content (a menu or a
* button in `endContent`).
* button in `endContent`). An open row closes on a tap on its own content,
* which activates nothing and paints no press, on a touch elsewhere, when

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.

FR7 closes an open row on “a pointer landing anywhere outside it”, which this head does for every pointer; “on a press elsewhere” would match here and in Item.doc.mjs.

Comment on lines +233 to +237
- while a modal sheet enters under a finger, the layer's input hold adds a
shield inside the dialog: a transparent cover of the viewport that takes
a tap or a drag landing during the entrance (`spec:AST-094` FR35). It is
removed when the sheet lands. A non-modal sheet adds no shield: it holds
only input on itself, and the page beside it stays live.

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.

Spec hold: this names an element job the record on main does not have, and its verification row fails an element in the open sheet's frame with no named job. An engineering owner approves or rejects it, ideally with the pull request that adds the shield; the bullet also leaves the shield's accessibility exposure unstated.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Spec hold: FR41 on main says a mouse “otherwise keeps its released behavior”; this adds a rule for drags a recognizer claims (no new selection, a later claim clearing the page's selection, a claim at pointerdown keeping it) and a verification row for it, which waits on an engineering owner's decision, ideally in the pull request that adds the every-pointer host.

vjeux added 18 commits November 1, 2026 22:19
…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.
The public animator (spec:AST-107 FR3, FR6): createAnimator(element) over
the internal animator, with animate, decay, set, get, getVelocity, stop and
destroy, from the package root; and useAnimator(ref), which takes the
element's ref like the gesture hooks, so a gesture hook can share the
element, and returns the animator. After every commit the hook follows the
element behind the ref. The internal factory is renamed
createMotionAnimator, so no internal factory shares a public factory's
name. Docs, a changeset, and playing-cards templates in React and plain
JavaScript, with their registrations.
…s (spec:AST-107 FR1, FR6)

The public pan: `createPanGesture(element, {axis?, onPan}) → {destroy}` and
`usePanGesture(ref, {axis?, onPan})` over it, reporting
`{state, translation, velocity}` as a drag begins, changes, ends or is
cancelled, under a finger, a mouse and a pen; the translation leaves out the
travel the drag needed to begin, so an element that follows it does not
jump. Docs pages for both, with a playing-card example in plain JavaScript
and in React, the route lock and the sandbox's routes.
…element on snap points

`createDragToSnapPoints(element, {axis, snapPoints, flickThreshold?,
onSettle}) → {settleTo, destroy}` and `useDragToSnapPoints(ref, …)` over it
(spec:AST-107 FR2, FR6): a drag moves the element along its axis under every
pointer, and the release settles it where Core's one release policy says
(the nearest point; the next one past the flick speed, 1008 px/s by default;
back to where the drag started when the release moves back toward it), on
the settle spring from the release velocity. Under Reduce Motion a touch
release cross-fades to its point and any other lands at once. Docs pages and
playing-card examples for both, the route lock and the sandbox's routes.
…n an input event's own task

A caller handling an input event (a key, a click, a pointer, a touch) runs
in that event's own task, never in a frame callback, so the first frame
whose callbacks run after its call is the one that commits the call's
animations, however long the task ran. The animator cannot tell that from
a call a page's own frame callback makes before a busy task, whose browser
start is right, so the caller says so with the internal option
`start: 'next-frame'`; a call from rest made with it starts at that frame,
and a later listener of the same event that holds the main thread delays
the motion instead of letting it skip into its curve.

- Chromium, which tells the page the start a frame or more after the
  compositor takes it: such a call starts at its first frame, as a call
  whose first frame comes within LATE_START_MS already does, and also when
  an unrelated task delayed that frame.
- WebKit, which dates the start at its last frame time before the task: the
  task's end is read at the call's first read, not at its first microtask,
  which runs before a later listener's block, so the dated start moves past
  the block. The first read stands for the task's end, so a first frame
  that merely comes late (more than LATE_START_MS after the call, with no
  block) moves the dated start to that frame too: nothing was painted
  before it, so the motion is delayed by that slow frame instead of
  skipping into its curve.

A call without the option, a call with an explicit start, and a call that
continues motion keep the rule they had. Internal only: the public animator
does not take it.
…al options)

The public pan and snap-point drag keep their behavior (spec:AST-107 FR1,
FR2) and now run on internal factories a Core component can use for a job
the public layer does not expose (spec:AST-107 FR7):

- createPan, under createPanGesture: the recognizer's own options (which
  pointers and scrolls start the drag, its claim distance, a begin gate, a
  selection lock), and what drove the drag and its travel from where the
  touch began in each report.
- createSnapDrag, under createDragToSnapPoints: a model of where a drag
  draws its element (one for one, a spring, or nothing yet inside a dead
  zone, the first target catching a settle in flight) and where a release
  or a cancel lands; a custom property to draw into; settleTo with a
  spring, a velocity and a touch release, returning the settle's motion.
- The animator draws x into a registered custom property (a length or a
  number), on the main thread, for a component whose parts all read one
  value.

architecture:react-component-runtime INV13 names these jobs among the known
public-API gaps.
…set, and a companion follows its travel (internal)

Two more internal options on the shared drag path, for a component whose
own work is timed by its drag (spec:AST-107 FR7):

- settleTo({onPass: {offset, call}}) calls back once, on the first
  animation frame whose presented value, read on the settle's own curve at
  that frame's time, has passed the offset in the settle's direction (on
  the next frame when it is there already). No timer: a settle that lands
  short, that another settle or a drag replaces, or that a destroy ends
  never calls back; a crossing during a drag is the caller's to read from
  the drag.
- The animator's custom-property channel takes a companion: a second
  custom property drawn from the travel by a pure mapping, written in the
  same keyframes and writes, so it has one start and one curve with the
  travel, follows every retarget, and takes whatever Reduce Motion does to
  the travel.
…llows the fingers (spec:AST-094 FR43, DEC-24; spec:AST-057)

A row's swipe runs the one shared path every followed pan runs: the
trackpad splitter, the pan, the snap-point drag and the animator. The row
declares its stops (rest, the open width, and out past its edge for a full
swipe) and its actions; the drag draws the row's travel into one custom
property that every part of the panel reads (spec:AST-057 FR2).

- A trackpad's swipe moves the row one for one with the fingers from its
  first event past the pan's 8 px start, and its release settles to the
  nearest stop by position and velocity (Core's one release policy) on the
  shared settle spring, from the fingers' speed at their last move, so the
  row never stops before it settles. Within one gesture an open row only
  closes, never passing rest to its other side.
- A finger's or a pen's drag stays on spec:AST-057's UIKit model: UIKit's
  25 px dead zone, its tracking spring, its release rule and settle spring
  from the row's own speed.
- A full swipe confirms by position only and fires the outermost action at
  any speed; speed alone never fires an entry (spec:AST-057 FR5), so a fast
  flick from an open row settles open.
- A mouse drag and a mouse wheel's notches never swipe a row; a tap or a
  click on an open row's content closes it and reaches nothing.

Interaction proofs: trackpad cases in WebKit (Chromium's synthetic wheels
read as a mouse wheel's), a mouse wheel's notch in Chromium, fingers by
real touch in both engines; the swipe actions' UIKit constants and their
measurements (UIKit lab, iOS 26.2 simulator, Oct 23 2026).
…s at the frame after it

The row's settles asked for in an input event's own task pass the
animator's start: 'next-frame' through the shared drag (settleTo's start):
Escape, a press outside the row, the dismissing tap on its content, and an
entry's activation. Each then starts at the first frame after that task, so
a later listener of the same event that holds the main thread delays the
close instead of letting it skip into its curve. A scroll's close and every
settle a drag's release starts keep the default.
…ke one swipe prop (spec:AST-057 DEC-8)

`Item` and `ListItem` take their swipe actions as one prop, `swipe`, whose
value `createSwipe({leading?, trailing?, swipeBehavior?})` makes (FR1). The
constructor's module, `@astryxdesign/core/Swipe` (also re-exported from the
root), carries every module the swipe runs on: the panels
(SwipeActionsLayer, moved from Item), the row's gesture code
(useSwipeAction and swipeRelease, moved from Item), and through them the
shared drag path and the animator (FR10). `Swipe` is opaque: a frozen
brand with nothing to read, whose host only createSwipe records.
Importing or calling createSwipe does no DOM work.

For a row given `swipe`, Item holds no swipe code: it reads the value
through swipeHost.ts, a reader with no imports, hands the runtime the value
carries its root and the facts it already has (role, disabled, delegation,
link root), and renders the root style, root handlers and layer it gets
back. The runtime decides which rows it serves (DEC-3); routes the root's
keys to the row's swipe through a registry keyed on the root element, with
the same handlers for every row and value, so a new value from an inline
call keeps the gesture and an open row as they are (FR11); and holds the
root's press while the row is open with a private attribute
(privateAttributes/pressHeld.stylex.ts) that the press controller reads as
a disabled surface: it takes the press and paints nothing, a pressable
inside keeps its own, and the touch still counts for the brake (FR7).
`swipeBehavior: 'commit'` with several entries on a side warns in
development (FR3).

The released `swipeActions` and `swipeBehavior` props and the four
`ItemSwipe*` types are deprecated (DEP-0017): static `@deprecated`
metadata, no runtime warning, and `swipe` wins when both are given, with a
development note. They keep their released behavior through DEP-0017's
deprecated path, the only swipe code in Item and on that path only: the
released loader (the one `import()` of swipe code, whose layer brings the
released commit warning), the released row style from the first render
(`spec:AST-017` DEC-17) and role gate, the bridge the loaded layer fills
with the key forwarder into it, and the coexistence diagnostic. It leaves
with the props in 0.8.0 (CLN-0025). The touch-support bundle guard names
the loader and counts its chunk out of Item's import (without it, the 13
row importers would carry the gesture core); `Swipe` joins the imports
that carry the gesture core.

An `astryx upgrade` codemod staged in transforms/next rewrites static
`swipeActions` and `swipeBehavior` on Item and ListItem to
`swipe={createSwipe(…)}`, adds the import, renames the four types, drops a
merged `swipeBehavior="reveal"`, and reports computed values, every row
that spreads props, and code that mutates the leading or trailing array of
a value it retypes as `CreateSwipeOptions` (`spec:AST-017` FR29c, FR29d).
Docs, the story and the changesets teach only `swipe`.

Size (the PR size report's options, gzipped, base 0a00169, whose Item
and List match main's at d96d470): `Item` alone 13,471 B (base 13,482),
`ListItem` alone 58,856 B (base 58,894), `./Swipe` 46,852 B; `Item.tsx`
935 lines (base 964), `ListItem.tsx` 474 (base 475).
…module (spec:AST-057 FR10)

FR10's module attribution in the touch-support bundle guard: each import
is built with the size report's options and code splitting, and every byte
of its entry output and the chunks it reaches by static import is
attributed to its source module from the metafile. Item alone and
ListItem alone hold no byte of a swipe module (the gesture core, the
animator, the snap-point drag, the panels' rendering, the row's gesture
code and its UIKit table); the reader of a swipe value and DEP-0017's row
style are not swipe modules, and the deprecated loader's chunk is outside
what loads at once. Item with createSwipe holds them, so the check finds
what it looks for.
…ask's freeze

AST-057's current state and contract table no longer list swipe and
createSwipe as an implementation gap, and ListItem passes swipe to its
Item; ListItem's record drops its swipe gap (two remain). AST-057's
current state records that a long main-thread task during a drag or
settle freezes the row, then it jumps, as the released swipe does.
…w, not a count at 60 Hz

"the settle after a held release is UIKit's spring, frame by frame"
required more than 10 frames with a new travel during the settle, a count
that holds at 60 Hz; on a loaded runner the page drew 5, and the test
failed with every frame it drew on the curve. The frame recorder now
logs every frame the page draws (its animation-frame times, the same time
for a frame with or without a new travel), and the test needs at least 4
frames drawn during the settle to judge, and a new travel on each of them,
each on the curve as before.
… as a digitizer stamps a finger's

The proof that a fling short of the commit point opens the row sent its
touches with Input.dispatchTouchEvent and no timestamp. Each dispatch
returns only once the page has handled the touch, so on a loaded page the
quick last moves and the lift went out late: in a run with each touchmove
costing the page 60 ms, the last two moves and the lift were stamped
108 ms and 100 ms apart instead of together, the lift fell past the
velocity filter's 40 ms stillness, and the release read as still and
closed the row. The gesture reads the touches' own times; the touches
now carry the script's times (each also waits out its gap in real time,
so no stamp is ahead of the page), and the same busy run opens the row.
@vjeux vjeux changed the title feat(Item): rows swipe on the shared drag path; a trackpad's swipe follows the fingers (spec:AST-094 FR43, DEC-24; spec:AST-057) feat(core): swipe actions come from createSwipe and run on the shared drag path; a trackpad's swipe follows the fingers (spec:AST-057 DEC-8; spec:AST-094 FR43, DEC-24) Nov 2, 2026
@vjeux
vjeux force-pushed the uikit/c12-swipe-every-pointer branch from eb34463 to 2f7567f Compare November 2, 2026 08:40

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

Approval waits on an engineering owner's spec decision, and some changes are needed either way. An engineering owner records that decision by commenting /approve-spec 2f7567fe6c17fa37ea31db67dd5e7a76e4d7fcf3 (the author may do so as an engineering owner) or rules otherwise. Three points need it: whether a mouse, pen or trackpad press on an open row's content should close it (spec:AST-057 FR7 and AR3 name a touch tap), the two new INV13 gap entries in architecture:react-component-runtime, and whether spec:AST-094 FR43 or spec:AST-057 FR4 and DEC-7 governs a mouse's horizontal wheel.

  • The diff is 20,249 authored lines, including five open dependency pull requests; land or retarget them, then split the rest.
  • The row's settle now animates a custom property on the main thread, which spec:AST-094 FR5 rules out, and the new spec:AST-057 line says main behaves the same although main settled with a compositor transform transition.
  • The open row adds its own document pointerdown and scroll listeners beside subscribeInput; subscribe through it when the row opens instead.
  • spec:AST-057 asks for a physical-device check of FR5 and FR6, item-swipe-gesture-system.md claims a finger fix that already worked on main, and the docs say an open row closes "on a touch elsewhere" where any pointer closes it.

[Automated review]

// UIKit's dismissal does: the tap recognizes at the lift, and the host
// keeps the click it would have made from the row (it cancels the touch,
// or where the primary pointer is fine its click guard swallows the
// click). Any pointer's press dismisses, since a trackpad opens a row too

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.

Spec hold: any pointer's press on an open row's content now closes it and its click is swallowed. spec:AST-057 FR7 and AR3 name only a touch tap, so this waits on an engineering owner's decision.

close();
}
};
document.addEventListener('pointerdown', onOutside, true);

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 document listeners sit beside subscribeInput (utils/inputStream.ts), the one entry for document-wide input in core (architecture:react-component-runtime INV16). The work is caused by the open transition, which setSwipeState already handles for the press hold. Subscribe there through subscribeInput and drop the Effect.

* element whose parts all read one value (a row's swipe travel,
* `spec:AST-057` FR2): a `<length>` in px, or a plain `<number>`. The
* property is registered as inherited so it interpolates, and it animates
* on the main thread, not the compositor; every other channel renders as

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 row's settle drawn into this custom property no longer advances through a main-thread block, which spec:AST-094 FR5 requires of every release. On main the root settled with a transform transition that the compositor runs.

`swipe` prop is not built, on `Item` or on `ListItem`.
- `ListItem` passes `swipe` to its `Item`, and the released `swipeActions`
and `swipeBehavior`, deprecated by DEP-0017, through the same passthrough.
- A long main-thread task during a drag or settle freezes the row, then it

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.

On main the row's root settles with a transform transition, which keeps moving through a long task, so "the released swipe behaves the same" does not hold for a settle.

'@astryxdesign/core': patch
---

[fix] Swipe actions on `Item` and `ListItem` run on the gesture system. A row claims a sideways touch at 8 px, before a browser commits to scrolling it. Its swipe begins at UIKit's 25 px, from 0, so a short wobble no longer moves the row. A finger or a pen swipes the row whatever the primary pointer, a touchscreen laptop's included, and a mouse drag never does. A second finger that lands before the claim leaves the touch to the browser's pinch; once the row has claimed its finger, a second finger no longer cancels the swipe. The click a browser synthesizes after a swipe is cancelled at its source instead of being swallowed by a 400 ms timer.

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.

On main a finger already swiped the row whatever the primary pointer: the gate was pointerType !== 'touch' alone and the layer passed isEnabled: true. Suggested wording: "A pen now swipes the row as a finger does."

* unreachable by keyboard and by mouse, so every entry must also be
* reachable through the row's own content (a menu or a button in
* `endContent`). An open row closes on a tap on its own content, which
* activates nothing and paints no press, on a touch elsewhere, when the

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.

Any pointer landing outside closes an open row (onOutside in Swipe/useSwipeAction.ts), as spec:AST-057 FR7 says. "on a press elsewhere" would match, here and in Item.doc.mjs.

vjeux added 2 commits November 2, 2026 01:12
createSwipe's doc is a new public hook page, so the docsite's generated
ShadCN registry gains one item, hook-create-swipe at hooks/create-swipe,
and the docsite build refuses a registry that differs from the reviewed
route lock. The lock gains that one entry; no existing name or path moves.
The docsite's MCP route indexes doc keywords in a plain object, so the
keyword "constructor" read Object.prototype.constructor as an existing
entry and the route threw at load ("includes is not a function"),
failing the docsite build. The keyword added nothing "swipe" does not
find; it goes.

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

Approval waits on an engineering owner's spec decision, and some changes are needed either way. An engineering owner records that decision by commenting /approve-spec e870248ecc40ef36a28bd41753c59feb2457a527 (the author may do so as an engineering owner) or rules otherwise. Three points need it: whether a mouse, pen or trackpad press on an open row's content should close it (spec:AST-057 FR7 and AR3 name a touch tap), the two new INV13 gap entries in architecture:react-component-runtime, and whether spec:AST-094 FR43 or spec:AST-057 FR4 and DEC-7 governs a mouse's horizontal wheel.

  • The diff is 20,253 authored lines, including five open dependency pull requests; land or retarget them, then split the rest.
  • The row's settle now animates a custom property on the main thread, which spec:AST-094 FR5 rules out, and the new spec:AST-057 line says main behaves the same although main settled with a compositor transform transition.
  • The open row adds its own document pointerdown and scroll listeners beside subscribeInput; subscribe through it when the row opens instead.
  • spec:AST-057 asks for a physical-device check of FR5 and FR6, item-swipe-gesture-system.md claims a finger fix that already worked on main, and the docs say an open row closes "on a touch elsewhere" where any pointer closes it.

[Automated review]

Comment on lines +994 to +1012
useGestureRecognizer(
rootRef,
() =>
new TapGestureRecognizer({
name: 'item-swipe-dismiss',
shouldReceiveTouch: (_tap, touch) =>
isOpen() &&
!(
touch.target instanceof Element &&
touch.target.closest('[data-swipe-panel]') != null
),
}),
tap => {
// Recognized at the lift, in its pointer event's own task.
if (tap.state === 'ended') {
close('next-frame');
}
},
);

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.

Spec hold: any pointer's press on an open row's content now closes it and its click is swallowed. spec:AST-057 FR7 and AR3 name only a touch tap, so this waits on an engineering owner's decision.

Comment on lines +976 to +980
document.addEventListener('pointerdown', onOutside, true);
document.addEventListener('scroll', onScroll, {
capture: true,
passive: true,
});

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 document listeners sit beside subscribeInput (utils/inputStream.ts), the one entry for document-wide input in core (architecture:react-component-runtime INV16). The work is caused by the open transition, which setSwipeState already handles for the press hold. Subscribe there through subscribeInput when the row opens, unsubscribe when it closes, and drop this Effect.

Comment on lines +344 to +345
* property is registered as inherited so it interpolates, and it animates
* on the main thread, not the compositor; every other channel renders as

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 row's settle drawn into this custom property no longer advances through a main-thread block, which spec:AST-094 FR5 requires of every release. On main the root settled with a transform transition that the compositor runs.

Comment on lines +311 to +312
- A long main-thread task during a drag or settle freezes the row, then it
jumps; the released swipe behaves the same.

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.

On main the row's root settles with a transform transition, which keeps moving through a long task, so "the released swipe behaves the same" does not hold for a settle.

'@astryxdesign/core': patch
---

[fix] Swipe actions on `Item` and `ListItem` run on the gesture system. A row claims a sideways touch at 8 px, before a browser commits to scrolling it. Its swipe begins at UIKit's 25 px, from 0, so a short wobble no longer moves the row. A finger or a pen swipes the row whatever the primary pointer, a touchscreen laptop's included, and a mouse drag never does. A second finger that lands before the claim leaves the touch to the browser's pinch; once the row has claimed its finger, a second finger no longer cancels the swipe. The click a browser synthesizes after a swipe is cancelled at its source instead of being swallowed by a 400 ms timer.

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.

On main a finger already swiped the row whatever the primary pointer: the gate was pointerType !== 'touch' alone and the layer passed isEnabled: true. Suggested wording: "A pen now swipes the row as a finger does."

* unreachable by keyboard and by mouse, so every entry must also be
* reachable through the row's own content (a menu or a button in
* `endContent`). An open row closes on a tap on its own content, which
* activates nothing and paints no press, on a touch elsewhere, when the

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.

Any pointer landing outside closes an open row (onOutside in Swipe/useSwipeAction.ts), as spec:AST-057 FR7 says. "on a press elsewhere" would match, here and in Item.doc.mjs.

This branch was successfully deployed

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

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant