Skip to content

feat(lab): CircleCheckIndicator presses the same way for every pointer (spec:AST-094 FR43) - #8701

Draft
vjeux wants to merge 1910 commits into
meta:mainfrom
vjeux:uikit/c06-fr43b
Draft

vjeux wants to merge 1910 commits into
meta:mainfrom
vjeux:uikit/c06-fr43b

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8255 and #8682 (and their predecessors). Review only this PR's change: vjeux/astryx@bdc151c...c36a3c6

User need

spec:AST-094 FR43 gives a fine pointer a finger's press model: a press paints at once on pointerdown, through the press controller and a finger's release fade, in place of :active. Lab's CircleCheckIndicator still painted a mouse press with its own :active fill and read its press image only under a coarse pointer, so with a mouse it showed a different press from the checkbox and radio marks beside it.

Problem and solution fit

Current specification

  • spec:AST-094 FR43, FR11's fine-pointer profile, DEC-21.

Public delta and caller burden

None in a published package: Lab is private, so there is no changeset. With a mouse or a pen, the circle's press appears at pointerdown and fades on release, as under a finger; a right click paints nothing.

  • Size: base → head per component in this pull request's CI Component Size report (analyze-pr.js).

Design and alternatives

Keeping the :active fill for a mouse would leave the circle with two press models, unlike the core marks after FR43.

Evidence

Chromium, a fine primary pointer, a real Storybook build: a mouse press is marked by the controller at pointerdown, the circle shows the round press image, and no fill sits beside it (fails on this PR's base). A unit case pins the image on every pointer and no :active fill (fails on the base).

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

vjeux and others added 30 commits October 26, 2026 19:07
…uard

At this layer the host has no click listener of its own: the click guard
(utils/clickGuard.ts) hears every click through one listener per window.
cixzhang's meta#8072 action-group press, ported onto the input stream below,
still disarmed the host's swallower and subscribed the host's onClick, so
the merged host failed to compile. The press now disarms the click guard
when it begins and subscribes no click: what it does is unchanged.

The click-count case becomes: a group press adds no click listener of its
own, before or after the host installs mid-press. GestureHost.test.ts
resets the click guard after each case, so a guard one case armed no
longer keeps its pointerdown subscription into the next (it hid the coarse
host's pointerdown listener from a later case).
… that finger until it lifts (spec:AST-094 FR14)

A recognition that claimed its touches and was then cancelled gave them
back to the browser unless another recognition owned them, so a finger
resting there could pinch-zoom the page. A gesture that cancels itself on
a finger's move, a long press dragged past its tolerance after it began,
gave back a finger that was dragging: the host stopped cancelling its moves
and the page scrolled the rest of the drag, a hand change FR14 forbids and
UIKit never makes (its scroll view never takes a touch another recognizer
had). A cancel during a finger's move now keeps that finger claimed until
it lifts; a resting finger is given back as before, and a waiting
recognition that fails still gives its claim back.

The new arena case fails on the previous code: the dragging finger reads
unclaimed right after the cancel.
…ide the block and judges it against the settled mark

The busy-window proof's precondition, that the change was presented before
the block began, was too strict. Both engines' compositors can present a
change the page committed before the block a few milliseconds into it: six
probes each found it 5 ms into the block in WebKit (frames -12 rest, +5
checked, +16 checked) and 1 ms in in Chromium in three of six. With about
half the attempts rejected, three in a row failed about one run in eight
("the change was never presented before the block").

The precondition is now that the change showed by the block's end, and the
proof judges the frames on screen during the block from the change on: the
last frame before the block if it already shows the change, plus every frame
inside the block from the change. Each judged frame must also be the settled
checked colour, read after the block, so a mark frozen mid-blend during the
block fails, as "shows the checked mark, whole" says.

Evidence (local stand-in copies, not shipped): a late frame (the block
starting 300 ms after the tap) passes; a change that shows only after the
block takes the retry path and reports "the change was never presented
during the block"; a 1 s linear transition on the mark, which WebKit freezes
at 229 during the block, passed the earlier assertions and fails the settled
check (123). The proof passes 10/10 in Chromium and in WebKit.
… scroller under it is at the edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628): a scrollEdge pan may
cancel its touch's moves from the first one, only while every scroller
under the finger sits at its edge and the finger moves away from it, and
only the moves the browser would rubber-band; it still begins at FR17's
10 px. The first case reads the first 2 px move cancelled at the edge and
fails on this head; the others keep what must not change: room to scroll
leaves every move to the browser, a first move toward the content too, a
pan without scrollEdge cancels nothing before it begins, and a touch the
browser took for a scroll stays the browser's when the list reaches its
edge.
… cancelled at the edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628). The sheet's scroll-edge
pan at the top of its content: every touchmove the page sees is cancelled,
the first one included. WebKit sends that first move at 2 px, before the
sheet's 10 px begin, and it reaches the page uncancelled on this head;
Chromium sends none inside its own 15 px slop, so there it proves only
that nothing scrolls.
…e away from an edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628). GestureRecognizer gains
keepsTouch(touchId), false by default and reached by the host through
recognizerInternals: whether it holds that finger before it begins. A
scrollEdge pan does while it is possible, the touch is a finger the browser
has not taken, its travel so far goes the pan's own way, and no scroller
under the finger can scroll for it (the probe its begin already reads), so
only moves the browser would rubber-band are held, never a real scroll.
The host's blocking touchmove check cancels a move a participant holds, as
it does a claimed one; the arena already holds that move when the check
runs, since the host feeds it from its window-capture subscriber first.
The pan still begins at 10 px; a mouse or a pen is never held. The README
says so.
…straight, (7, 7) and (8, 8), and the screen-edge band

The FR18–FR22 verification row's real-touch states, in the gesture browser
spec on a bare page:
- a finger moved 9 px straight or (7, 7) over a scroll view keeps its tap
  and scrolls nothing; one moved 10 px or (8, 8) is a tap or a scroll,
  never both; a tap never cancels a touchmove;
- a screen-edge pan on a strip as wide as its band, landing 40 px from the
  edge, has not claimed at 14 px of travel and claims at 15;
- a touch landing 41 px from the edge never begins the edge pan, and the
  horizontal scroll it starts is not blocked.
The bare fixture exposes the tap and screen-edge pan recognizers.
…edge-zone strip decides at UIKit's 15 px

The host's arena environment had no viewSize, so under the real host a
screen-edge pan never saw its edge zone while a touch was down and decided
at 10 px, while its attach-time touch-action (read through the default
environment) had already claimed the axis (spec:AST-094 FR18).

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…fter layout rounding

Lane 0's real-host browser spec found a strip authored at the 40.2 px edge
region lays out at 40.203 px (layout snaps to 1/64 px), so the strict
comparison read it as off the zone and the pan decided at 10 px instead of
UIKit's 15 (spec:AST-094 FR18). The zone takes half a pixel of tolerance:
the rounded strip is the zone, a view a pixel wider still is not.
Failing-first test at 40.203 px: it claims pan-x and decides at 15.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…w) as a dependency

# Conflicts:
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
…now that a strip within half a pixel is the edge zone (meta#8061)
…, and under inputMode 'pointer-events' a claimed finger's touchmoves are cancelled

- spec:AST-094 FR20 and DEC-8: a native scroll above an unclaimed finger
  counts as the browser taking it only once that finger has travelled the
  take distance (10 px) from where it landed. A scroll under a still finger
  (scroll anchoring, a carousel advancing, a transcript pinned to its end)
  is the page's own, and the touch and its click stay. (The same rule
  meta#8309 carried, moved down to the host that introduces it.)
- spec:AST-094 FR19 and FR21 under inputMode 'pointer-events': a finger
  comes from its pointer events, so its touches carried no id of ours and a
  claim never cancelled them. The blocking touchmove listener now matches a
  touch to the nearest live pointer-fed finger on its target, so a claimed
  pan's moves are cancelled and the browser cannot scroll under it.

Tests (failing first): 9 px and (7, 7) are not taken, 10 px and (8, 8) are,
a still finger is not; the FR30 own-scroll case moves its finger first; under
'pointer-events' a pan's touchmoves pass before its claim and are cancelled
after it.
…n: pan-y only on a strip within its band, left as is on a wider view
…r-events claims cancel) as a dependency

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
…8072 9800eb5, the FR18-FR22 browser states) as a dependency
…eta#8061 9adbe2d, meta#8072 9800eb5) into the gesture watcher

The README's touch-action table takes meta#8061's edge-pan rows beside this
PR's watcher row.
…es its recognizers, and the group keeps answering afterwards

Under a fine pointer the host is not installed, so usesTouchEvents keeps
its initial true, and a group press's own touch pointermove and pointerup
went down the touch-pairing path and returned: the finger's moves and lift
never reached the group, its contact stayed in the arena, and the group
answered no press after it, a mouse's included. The group press now feeds
its own events through the pointer path for every pointer type.

Test (failing first): under a fine pointer a finger tap on a group
recognizes, and a mouse tap afterwards does too.
… are all passive

spec:AST-094 FR20: a watcher registers only passive listeners, but a
watcher-only page still got the host's cancelable document touchend and
its contextmenu and window click listeners. The document touchend is now
passive until an enabled claimer (a recognizer that claims touches, or a
consumer) is attached, and the contextmenu and click listeners are on only
then, swapped as attachments change. The new test (a lone watcher leaves
no cancelable document or window listener; a claiming pan brings the
three) fails on the previous head.
…r watchers) into the host line

The host keeps this PR's isRtlElement and its fingers held before a
recognizer begins (a kept press, a scroll-edge pan), beside meta#8061's view
size and meta#8309's pointer-events finger lookup. meta#8589's claimer check
counts only participants active on this pointer (isActive,
isConsumerActive), and a group press's click listener now stays while a
claimer keeps it installed. The browser spec keeps the fine-pointer
describe beside the FR18-FR22 real-touch states.
…'s passive-only watcher listeners) into the arena fixes

The host-owner changeset keeps this PR's own listeners for the scroll
lock and finger-scroll watch, and adds meta#8336's scroll-edge hold.
…inter reaches its recognizers) as a dependency
… under a fine pointer) as a dependency
…tion group's finger under a fine pointer) into the gesture watcher
Main's meta#8521 is its head 25b468c squashed, which this pull request already
carries: every file it touches is byte-identical to 25b468c. In the two
files that conflicted (the motion README and animator.ts), the resolution is
this side's version, main's merged meta#8521 with this pull request's later work
on top. Nothing of main is dropped.
…ta#8521) into meta#8270

# Conflicts:
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
vjeux added 7 commits October 27, 2026 09:54
…ny primary pointer (spec:AST-094 FR41)

locksSelectionFor drops its coarse-pointer gate, which merged FR41 no
longer has: a finger and a pen take the lock wherever their hold does
something, a touchscreen laptop's included; a mouse never does, keeping
its released behavior (spec:AST-017 FR59). No pointer query decides it,
so the function takes the pointer kind only. The every-pointer changeset
says so.
…t without touching that view

Its disarm timer outlived the view it armed on: in a full run a test file
ended inside the 400 ms window, the environment tore its window down, and
the timer's disarm called removeEventListener on what was left, an
unhandled TypeError during a later file's test. Fails until disarm checks
its view is still there.
…e selection hold at once (spec:AST-094 FR41)

The hold a claimed mouse or pen drag puts on the document is released a
task after the press ends, so the press's own mouseup and click, which
follow a lift in the same task, still meet it. No mouseup or click follows
a pointercancel, a blur or a detach, yet the hold lingered that task too:
a check right after the cancel, or a test that ends there, met a page
that still refused selection. Fails until a cancel releases at once.
…ing it; a cancel lifts the drag's selection hold at once (spec:AST-094 FR41)

The click guard keeps the window it listens on and, at disarm, removes
nothing when that window is gone (the document has another view, or its
listener methods went with a torn-down environment): its listeners went
with it. The disarm timer that outlived a test file no longer throws.

The document-wide selection hold of a claimed mouse or pen drag ends a
task after a lift, so the press's own mouseup and click still meet it,
and at once after a pointercancel, a blur or a detach, which nothing
follows: the host passes isCancel from its cancel paths.

Also drops an unused import from the host and has a test assert the
translation it kept in a variable.

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

The circle's mouse and pen press now matches spec:AST-094 FR11's fine-pointer profile and DEC-21 on main. Approval still waits on an engineering owner for two other changes at this head: the rewrite of component:Button FR15 to a 250 ms coarse-pointer release painted on ::before, and the public indicatorPressScope press hand-off, which component:CheckboxInput AV1 does not allow today. An engineering owner can record the Button decision by commenting /approve-spec <full head SHA> on this pull request (you may do this yourself as an engineering owner), and can provide or identify the record that lets an indicator own its press. Separately, please:

  • Rebase once #8255, #8682, #8665 and #8118 land, so this diff holds the three circle files instead of 42,216 authored lines.
  • Remove CheckboxInput's --_indicator-press-fill hand-down and its docs (CheckboxInput.tsx:90-96, CheckboxInput.doc.mjs:162-168, indicator.markers.stylex.ts:25-30). They describe an :active mouse press that the circle no longer reads.
  • Drop "A mouse on a desktop keeps :active." from .changeset/press-control-profile.md, and stack on #8696 and #8695 so Core's marks and the jsdom selection-mark tests line up with the circle.

[Automated review]

Comment on lines +90 to +96
'--_indicator-press-fill': {
default: 'transparent',
':active': {
default: colorVars['--color-overlay-pressed'],
'@media (pointer: coarse)': 'transparent',
},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

After this pull request no indicator reads --_indicator-press-fill: the circle, the only indicator that paints its own press, takes --_press-paint-image for every pointer. spec:AST-094 FR11 and DEC-21 also replace :active for a mouse press. Removing this property, its two entries in CheckboxInput.doc.mjs and its line in derivedVarRegistry.test.ts keeps a theme author from building a second, :active press model from the docs.

Comment on lines +25 to +30
* The indicator applies this marker together with
* `data-astryx-indicator-press="self"` and paints the press itself from what
* the owner hands down as inherited private properties (CheckboxInput:
* `--_indicator-press-fill` for a mouse press, `--_press-paint-image` for a
* touch press, `--_indicator-hover` for hover). The owner finds it with
* `stylex.when.descendant()` and leaves its own layer off.

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 tells an indicator author that a mouse press arrives as --_indicator-press-fill and only a touch press as --_press-paint-image. With this pull request's circle, the image is the press for every pointer, as FR11's fine-pointer profile says. Worth describing --_press-paint-image alone here.

vjeux added 17 commits October 27, 2026 13:05
The unhandled TypeError in full runs came from a click guard a test left
armed: its 400 ms disarm timer fired after the environment tore that
file's window down, in a later file. A test that ends with the guard
armed now checks the next test starts with it disarmed. Fails until the
shared test setup disarms every guard as each test ends.
…est ends; the guard has no special case for a removed window

The cause of the unhandled TypeError was test isolation, not the guard: a
test that ended inside the guard's 400 ms window left its timer to fire
after the environment tore that file's window down. The shared setup now
disarms every guard in afterEach, so no timer outlives its test. The
check 2d365a7 added to disarm, which skipped unsubscribing once the
window looked gone, only covered for that leak (a browser keeps a closed
frame's listener methods), so it goes, with its case.
Button.spec.md FR15: main's text (meta#8613: the 200 ms release at tempo 1,
scaled by the theme's tempo, spec:AST-108 FR4) with meta#8118's own
additions kept; the touch release's measured 250 ms is likewise stated
at tempo 1.
… paint

Button.spec.md FR15: main's text (meta#8613: the 200 ms release at tempo 1,
scaled by the theme's tempo, spec:AST-108 FR4) with this pull request's
own wording kept; the measured 250 ms release is likewise stated at
tempo 1.
…busy first frame outran

pressFeedback.ts removes data-astryx-press="fading" on a timer of the
release's duration, started at the write. When the main thread is busy
right after the lift, the release animation starts only at the first frame
after the block, so the timer removes the release partway through it: on
a list row after a 150 ms block, 0.736 -> 0.558 -> gone about 52 ms in.
These fail on that code:

- pressFeedback.test.ts: with a release animation the stylesheet started
  late (or still waiting for its first frame) when the clock runs out,
  the controller's release and another painter's (paintPress) stay
  fading until the animation finishes. Guards: a cancelled animation ends
  it, no animation ends it on the clock (reduced motion's instant
  release, a surface without a press layer), a descendant's, an endless or
  an earlier animation is not waited for, and a new press keeps its paint
  when the old release ends later.
- ButtonPressRelease.interaction.browser.spec.ts, both engines: after
  150 ms of script at the release, the button's layer keeps the release
  until its strength reaches 0.
…y first frame started it

endAfterRelease replaces the clock that removed data-astryx-press="fading"
at the release's duration after the write, for the controller's release
and for every other painter's (paintPress). The clock now only looks: at
the release's duration, a release animation the write started on the
surface (one of its own CSS animations, on it or its ::before or ::after,
finite, started at or after the write or still waiting for its first
frame) is waited for until it finishes or is cancelled, so a busy main
thread that starts the animation late ends it late too, at strength 0.
With no such animation (reduced motion's instant release, a surface
without a press layer, an engine without Web Animations) the release ends
on the clock, as before. Any new write, or a new press, cancels the wait.
A slot's pending clock is now a cancel function, so the release's wait
and every timer clear the same way.
…ts animation does) into the circle's FR43 change

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

The circle's mouse and pen press now matches spec:AST-094 FR11 and DEC-21, but approval waits on an engineering owner for two other changes at this head: the rewrite of component:Button FR15 to a 250 ms coarse-pointer release painted on ::before, and the new public indicatorPressScope press hand-off, which no current record governs. An engineering owner can record the Button decision by commenting /approve-spec <full head SHA> on this pull request (the author may do this as an engineering owner), and can provide or identify the record that lets a theme indicator own its press. Separately, please:

  • Rebase once #8255, #8682, #8665 and #8118 land, so this diff holds the three circle files instead of 42,619 authored lines.
  • Remove --_indicator-press-fill and correct the docs that call it an indicator's mouse press (CheckboxInput.tsx, CheckboxInput.doc.mjs, indicator.markers.stylex.ts), and drop "A mouse on a desktop keeps :active." from .changeset/press-control-profile.md.
  • In the merged gesture stack, build PressTracker from a create… factory and stop re-exporting the internal animator as createAnimator, as architecture:react-component-runtime INV13 on main now requires.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — c36a3c62 Deployed Oct 27, 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.

2 participants