Skip to content

feat(core): code outside a component relates to its gesture recognizers by name - #8261

Closed
vjeux wants to merge 87 commits into
meta:mainfrom
vjeux:uikit/f2-gesture-delegates
Closed

vjeux wants to merge 87 commits into
meta:mainfrom
vjeux:uikit/f2-gesture-delegates

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Update: no port after all. Closing: this API's audience was app code, which AST-107 FR4 keeps internal for now, and no component needs it yet. If you want it public, say so and I'll open a fresh pull request on current main.

Depends on #8076 (and its predecessors, at a71f4e5); review the last commit.

User need

Code that composes components can attach its own recognizers, but a component's recognizers are hidden. So a pan inside a sheet always beats the sheet's drag, because the innermost wins, and nothing can stop a component gesture (no back swipe while a form is dirty). The two conflict with no way to say who waits.

Problem and solution fit

UIKit solves this with named, exposed recognizers and the app's own delegate. This brings it over:

  • components publish their main recognizers under stable Component.role names (PUBLISHED_GESTURES);
  • gestureNamed(name, {within, containing}) matches them, telling instances apart by their element; it's evaluated when a touch asks, so remounts need nothing;
  • your own recognizer relates through its existing delegate options;
  • addGestureDelegate / useGestureDelegate add delegate methods to recognizers you don't own. They're additive: the component's delegate still runs, any false veto wins, any true relationship applies.

Current specification

spec:AST-094 FR15 (arbitration like UIKit's). The names and API are proposed; pending owner review.

Public delta and caller burden

// Before: impossible. After: a canvas pan in a sheet waits for the sheet.
new PanGestureRecognizer({
  shouldRequireFailureOf: (_, other) =>
    gestureNamed('BottomSheet.drag', {containing: canvasRef})(other),
});
useGestureDelegate(gestureNamed('NavigationStack.backSwipe', {within: stackRef}),
  {shouldBegin: () => !isDirty});

v1 names: NavigationStack.backSwipe, .contentBackSwipe, Item.swipe, Lightbox.dismissPan, .zoomPan, .zoomPinch, Carousel.pager, Toast.swipe, BottomSheet.drag. Each component sets its name in its own pull request. Nothing changes for code that declares nothing. Internal only: exposing it to apps needs its own spec decision.

Design and alternatives

Rejected: exposing recognizer instances (couples apps to component internals and remounts), and a single replaceable delegate as in UIKit (one app's delegate would erase a component's rules).

Evidence

Chromium, a stand-in sheet named BottomSheet.drag; left is the default, right the declaration:

A: app pan in a sheet waits for it B: the sheet waits for an app pan around it C: veto while editing
A: the app's dot follows by default; declared, the sheet drags B: the sheet drags by default; declared, the app's dot moves C: the sheet drags by default; vetoed, it stays

The same three run in WebKit. Units cover each relation both ways, both vetoes (first touch included), simultaneity, the owner's veto winning, removal, scopes and the hook's lifetime.

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

  • Restacked on feat(core): the internal touch foundation: press tracking and the animator #8076 @a71f4e51 (motion and gestures internal): tsc for core and lab, changesets, sync-exports and check:sync clean.

  • pnpm exec vitest run --project ui packages/core/src/gestures packages/core/src/motion packages/core/src/utils: 62 files, 1,118 tests passed.

  • pnpm exec vitest run packages/cli/foundation/doc-compiler packages/cli/foundation/discovery packages/cli/test/experimental-isolation.test.mjs packages/cli/test/authoring-self-docs-match-types.test.mjs: 49 files, 1,292 tests passed (the search experimental goldens gain only useGestureDelegate).

  • pnpm exec playwright test --config packages/core/src/gestures/__tests__/browser/playwright.config.ts --project chromium: 16 passed; WebKit in mcr.microsoft.com/playwright:v1.61.1-noble, same config: 13 passed, 3 skipped (Chromium-only scroll cases).

  • tsc --project packages/core/tsconfig.json --noEmit, ASTRYX_STRICT_LINT=1 eslint packages/core/src/gestures: clean.

  • pnpm check:changesets, changeset coverage for this commit, pnpm check:sync, pnpm check:use-client, pnpm sync:exports:check: clean. ShadCN route lock regenerated (UPDATE_SHADCN_ROUTE_LOCK=1).

  • Rebased onto feat(core): the internal touch foundation: press tracking and the animator #8076 at eaf2189 (its predecessor's CLI changeset fix); every check above re-ran green, and changeset coverage from main now passes.

  • Size: 17 files, 1,287 lines added, 204 removed (about 200 move from gestures.browser.spec.ts into the shared drivers.ts).

Stack (merge in this order): #8052 spec → … → #8074 gesture system → #8076 internal motion and gestures → this pull request (beside the gesture line #8270 → #8309 → #8315 → #8336 → #8320 on #8076).

vjeux added 25 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A file that reads only defineConsts values from a .stylex.ts module lost
that import under the legacy {stylexOptions} form Storybook uses: the
babel wrapper's StyleX instance ran without treeshakeCompensation (the
unplugin applies its Vite default only to its own instance, which runs
after the wrapper), so once stylex.create was compiled the TypeScript
transform dropped the import and the defining module never reached the
build. A constant used as a media-query key failed the build with
"Invalid empty selector"; one used as a value shipped as a custom
property nothing declares. The wrapper now gets treeshakeCompensation by
default.

The layer split also substituted constants only within each layer, so a
constant defined on the library side and read on the product side stayed
a placeholder in either form; every layer now sees every constant.

A production build now warns, naming the variable and the rule that reads
it, when the StyleX stylesheet reads a generated custom property nothing
declares; undefinedVariables: 'error' fails the build instead, and this
repo's Storybook and Sandbox configs set it so CI catches the next case.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.
…tarts at the commit

play() bakes curves given as value and velocity functions of time (a spring
chasing a moving target, a fitted UIKit curve) like a spring.
createAnimator(dialog, {pseudoElement: '::backdrop'}) drives a
pseudo-element: a static filling animation holds its end value.
animatorFor(element) shares one animator per element, so callers animating
different axes compose in one translate track; atRest() waits out every
curve on its properties, and rebase() moves them by a layout shift keeping
their springs. A call from rest gets no start time, so the browser starts it
at its commit, as Core Animation does, and handle.started reports that
start. Every animation and set() promotes its channels to a compositing
layer in the same task (will-change, or a host custom property for a
pseudo-element) until 500 ms after the last motion: Mobile Safari only runs
a script-started curve through a block on an element that already has a
layer. Under Reduce Motion the substituted cross-fade can follow a
presentation's own spring, and crossfadeAs gives a slide that changes no
opacity its direction. The header lists the iOS Safari limits measured on
the simulator.
…eraction harness

useAnimator wraps the animator for React without re-rendering per frame.
Browser proofs on the interaction harness, in Chromium and WebKit on the
phone profile: a spring keeps advancing on every screencast frame through a
1 s main-thread block and every frame sits on its analytic curve; a spring
ending inside the block keeps its end state; no Layout or Paint runs while
it animates; the engine plays the curve when seeked; a finished animation
leaves within a frame or two of the curve's end; a dialog's ::backdrop
follows the curve.
A touch presentation is now also an idiom: ios for WebKit on iOS or iPadOS
under a coarse pointer (CSS.supports('-webkit-touch-callout: none'), which
no other engine implements), default everywhere else, Android included,
which keeps today's behaviour until a Material variant exists.
useTouchPresentation and readTouchPresentation report it, useTouchIdiom
reads it alone, and useMotionPresentation now chooses UIKit motion only in
the iOS idiom. Styles gate the same way with two nested StyleX keys, the
media query outside and the supports query inside (IOS_TOUCH_STYLE_KEYS),
as core's 16px input floor already does; a test keeps every such key in
core spelled and nested that way. A real-engine spec reads Chromium as an
Android phone and WebKit as default.
@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 Ready Ready Preview Oct 25, 2026 7:01am UTC

Request Review

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

It filtered the registry to the four shared tables, so the first component
table that discovery found would fail it. It now compares every table the
discovery loop registered against the generated role kinds (a probe table
added under motion/uikit passes once the list is regenerated).
@vjeux
vjeux force-pushed the uikit/f2-gesture-delegates branch from dc79d9e to 500e1f8 Compare October 25, 2026 03:08
vjeux added 3 commits October 24, 2026 21:22
…tunes

Since nothing registers a table on import, the proof that a theme retunes a
table spring must register the springs table, as a touch module would. It
checks that the role is there before measuring, so a proof that resolves a
role nothing registered fails with that cause instead of a curve mismatch.
A decay only continues the velocity a finger handed it on release, so it
keeps its motion, as a native scroll view's momentum does; a spring a
component starts on release toward a target it picks is a transition and
cross-fades (DR2). The animator's changeset names the design:reduced-motion
policy instead of the spring fade it replaced. The same statement lands in
meta#8056, where the animator introduces Reduce Motion.
Theme adaptations have no platform condition, and defineTheme rejects
when: {platform}, so the template's adaptation axes are pointer precision,
contrast and motion preference, as 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.

Approval is on hold for an engineering owner's decision: the outside-delegate API (PUBLISHED_GESTURES, gestureNamed, addGestureDelegate, useGestureDelegate) has no governing current spec, and the draft spec:AST-094 FR15 covers recognizer arbitration but not outside delegates or the published-name list. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request once the governing requirement is identified or added, and the author may do so as an engineering owner. These changes are needed either way:

  • Land the stacked predecessors and rebase, so this pull request carries only the delegate commit (about 1,500 changed lines) instead of about 31,000 authored lines.
  • State in the useGestureDelegate docs, the README and the changeset that no component publishes these names yet.
  • Align draft FR8 with design:reduced-motion DR2 (an opacity-only fade of 200 ms or less), or list DR2 among the records it amends.
  • Drop the removed platform condition from the CLI theme template, and say 768px in the adaptive changeset unless the rule reads the theme's own md.

[Automated review]

returns: [],
usage: {
description:
"Experimental: this hook may change or be removed in a patch release. Lets code outside a component relate to the component's own gesture recognizers, the way a UIKit app uses a recognizer's name and its own delegate. Components publish their main recognizers under stable names (PUBLISHED_GESTURES, such as 'NavigationStack.backSwipe', 'Item.swipe', 'Lightbox.dismissPan'). To relate your own recognizer to one, use its existing delegate options with `gestureNamed` (for example a canvas pan inside a sheet that waits for the sheet's drag: `shouldRequireFailureOf: (_, other) => gestureNamed('BottomSheet.drag', {containing: canvasRef})(other)`). Use this hook for a recognizer you do not own: a veto (no back swipe while a form has unsaved changes) or a relationship between two components' recognizers. Code that declares nothing changes nothing.",

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 component sets any of these names yet, and there is no NavigationStack component, so a delegate written from this text never matches and fails silently. Please say the names are reserved until components adopt the gesture system, or ship each name with the component that sets it.

Comment thread packages/cli/assets/theme.template.ts Outdated
Comment on lines +361 to +362
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

platform is no longer an adaptation condition at this head (ThemeAdaptationCondition holds width, pointer, contrast and motion), and when: {platform: 'ios'} now fails validation, so this sends theme authors to an invalid rule. Please drop the platform clause.

Comment on lines +107 to +109
place, and any other move fades through. A navigation push or pop
cross-fades over 0.5 s on the ease-in-out curve; a sheet, a full-screen
modal or an alert fades on its own presentation spring and drops its travel

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 current design:reduced-motion DR2 caps a coarse-pointer reduced-motion transition at an opacity-only fade of 200 ms or less, and the animator in this stack ships exactly that. A 0.5 s cross-fade and a fade on the presentation spring contradict it; please align FR8 with DR2, or list DR2 among the records this spec proposes to amend.

Comment thread .changeset/touch-presentation.md Outdated
'@astryxdesign/core': patch
---

[feat] `presentation="adaptive"` on DropdownMenu, ContextMenu, MoreMenu, Selector and MultiSelector, and a DropdownMenu's default sub-menu drill-in, now use the theme's compact definition with a coarse primary pointer: a viewport narrower than the theme's `md` point (768px by default) or shorter than 500px. This is a behavior change. The old default was a bottom sheet at 768px wide and below on a coarse pointer. Now a coarse-pointer screen exactly 768px wide keeps the anchored popover, and a phone in landscape (wider than 768px but shorter than 500px) gets the bottom sheet and the drilled-in sub-menu. To pin a component's presentation regardless of the rule, pass `presentation="popover"` or `presentation="bottom-sheet"`. Mouse and keyboard users on desktop see no change, because every case requires a coarse pointer.

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 query comes from DEFAULT_COMPACT_DEFINITION, a module constant built from the default width points, so a theme that overrides md still switches at 768px. Please say 768px here, or resolve the rule from the active theme's compact definition (spec:AST-012 FR9 asks for the effective md).

vjeux and others added 10 commits October 24, 2026 22:23
…cription

They ran under their own Playwright config, which no CI job picks up;
spec:AST-030 FR11 asks new coverage to join its existing owner. Their
results (Chromium and WebKit, and Mobile Safari on the iOS 26.2 simulator)
stay in the pull request description, and the unit suites keep the
resolver's answers.
Nothing pinned useMenuDrillIn's theme read: a fixed default query left the
suites green. A 900 px coarse screen now flies the sub-menu out under the
default md (768) and drills in under a theme whose md is 1000, as the root
sheet does (spec:AST-012 FR9).
… reads

Its default ignored a theme that moves md; nothing calls it yet, so the
compact definition is now required and its first caller passes the
theme's (useCompactDefinition()). The changeset names ContextMenu's
sub-menus, whose drill-in changes too.
…l subpath

The package no longer exports @astryxdesign/core/motion/experimental or
@astryxdesign/core/gestures/experimental: a file path alone does not mark
an API as experimental, and a public experimental API needs explicit
marking in the API itself and its own spec decision. Core components
import motion and gestures from ../motion and ../gestures, and Lab through
internal workspace imports.

Removes the two entry modules and their tests, their package.json and
sync-exports entries, the hook docs whose import path was a removed
subpath (useAnimator, useGestureRecognizer, usePressTracker,
useTouchPresentation; already left out of search and the docsite), and
the subpaths changeset. The READMEs and the gestures barrel say the
modules are internal.
… into the touch foundation

# Conflicts:
#	packages/core/src/utils/touchPresentation.ts
The animator and reducedMotionFade() read the person's settings
(prefers-reduced-motion and a coarse primary pointer) and nothing else: the
prefersReducedMotion and isCoarsePointer options are gone, so no caller can
keep the motion or change which form it takes (design:reduced-motion DR4),
and the experimental motion surface adds no Reduce Motion override. Tests
set the settings through matchMedia.

Co-authored-by: vjeux <197597+vjeux@users.noreply.github.com>
…hangesets say internal again

The proposed ./motion/reducedMotion.stylex export goes: its constants carry
no experimental marking and had no spec decision, and Reduce Motion stays
internal (design:reduced-motion). Its Lab import test goes with it. With the
experimental subpaths gone, the touch pieces' changesets say they are
internal and not exported again, and search and the docsite no longer need
to skip experimental hook docs.
A static import graph of core's source fails when anything imports a table
as a namespace object (which keeps every table, register function and the
role registry), when shared code imports a table that belongs to one
component, or when a component reaches a component's table only through the
uikit barrel or shared code. A made-up package proves each case fails.
Motion and gestures are internal to core, so Lab reaches them through
workspace imports of their module paths (@astryxdesign/core/motion/animator,
@astryxdesign/core/gestures, @astryxdesign/core/utils/touchPresentation),
which core's exports do not list. Vitest and Storybook already alias
@astryxdesign/core to its source; Lab's typecheck and declaration build now
map those paths to core's built types the same way.
…rs by name

Components publish their main recognizers under stable Component.role
names (PUBLISHED_GESTURES). gestureNamed(name, {within, containing})
matches them, telling instances apart by the element they are attached to,
evaluated when a touch asks. App code relates its own recognizer through
its existing delegate options, and adds delegate methods to recognizers it
does not own with addGestureDelegate / useGestureDelegate: UIKit's
delegate model, additive to the component's own (any false veto wins, any
true relationship applies). The arena consults the registered delegates;
with none, nothing changes. The browser drivers move to a shared module
for the new Chromium and WebKit spec.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision: the internal outside-delegate API (PUBLISHED_GESTURES, gestureNamed, addGestureDelegate, useGestureDelegate) and its nine reserved recognizer names have no governing current spec, because spec:AST-094 is not on main and the open draft's FR15 covers only exclusivity, require-to-fail and innermost-wins. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request once the governing requirement for outside delegates and the reserved names is identified or added, and the author may do so if they are an engineering owner. These changes are needed either way:

  • Make the release velocity meet architecture:interaction-modality INV10 and spec:AST-100: one filter, a release of 0 after a still pause, and no velocityStrategy alternatives.
  • Land the stacked predecessors and rebase, so this pull request carries only the delegate commit (about 1,350 changed lines) instead of about 30,000 authored lines.
  • Say the published names are reserved until components set them, register useGestureDelegate in the component's own document, and rewrite .changeset/reduced-motion-record.md as an internal change.

[Automated review]

* header describes, `uikit` by default.
* @experimental
*/
export type VelocityStrategy = 'uikit' | 'lsq2' | 'iosFling' | 'window';

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:interaction-modality INV10 gives Core one release filter with no alternatives, and spec:AST-100 FR2 makes the release 0 after a still pause. This type lets each pan pick one of four filters (through velocityStrategy), and the default keeps the last moving value after a rest (lines 18–20). Please keep one filter, add the stillness reset, and drop velocityStrategy with the lsq2, iosFling and window strategies.


## Relating to a component's recognizers

Components publish their main recognizers under stable names,

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 component sets any of these names yet (nothing outside gestures/ imports the gesture system, and core has no NavigationStack), so a delegate written from this section never runs and gets no warning. Please say the names are reserved until each component sets its name.

return undefined;
}
return addGestureDelegate(
document,

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's doc says "in its document", but this always registers on the global document, while gesture hosts are per document (getGestureHost(doc)). A component rendered into an iframe or a second window through a portal would register where its recognizers never arbitrate. Please take the document, or an element to read ownerDocument from, as an option; or narrow the doc.

'@astryxdesign/core': patch
---

[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions.

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 animator and reducedMotion.stylex.ts are internal (no ./motion export), so this [fix] would announce removed options on an "experimental" API nobody can import. Please write it as [chore] Internal: … like the sibling changesets, or fold it into uikit-motion-animator-curves.md.

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Merging main here isn't mechanical: this branch still carries an older copy of the #8076 foundation that main has since landed in its own form (62 files), and taking either side breaks the build. I'll port this pull request's own commit onto current main as a fresh pull request and close this one in its favor.

@vjeux vjeux closed this Oct 28, 2026

This branch was successfully deployed

1 active deployment
Preview — 8e4b380b Deployed Oct 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants