Skip to content

feat(core): UIControl press tracking, press profiles, the press paint and hooks - #8073

Closed
vjeux wants to merge 138 commits into
meta:mainfrom
vjeux:wip/uikit-pr-g2c
Closed

vjeux wants to merge 138 commits into
meta:mainfrom
vjeux:wip/uikit-pr-g2c

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8072 (and its predecessors). Review the commits after #8072's in the Commits tab; this push alone: vjeux/astryx@b600a78...63128d3

Why

On a phone, Astryx's rows and buttons paint their press without UIKit's tracking rules. A finger that slides a little off a button un-presses it at once, a flick through a list lights rows it passes, and a touch that stops a scroll counts as a press. Rows and buttons are most of what a finger touches.

What led to it

This is part of the UIKit-parity work that makes Astryx's touch components behave as they do on iOS. The gesture spec gives presses two profiles, a control's and a table cell's (spec:AST-094 FR11), with every clock taken from the measured constants table.

Options and tradeoffs

  • Keep CSS :active and the generic press controller: no notion of UIControl's events, so the problems above stay.
  • Track presses in each component: the same rules written many times.
  • One press tracker with UIControl's events, two profiles, and one writer for the press paint.

Why this choice

PressTracker reports UIControl's events, with "inside" the bounds expanded by 70 pt, as UIButton measures. The control profile highlights in the same frame (after the 150 ms content-touch delay inside a scroller) and flashes a quicker tap; the cell profile always waits for the delay and cancels past the pan's slop. A touch braking a fling paints nothing. All painters write data-astryx-press through one writer, which owns the release clock and never overwrites a row another painter holds. useGestureRecognizer and usePressTracker attach without a render per move. Everything stays on the internal gesture entry.

How to check it

  1. On a phone, press a button and slide 60 pt off it. Expected: still pressed; at 75 pt it un-presses.
  2. Flick a list of rows. Expected: no row lights.
  3. Touch a flicking list to stop it. Expected: it stops, and no row presses.
  4. Tap a button quickly. Expected: a short flash, then the release fade.

Evidence

  • UIButton's tracking, measured in a UIKit reference app on the iOS 26.2 simulator in 1 pt steps: inside up to 70 pt, touchDragExit at 71, touchDragEnter at 69.
  • Unit cases drive both profiles through the arena with explicit timestamps, the one writer (release timing, a held row surviving presses), and the hooks (one recognizer across renders, one render per drag).
  • This push merges main, which landed test(interaction): frame evidence #8045 and fix(core): a mouse or Space press on a touch-first device paints the pressed state #8046, keeping main's merged press controller with only this pull request's writer on top; a red-first case keeps a held row through a mouse press that ends early. The whole Core UI suite and pnpm check:repo pass.

vjeux added 4 commits October 23, 2026 20:53
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.
…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.
@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 28, 2026 6:00pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 28, 2026, 6:18 PM UTC

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

An offer for one changeset, on the fork branch vjeux:uikit/f1-8073-changeset (ce7f127, one commit on 8a8ac32): gesture-press-tracking.md ends at "Not exported from the package entry point.", dropping "no component uses them yet", which goes false in the release that also carries the components using this code.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8117 brings meta#8082 b9a18f5: meta#8076 4adaf8b with meta#8073's one press
writer, and the list's internal constants (registerListTable is gone).
pressFeedback.ts writes through the one writer: the controller's paint
lands only where a press may replace it, so a held row keeps its paint,
and a press's end clears its input marker as before. motion/uikit/index.ts
exports the button's and the list's values; build-css keeps this branch's
Reduce Motion cases beside meta#8082's row-layer describe.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8082 brings meta#8076 4adaf8b with meta#8073's one press writer, and meta#8077's
internal constants: the row press layer lives in rowPressLayer.ts and
registerListTable is gone. pressFeedback.ts keeps this branch's profiles
and slots and writes through the one writer (writePress, which keeps a held
row, so a press's end clears only its own paint); the release timer plays
the profile's clock, capped under reduced motion as meta#8082's is. Item and
TreeListItem import the row layer beside this branch's profiles;
build-css and motion/uikit/index.ts take meta#8082's.
…egan-elsewhere; a recognizer's own scroll is not the browser taking the finger

From CodeBunny on meta#8195, fixed once here where the host enters:
- spec:AST-094 FR28: a touch with no recognizer or consumer on its path
  never reached the arena, so a recognizer tracking a finger was never
  told about a second finger beside it (a menu press could not end on
  one). GestureArena.untrackedBegan reports such a touch to recognizers
  that are tracking touches; it creates no record, claim or lock, and its
  moves and end stay the page's.
- spec:AST-094 FR30: a recognizer that scrolls a scroller itself (a menu's
  edge autoscroll) was cancelled by its own write. The internal
  GestureHost.ownScroll(scroller) makes onScroll skip that scroller until
  two frames pass; a real pan in that window still reports itself through
  pointercancel or touchcancel.
Three tests; two fail on the previous head.
… FR30 ownScroll) into UIControl press tracking
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…s a dependency

useGestureRecognizer.ts: meta#8073's node attachment (a disabled gesture is
not attached, a replaced node re-attaches), its host listener sync and the
scroll-brake default, plus meta#8074's own changes from c248459: the
isEnabled sync keeps the listener quiet (architecture:react-component-runtime
INV2) and a change to the press tracker's four build options rebuilds it
from the next touch. The tests keep both sides' cases.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…placed node; meta#8258's docs with her meta#8067, meta#8056 and meta#8055 fixes) into the experimental touch layer
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
… recognizer's own scroll, from meta#8072) into the experimental touch layer
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…d name their Effects) into the presented start
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…meta#8067, meta#8056 and meta#8055 fixes) into the presented start

Four files both sides changed: the animator's attach flushes waiting
presented calls before the element changes and leaves demotion to her later
step after the landing's commit; dispose flushes them, then lands pending
moves as her version does; the test's style stub takes her pseudo-element
stylesheet and keeps this side's live-animation hold; the README and the
browser spec's header take her wording, with this side's added proofs.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes are needed before approval: the press tracking itself matches the current spec:AST-094 FR11, but the gesture host this diff carries predates the spec's DEC-8, and the diff still includes the unlanded gesture stack (6,330 authored lines, of which the press-tracking layer is 1,469). Please narrow it to the press-tracking layer once #8061, #8071 and #8072 land (or base the branch on #8072), conform those layers to the points below, and update the description, which still describes the removed delegated press, the old cell delay and a 0.25 s release.

  • GestureHost treats any scroll under an unclaimed touch as the browser taking it, so a tap that moved 1 px loses its click, while FR20 counts a native scroll only after 10 px of travel.
  • GestureHost writes the derived touch-action over the element's own, turning an author none or pan-y into manipulation, while FR18 requires intersecting it with the author's value.
  • The one-edge ScreenEdgePanGestureRecognizer decides at 15 px while leaving the browser every touch behavior, against FR19's 10 px, and the host still needs real-touch browser tests (FR18 to FR22, FR41, spec:AST-013 FR10) wherever it lands.

[Automated review]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This counts any scroll of an ancestor as the browser taking every unclaimed touch inside it, whatever that finger's travel, so a tap that moved 1 px fails and its click is swallowed when its scroller fires one scroll event. Current spec:AST-094 FR20 counts a native scroll "only once that finger has moved the scroll take distance (FR2)", which is 10 px (DEC-8). Checking each touch's travel from touch-down against 10 px before taking it would conform. The test "reads a scroll above an unclaimed touch as the browser taking it" pins the old rule.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This replaces the element's own touch-action with the value derived from its recognizers, so an author none or pan-y becomes manipulation once a tap or long press attaches. Current spec:AST-094 FR18 (DEC-8): "The derived value is intersected with the element's own author touch-action and is never wider than it". Reading the element's computed value before the first write and intersecting the tokens, keeping the author's value when it lacks pinch-zoom, would conform.

constructor(options: ScreenEdgePanGestureRecognizerOptions = {}) {
super({
maximumNumberOfTouches: 1,
hysteresis: UIKIT_SCREEN_EDGE_PAN.hysteresis.value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With one edge, this pan leaves the browser every touch behavior (lines 63-66) but begins or fails only at 15 px: it is still possible at 10, 12 and 14 px. spec:AST-094 FR19 says "A recognizer that competes with native scrolling MUST decide within 10 px of travel". Deciding by 10 px, a touch-action that does not compete, or an owner's amendment of FR19 would resolve it. This belongs with #8061.

expect(highlights).toEqual([true, false]);
});

it('shows a quicker tap at the lift straight into the release, with no flash', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This runs outside a scroller, where the cell profile lights at touch-down, so the quick-tap path the name describes is never reached. Passing scroll: scrollProbe(() => true, true) to down would cover the cell's quick tap inside a scroll view, which no test exercises today.

optionsRef.current.onControlEvent?.(event, touch),
onHighlightChange: isHighlighted =>
optionsRef.current.onHighlightChange?.(isHighlighted),
trackingExpansion: options.trackingExpansion,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

trackingExpansion, highlightDelay, cancelTravel and quickTapFlash are read once, when the tracker is created, so a later value is silently ignored while the callbacks and isEnabled stay live. Reading them through optionsRef, as the callbacks are, would keep them current.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8270

meta#8073 now drops the host's delegated consumer itself (95daacd), so this
PR's lock keeps only its contact map, and its case for a delegated
pressable goes with the consumer.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…ated consumer

The gesture host has no delegated consumers and no scroll handoff, so its
header drops both, and one changeset covers the press tracking, the one
press writer and the hooks. Both texts are cixzhang's from c34bf73 on
meta#8073, taken verbatim so the next meta#8076 merges without a conflict.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8076's head is a fast-forward of the ee34b25 meta#8176 carries: meta#8073's
began-elsewhere for untracked fingers and a recognizer's own scroll, and
the motion roles' table values in the docs. No conflicts; `timing.ts` is
meta#8076's with exactly meta#8484's change. This branch takes it ahead of meta#8176,
whose next head brings it too.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8076's head is a fast-forward of the ee34b25 meta#8176 carries: meta#8073's
began-elsewhere for untracked fingers and a recognizer's own scroll, and
the motion roles' table values in the docs. No conflicts; `timing.ts` is
meta#8076's with exactly meta#8484's change. This branch takes it ahead of meta#8176,
whose next head brings it too.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…ough meta#8055 via meta#8258) into meta#8270

The host locks selection for the touches the arena admitted, then tells
the arena about each untracked finger (her began-elsewhere): both loops
run after the arena begins the touches.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's decision on context menus: a public, framework-agnostic createContextMenu(element, {items | render, onOpenChange?, presentation?}) → {open(at?), close(), destroy()} and a thin useContextMenu(ref, opts) hook, with <ContextMenu> rebuilt on them. Row menus attach to the list item element itself, so ul > li stays valid. They get a full-row hit area, open on long-press, right-click or Shift+F10, show press paint and lift on the row, and return focus on close. The records are being written now.

For this PR, a small change:
one press paint per element. If the attached element is already pressable (a row with onClick or href), the factory reuses that paint; otherwise it adds its own.

vjeux added 4 commits October 28, 2026 10:39
Main merged meta#8045 and meta#8046. The one conflict is utils/pressFeedback.ts:
it takes main's merged meta#8046 text (the finger and button slots, the mouse
and key press) and keeps this pull request's own additions, the one
writer of the press attribute (writePress, paintPress, readPressPaint)
and the header line naming them.
Main's button slot, merged with meta#8046, ends a press's release at once
when the next press starts, by clearing the press attribute directly. On
a row another painter holds, that clear erased the hold, which this pull
request's one writer refuses. A mouse press on a held row, released, then
a press elsewhere must leave the row held. Fails until the early end goes
through the one writer.
finish() writes none through writePress, as this pull request's
controller paints, so a held row stays its owner's when the next press
ends a release early, under the finger and the button slots alike.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
meta#8073 merged main 70ea859, where meta#8045 and meta#8046 landed, and keeps its
one writer of the press attribute on main's press controller. Taking that
head resolves this pull request's conflict with main in
utils/pressFeedback.ts with no change of its own.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes are needed before approval: the press tracking itself matches spec:AST-094 FR11 under a finger, but the gesture layers this diff carries no longer match the current spec, and the diff is 6,343 authored lines, 4,923 of them from #8061, #8071 and #8072. Please narrow it to the press-tracking layer once those land (or base the branch on #8072), with these fixed where they live:

  • GestureHost installs nothing unless the primary pointer is coarse, so a mouse, a pen or a touchscreen laptop gets no press tracking or gestures, while FR43 and react-component-runtime INV13 require the host to attach for every pointer.
  • A scroll under a finger that moved 1 px cancels a tap and swallows its click (FR20 waits for 10 px), and the host's touch-action overrides a narrower author value (FR18).
  • The screen-edge pan should claim pan-x only in its band, decide at 10 px on a wider element and stay off under a pen (FR18, FR19, FR43).
  • PressTracker should be a factory with no instanceof checks (INV13) and import the gesture tables by group (component-style-authoring INV6).

[Automated review]

}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a fine primary pointer the host registers nothing, so a mouse, a pen or a touchscreen laptop gets no press tracking or gestures. Current spec:AST-094 FR43 and react-component-runtime INV13 say the host attaches for every pointer, with no touch-only query; under a fine pointer it can listen on the attached element and add document listeners only during a press (FR24).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any scroll of a scroller that contains an unclaimed touch counts as the browser taking it, however far the finger moved, so a tap that moved 1 px loses its click. FR20 (DEC-8) counts a native scroll only once that finger has travelled the 10 px scroll take distance.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This writes the derived value over the element's own touch-action, so an author none or pan-y becomes manipulation. FR18 (DEC-8) intersects the derived value with the author's and keeps the author's value when it leaves out pinch-zoom.

);
}

export class PressTracker implements TouchConsumer {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

react-component-runtime INV13 keeps classes to the recognizer family (its base, the arena and the host), and its verification fails a class outside it and an instanceof check on a recognizer (line 148). A createPressTracker(options) factory that returns a plain object, as createActionGroupTracker does, would fit.

* swallows for a touch a recognizer took.
*/

import {UIKIT_GESTURES} from '../motion/uikit/gestures';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

UIKIT_GESTURES bundles every group's rows: a bundle of this file carries the screen-edge, rotation, swipe, click and velocity rows. component-style-authoring INV6 ships a component only the rows it reads, so importing UIKIT_PRESS, UIKIT_PAN and UIKIT_CONTROL by name (and press instead of timing) keeps press-tracked controls lean.

* FR18). Pans from several edges keep the browser the axis along them.
*/
override get nativeTouchBehavior(): NativeTouchBehavior {
if (this.edges.length === 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current FR18 (DEC-13, DEC-22) has a one-edge pan claim pan-x for a touch in its band on an element within the band, and decide at FR19's 10 px on a wider element; FR43 keeps it off under a mouse, a trackpad or a pen. Here it claims nothing, always waits 15 px, and accepts every pointer type.

optionsRef.current.onControlEvent?.(event, touch),
onHighlightChange: isHighlighted =>
optionsRef.current.onHighlightChange?.(isHighlighted),
trackingExpansion: options.trackingExpansion,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These four options reach only the first render's tracker, so a later value (another profile, say) is silently ignored while the callbacks stay live. Reading them through optionsRef, as the callbacks are, fixes it.

'@astryxdesign/core': patch
---

[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks, with every press painter writing `data-astryx-press` through one internal writer in `utils/pressFeedback.ts`. Not exported from the package entry point; no component uses them yet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hooks/actionGroupTracker.ts still writes data-astryx-press directly, with its own fade clock, so "every press painter ... through one internal writer" is not true yet. Either route its paint, cut and release through paintPress, or narrow this sentence.

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Closing: carried by #8360 (this head, 63128d3, is in it by hash), with each open review item handled where it lives:

This branch was successfully deployed

1 active deployment
Preview — 63128d35 Deployed Oct 28, 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