Skip to content

chore(core): a shared touch hit area that grows toward 44 px and stops at half the gap to each neighbour - #8635

Merged
cixzhang merged 9 commits into
mainfrom
feat/core-hit-area
Oct 27, 2026
Merged

cixzhang merged 9 commits into
mainfrom
feat/core-hit-area

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

On a phone, a control smaller than 44 px is hard to hit, but Astryx doesn't grow controls on touch screens. The hit area has to grow invisibly instead. Done per component, that hit area can cover a neighbour: a Switch beside a label link, a chip's remove button, two toolbar buttons 4 px apart, pagination dots. A tap meant for the neighbour then goes to the wrong control. Today a press group grows every action by a flat 8 px and gives any overlap to the earlier action in the DOM.

This adds one shared, Core-internal utility that every control and group uses, so the rule is the same everywhere: grow toward 44 px, and stop at half the gap to each neighbour, so a tap always goes to the nearer target.

Intended invariant

  • Under a coarse primary pointer, a target's hit area grows toward 44 × 44 px, centered, and no side reaches past the midpoint between it and a neighbour (0 when they touch).
  • Nothing grows under a fine pointer, and nothing visible changes size.
  • Until the first measurement the insets are 0, so first paint and server rendering never overlap anything.

Change

packages/core/src/utils/hitArea.ts (not exported from @astryxdesign/core/utils; a test checks):

  • A watched control counts as a neighbour by its own box, never by a part inside it (a Switch's invisible input is grown by its own hit area).
  • clampedHitInsets(target, neighbours, minSize = 44) takes plain rects and returns per-side insets. A neighbour counts when either one, at full growth, would reach into the other's half of the gap, or their full areas would meet. It stops the side facing it; for a diagonal neighbour, the side with the larger gap. Both make the same decision, so their areas never overlap.
  • resolveHitTarget(x, y, rects, minSize = 44) returns the index of the nearest target when the point is within that target's growth toward 44 px, or -1. Being nearest, the point is on that target's side of every midpoint, corners included. It's for a group that measures its actions at press start.
  • watchHitArea(element, onInsets, minSize = 44) keeps a standalone control's insets live:
    • only under a coarse pointer: with a fine one it runs nothing but its pointer query (no observer, timer or listener; spec:AST-094 FR24);
    • only while the control is on screen (one shared IntersectionObserver);
    • all pending controls in one pass per frame (one shared rAF queue: every read, then every write);
    • re-measured when the control, its parent or the window resizes, and once the control's parent's text or children settle after a change (a label or link beside it can move without a resize), through the same queue; changes elsewhere on the page cost nothing;
    • re-checked at a touch's start, through one shared capture listener on the document (on only while the pointer is coarse; no watched control, row or handle carries a listener of its own, as spec:AST-103 FR5 asks of a list): the control is re-measured at once (one read). A touch that a neighbour is nearer than the control (inside the area, at a corner beside an offset or diagonal neighbour, or outside it after the neighbour moved) swallows that control's click; a touch with no nearer neighbour stays the control's, including one the browser's own touch adjustment hands it a few px past its area with a one-shot capture listener, removed by the click, a cancelled touch, or the next press anywhere (the control's own label included); a keyboard or assistive-technology click (detail 0) always goes through. Nothing is re-sent to the neighbour: a missed tap beats a wrong one.
  • resetHitAreaForTests() stops and forgets every watch, so nothing outlives a test.
  • writeHitInsets(element, insets) sets --astryx-hit-inset-{top,right,bottom,left} for an invisible coarse-only hit layer.

No component uses it in this PR. The Switch and the press groups adopt it in their own PRs.

Evidence

  • Before: no shared rule; each control grows its own hit area, with no knowledge of neighbours.
  • After: hitArea.test.ts (37 tests):
    • a lone 24 px target grows 10 px per side;
    • a link 8 px away stops that side at 4 px;
    • touching neighbours stop at 0;
    • a neighbour 16 px away still stops the side at 8 px (a tap 2 px from the neighbour is the neighbour's);
    • diagonal neighbours never overlap, including ones of different sizes and a wide one beside a tall one;
    • two watched 8 px dots 24 px apart meet in the middle;
    • a long link that doesn't grow still stops the side at half the gap;
    • a corner touch goes to the nearer of two diagonal targets;
    • overlapping ancestors are ignored;
    • in a toolbar a touch in the 4 px gap goes to the nearer button;
    • the live watch measures nothing under a fine pointer or off screen, batches two controls into one frame (all reads before any write), carries controls past the 4 ms budget to the next frame (at least one a frame), re-measures after its parent's text changes and a neighbour moves, swallows the click of a touch that a fresh measurement puts nearer a neighbour (one-shot, also removed by the next touch), runs no observer or listener under a fine pointer, returns to 0 when the pointer turns fine, and stops after unsubscribe.
  • Product/runtime behavior unchanged: nothing imports the module yet.

Performance (real Chrome, headless Linux, coarse pointer, 1280 px wide; 200 rows, each a 32 × 20 switch, a link and two buttons, 800 interactive elements in all). Each pass reads the page's interactive elements once, then measures controls for up to 4 ms (at least one) and carries the rest to the next frame.

Switches on screen CPU Frames Longest frame
200 (6400 px tall viewport) 1× 2 8.2 ms
200 4× slowdown 5 12.8 ms
about 30 (844 px tall viewport) 1× 1 3.6 ms
about 30 4× slowdown 3 12.4 ms
  • Every frame stays under 16 ms. Without the budget, 200 switches at 4× took one 27 ms pass.
  • Reading DOMRect fields in the inner loop cost 22 ms at 1×; rects are copied to plain objects once.
  • A pass runs only when controls appear on screen, something resizes, or a control's parent settles after a text or child change (100 ms). It never runs per frame.
  • A touch's start re-measures just that control: median 0.9 ms (max 1.6) at 1×, 4.1 ms (max 6) at 4× slowdown, over 20 touches on the 800-element page.
  • In that page the first switch's insets were top 12, right 4 (the link is 8 px away), bottom 4 (the next row's switch is 8 px below), and left 6.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • vitest run packages/core/src/utils/hitArea.test.ts: 37 passed.
  • tsc --noEmit -p packages/core and eslint: clean.
  • Performance page: the module bundled with esbuild and loaded in Chrome with a coarse primary pointer; times are from inside the frame callback, with layout already clean.

…s at half the gap to each neighbour

A finger needs a 44 px target, but a control may not grow on a phone, and
its hit area must never take a tap that is nearer a neighbour. utils/hitArea
works this out once for every control:

- clampedHitInsets(target, neighbours, minSize) grows each axis toward the
  minimum and stops each side at half the gap to a neighbour (0 when they
  touch); a diagonal neighbour stops the side with the larger gap.
- resolveHitTarget(x, y, rects) gives a group's touch to the target whose
  clamped area holds it, nearest first, for groups that measure at press
  start.
- watchHitArea(element, onInsets) keeps a standalone control's insets live:
  coarse pointer only, on-screen controls only (one shared
  IntersectionObserver), every pending control in one read pass per frame
  (all reads, then all writes), re-measured on its own, its parent's or the
  window's resize. The insets start at 0, so first paint and server
  rendering never overlap anything. writeHitInsets sets them as custom
  properties for an invisible coarse-only hit layer.

Core-internal: not exported from the utils entry (a test checks).
@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 Oct 27, 2026 5:40am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 27, 2026, 6:07 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 27, 2026
cixzhang added a commit that referenced this pull request Oct 27, 2026
…from a neighbour

Per Cindy's 18:22 rule, an expansion never overlaps a neighbour's target:
the flat 8px expansion (first match in DOM order on overlap) gives way to
the shared hit area, resolveHitTarget (#8635), as the tracker's
resolveTarget under a coarse pointer: each action's area grows toward 44px,
each side capped at half the gap. The driver passes resolveTarget through;
a fine pointer keeps the actions' own bounds.

utils/hitArea.ts and its test are carried verbatim from #8635 (e297a6b)
until it lands; this branch takes #8635 instead once it is on main.
A page appearing with 200 on-screen switches took one 27 ms pass on a
4x-slowed CPU: a long task exactly as the page appears. After its one read
of the page, a pass now measures controls for up to 4 ms (at least one) and
carries the rest to the next frame; their insets stay 0, which is safe,
until measured. The new test fails on the previous head.
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…s target is a clamped coarse-only hit layer

Cindy's size ruling (b133633a's decision comment): no control grows for a
finger, and a 44 px target is an invisible hit area, only where the primary
pointer is coarse, that never overlaps a neighbour's target. The handle was
a 44 x 44 target for every pointer, so on rows under 44 px the targets of
adjacent handles overlapped.

- The handle is 28 px for every pointer, still taking only the lines'
  height in its row.
- Its `::after` hit layer grows by the insets the shared hit area writes
  where the primary pointer is coarse (`watchHitArea`, `writeHitInsets`),
  toward 44 px with each side capped at half the gap to a neighbouring
  target; 0 elsewhere.
- utils/hitArea.ts and its test are carried verbatim from meta#8635 (e297a6b),
  which lands first; identical files merge cleanly when it does.
- Failing-first test: under a coarse pointer the handle carries the hit
  insets.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…get is a clamped coarse-only hit layer

Cindy's size ruling (b133633a's decision comment on this pull request): the
hint's glyphs and Done no longer widen to 44 px under a coarse pointer, and
their `::after` no longer reaches 44 px tall regardless of its neighbours.
Each button's hit layer grows by the insets the shared hit area writes
where the primary pointer is coarse (`watchHitArea`, `writeHitInsets`),
toward 44 px with each side capped at half the gap to a neighbouring target,
so it never covers a row or handle beside the hint. utils/hitArea.ts comes
verbatim from meta#8635 (e297a6b). Failing-first test: under a coarse pointer
each hint button carries the hit insets.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
cixzhang added a commit that referenced this pull request Oct 27, 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.

Approval is on hold until the touch hit-area rule this utility encodes has governing spec: an engineering owner needs to provide or identify it (for example by landing spec:AST-094 FR42 from #8633), covering the 44 px coarse-pointer target, the half-gap clamp including diagonal neighbours, and one shared Core-internal owner for every control and group. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, which the author may do if they are an engineering owner. Independently of that decision, two fixes are needed in packages/core/src/utils/hitArea.ts:

  • Diagonal neighbours of different sizes get overlapping hit areas, and a touch nearer the neighbour can resolve to the farther target (for example resolveHitTarget(133, 126, …) on the test's own diagonal pair), contrary to the documented never-overlap and nearer-target guarantees.
  • watchHitArea keeps stale insets when a neighbour moves or appears without a resize of the control, its parent or the window, so the hit area can reach into the neighbour's own box.

[Automated review]

if (!intersects(grown(target, insets), neighbourHalf)) {
continue;
}
if (gap.x >= gap.y) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A diagonal neighbour cuts only the larger-gap side, and only when this target's own growth reaches the neighbour's half of the gap (line 122), so two diagonal targets of different sizes can decide differently. A 16 × 16 target at (100, 100) and a 32 × 20 target 12 px across and 14 px down get areas that overlap by 8 × 5 px, contrary to “the two areas never overlap” above. Even for the test's equal pair, resolveHitTarget(133, 126, …) returns the target 9.2 px away rather than the one 4 px away. Deciding the diagonal cut from the gaps alone (so both targets cut the same side), or cutting both facing sides, would close the overlap; an unequal-size diagonal test would catch it.

Comment thread packages/core/src/utils/hitArea.ts Outdated
Comment on lines +447 to +448
* measured; where it is, the element is measured while on screen, on resize
* (its own, its parent's, the window's), all in the shared per-frame pass.

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, with visibility and pointer changes, are the only re-measure triggers, so a neighbour that moves or appears without resizing the control, its parent or the window leaves the old insets in place. For example, a switch measured with its link far away keeps right: 10 after the link moves to 8 px away, reaching 2 px into the link's own box. Re-measuring on DOM mutations and scrolling, or checking at press start that the touch is nearer this control, would keep the insets current; a test that moves a neighbour without resizing the control would pin it.

… nearest target, and a moved neighbour re-measures

- clampedHitInsets decided a diagonal cut from this target's growth alone,
  so two diagonal targets of different sizes could decide differently and
  overlap (16 x 16 beside 32 x 20: 8 x 5 px). A neighbour now counts when
  either one, at full growth, would reach into the other's half of the
  gap, a decision both make the same way. That also stops a target short
  of half the gap to a neighbour that does not grow (a long link).
- resolveHitTarget gives a touch to the nearest target when it is within
  that target's growth, so corner touches between diagonal targets go to
  the nearer one.
- watchHitArea re-measures every control once the page's content settles
  after a change (text or children, 100 ms), since a neighbour can move or
  appear without a resize; a ResizeObserver's first notice of an element
  no longer measures it twice.

The new tests (an unequal diagonal pair, a corner touch, a long link, a
neighbour moved by a text change) fail on the previous head.
…re-checks a touch at its start, and watches only each control's parent

- spec:AST-094 FR24: while the primary pointer is fine, the watch now runs
  only its pointer query. Its IntersectionObserver, ResizeObserver, window
  resize listener, content observers and press listeners start when the
  pointer turns coarse and stop when it turns fine.
- At a touch's start on a watched control, it re-measures that control at
  once (one read) and writes the fresh insets. A touch outside the fresh
  area (a neighbour moved nearer since the last measurement) swallows that
  control's click with a one-shot capture listener, removed by the click
  or by the next touch; nothing is re-sent to the neighbour.
- Content changes re-measure only the controls whose parent changed (text
  or children, settled for 100 ms), not on every change anywhere on the
  page. A ResizeObserver's first notice of an element no longer measures
  it twice.
- The changeset no longer says no component uses it.

The new tests (a swallowed click, its one-shot removal by the next touch,
and no observer or listener under a fine pointer) fail on the previous
head.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
… counts observers and window listeners

spec:AST-094 FR24: a disabled switch now starts no hit-area watch, and
under a fine pointer the watch (from meta#8635's latest head, carried here)
starts nothing but its pointer query. The FR24 test now also counts
IntersectionObservers and window resize listeners: none under a fine
pointer, none for a disabled switch under a coarse one, one for an enabled
switch under a coarse one. It fails on the previous head.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
cixzhang added a commit that referenced this pull request Oct 27, 2026
@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

Choose a reason for hiding this comment

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

Approval is on hold until the touch hit-area rule this utility encodes has governing spec: an engineering owner needs to provide or identify it (for example by landing spec:AST-094 FR42 from #8633), covering the 44 px coarse-pointer target, the half-gap clamp including diagonal neighbours, and one shared Core-internal owner for every control and group. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, which the author may do if they are an engineering owner. Independently of that decision, packages/core/src/utils/hitArea.ts needs these fixes:

  • watchHitArea only considers neighbours within 22 px, so targets under 22 px grow past the midpoint: two 8 px dots 24 px apart get hit areas that overlap by 12 px.
  • Diagonal neighbours of complementary shapes, such as a 32 × 32 and a 40 × 24 target 4 px across and 12 px down, still get overlapping areas.
  • A click swallower left by a touch that produced no click cancels the next keyboard activation (a click with detail 0).

[Automated review]

Comment thread packages/core/src/utils/hitArea.ts Outdated
candidates: ReadonlyArray<Candidate>,
): {target: HitRect; insets: HitInsets} {
const target = plainRect(watch.element);
const reach = Math.max(0, watch.minSize / 2);

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 band is minSize / 2 (22 px), but clampedHitInsets counts a neighbour at any gap under twice the larger growth, which is more than 22 px for a target under 22 px (36 px for an 8 px dot). Neighbours 22 px or more away are dropped here, so such targets keep their full growth past the midpoint: two watched 8 px dots 24 px apart each get 18 px toward the other instead of 12, overlapping by 12 px. Widening the band to minSize fixes it; a live test with a small target and a neighbour 22–44 px away would pin it.

Comment thread packages/core/src/utils/hitArea.ts Outdated
Comment on lines +132 to +134
const crosses =
intersects(grown(target, full), grown(rect, half)) ||
intersects(grown(rect, growth(rect, minSize)), grown(target, half));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A diagonal neighbour counts only when one target's full growth reaches the other's half of the gap on both axes. A target that grows mostly sideways and one that grows mostly vertically can each pass half the gap on one axis only, so neither cuts while their full areas still meet: a 32 × 32 target and a 40 × 24 target 4 px across and 12 px down overlap by 4 × 4 px, contrary to “the two areas never overlap” above. Also counting a neighbour whenever the two full-growth areas intersect, then cutting the larger-gap side as now, closes it.

Comment on lines +439 to +450
const swallow = (click: Event) => {
click.preventDefault();
click.stopPropagation();
disarm();
};
const disarm = () => {
element.removeEventListener('click', swallow, true);
if (entry.disarm === disarm) {
entry.disarm = null;
}
};
element.addEventListener('click', swallow, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the next click or the next pointerdown removes this swallower, so a touch that ends without a click (one the browser takes for a scroll) leaves it armed, and a later keyboard activation, a click with detail 0 and no pointerdown, is cancelled and stopped. Letting detail === 0 clicks through and disarming on pointercancel would keep keyboard and assistive-technology activation working, as spec:AST-094 FR21 requires for clicks a touch gesture holds.

element,
minSize,
onInsets,
isVisible: isCoarse && visibility == null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the element already has a watch, this new one starts with isVisible false and is neither observed again nor scheduled (lines 638–643), so it hears no insets until the next visibility change, resize, content change or touch. Copying isVisible from the entry's existing watch and scheduling it would fix the multi-watch path.

…s the diagonal pocket and lets keyboard clicks through

- The live watch read neighbours within 22 px only, but two small targets
  reach each other from up to 44 px apart: two 8 px dots 24 px apart kept
  18 px of growth toward each other. It reads within the minimum size now.
- A wide target and a tall one, diagonally, could each stay short of the
  other's half of the gap on one axis yet meet at full growth (32 x 32
  beside 40 x 24: 4 x 4 px). A neighbour now also counts when the two full
  areas meet.
- The one-shot click swallower lets a keyboard or assistive-technology
  activation (detail 0) through, and a cancelled touch (pointercancel)
  disarms it, so a touch the browser took for a scroll leaves nothing
  armed.
- A second watch of an element starts as visible as the first, so it hears
  its insets.

Each fix has a test that fails on the previous head.
… control's label included

The guard ended on its click, a cancelled touch, or the next press on the
control itself; a tap on the control's own label lands outside it, so a
touch that ended with neither a click nor a cancel could leave the label's
click to the input swallowed. While armed, the guard now also listens for
the next pointerdown anywhere in the document. The test's label-tap case
fails on the previous head.

@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 until the touch hit-area rule this utility encodes has governing spec: an engineering owner needs to provide or identify it (for example by landing spec:AST-094 FR42 from #8633), covering the 44 px coarse-pointer target, the half-gap clamp including diagonal neighbours, the touch-start guard that drops a stale tap, and one shared Core-internal owner for every control and group. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, which the author may do if they are an engineering owner. The earlier overlap, band and keyboard-click problems are fixed; independently of that decision, one fix is still needed in packages/core/src/utils/hitArea.ts:

  • A touch nearer an offset or diagonal neighbour can still land in a control's area, because the clamp cuts one side per neighbour and the touch-start check only tests the fresh area (on the tests' own diagonal pair, a touch 4 px from the second target and 9.2 px from the first goes to the first, while resolveHitTarget picks the second), so either swallow such taps at touch start or narrow the header's promise that a tap always goes to the nearer target.

[Automated review]

Comment thread packages/core/src/utils/hitArea.ts Outdated
for (const watch of entry.watches) {
const {target, insets} = measureOne(watch, candidates);
deliver(watch, insets);
isInside ||= contains(grown(target, insets), press.clientX, press.clientY);

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 only asks whether the touch is inside the fresh area, and clampedHitInsets cuts one side per neighbour, so a touch nearer an offset or diagonal neighbour can still be inside this control's area. On the test file's diagonal pair (hitArea.test.ts:85), a touch at (133, 126) is 4 px from the second target and 9.2 px from the first, yet it is inside the first's area and its click goes through, while resolveHitTarget picks the second; beside the test's long link (:59), the switch takes a touch at (135, 131), 5 px from the link and 11.4 px from the switch. Also swallowing the click here when a neighbour is nearer the touch than the control (the nearest test resolveHitTarget already makes) would keep the header's promise at lines 9–10 and 18–22; otherwise that promise should say only that two areas never overlap.

@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 until an engineering owner provides or identifies governing spec for the touch hit-area rule this utility encodes: the 44 px coarse-pointer target, the half-gap clamp including diagonal neighbours, the touch-start guard that drops a stale tap, and one shared Core-internal module for every control and group (spec:AST-094 has no such claim on main, and the FR42 proposed in #8633 covers only the first two). An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, which the author may do if they are an engineering owner. Whatever the decision, three fixes are needed in packages/core/src/utils/hitArea.ts:

  • In Chromium the touch-start guard swallows taps the browser hands to a watched control even when no neighbour is near (a touch 1 to 5 px past a lone switch's hit area gets pointerdown and no click), so it should swallow only when a neighbour is nearer the touch than the control, with a Chromium touch test to pin it.
  • A tap nearer an offset or diagonal neighbour can still land in the control's area (a touch 5 px from a link and 11.4 px from a switch toggles the switch), and the same nearest check would close that gap.
  • INTERACTIVE misses menuitemcheckbox, menuitemradio and treeitem rows, which Core renders with tabindex="-1", so a watched control grows into them.

[Automated review]

Comment thread packages/core/src/utils/hitArea.ts Outdated
deliver(watch, insets);
isInside ||= contains(grown(target, insets), press.clientX, press.clientY);
}
if (!isInside) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In Chromium this swallows taps the browser deliberately hands to this control. Touch adjustment sends a near miss to the nearest clickable element, so a touch 1 to 5 px past a lone watched switch's hit area arrives here as pointerdown, falls outside the fresh area, and has its click cancelled, while the same switch without the module takes that tap. Swallowing only when a neighbour measured in this pass is nearer the touch than the control (the test resolveHitTarget already makes) would keep the moved-neighbour case and stop dropping these taps; a Chromium touch test would pin it.

Comment on lines +214 to +235
const INTERACTIVE = [
'a[href]',
'area[href]',
'button',
'input:not([type="hidden"])',
'select',
'textarea',
'summary',
'label[for]',
'[contenteditable=""]',
'[contenteditable="true"]',
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="option"]',
'[role="slider"]',
'[tabindex]:not([tabindex="-1"])',
].join(',');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

menuitemcheckbox, menuitemradio and treeitem are missing here, and Core renders those rows with tabindex="-1" (DropdownMenuCheckboxItem.tsx, DropdownMenuRadioItem.tsx, and the roving tabindex in TreeListItem.tsx), so they never count as neighbours. A watched control 8 px from such a row keeps a 10 px inset and reaches 2 px into the row. Adding those roles keeps the scan in step with [role="menuitem"] and [role="option"].

…control, and a watched neighbour counts by its own box

- A browser's own touch adjustment hands a control taps a few px past its
  box; the touch-start guard swallowed any outside the fresh area, so a
  lone switch lost taps 1 to 5 px past its hit area. The guard now swallows
  only when a neighbour is nearer the touch than the control.
- A neighbouring watched control was read by its interactive part, which
  for a Switch is its invisible input grown by its own hit area, so one
  press shrank a stacked switch's area from half the gap to a quarter. A
  watched control now counts as a neighbour by its own box.
- A test pins that the document listener goes when the last watch stops.

The two behavior tests fail on the previous head.

@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 until an engineering owner provides or identifies governing spec for the touch hit-area rule this utility encodes: the 44 px coarse-pointer growth, the half-gap clamp including diagonal neighbours, the touch-start guard that cancels a tap, and one shared Core-internal module for every control and group (spec:AST-094 has no such claim on main, and the FR42 proposed in #8633 covers only the first two). An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, which the author may do as an engineering owner. Whatever the decision, two fixes are needed in packages/core/src/utils/hitArea.ts:

  • A tap nearer an offset or diagonal neighbour still lands in the control's area and toggles it (in Chromium, a touch 5 px from a link and 11.4 px from a switch toggled the switch), because the touch-start check keeps every touch inside the fresh area; run the nearer-neighbour test for every touch, or promise only that two areas never overlap.
  • INTERACTIVE misses menuitemcheckbox, menuitemradio and treeitem rows, which Core renders with tabindex="-1", so a watched control grows into them.

[Automated review]

Comment thread packages/core/src/utils/hitArea.ts Outdated
Comment on lines +493 to +496
if (contains(grown(target, insets), x, y)) {
isNearerNeighbour = false;
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A touch inside the fresh area leaves the loop here before the nearer-neighbour test, and the clamp cuts only one side per neighbour (:143–155), so corners of the area beside an offset or diagonal neighbour stay this control's even where the neighbour is nearer. In Chromium, beside the tall link of hitArea.test.ts:60–65, a touch 5 px from the link and 11.4 px from the switch toggled the switch; on the diagonal pair of :86–95, resolveHitTarget(133, 126, …) returns the second target while this path keeps the touch in the first's area. Running the isNearerNeighbour test for every touch (cancelling when a measured neighbour is nearer than the control) would keep the header's promise at :21–22; otherwise that promise should say only that two areas never overlap.

…ide the area too, and tree and menu items count

- The touch-start check kept every touch inside the fresh area, but the
  clamp cuts one side per neighbour, so a corner beside an offset or
  diagonal neighbour could still be this control's while the neighbour was
  nearer. The check now asks of every touch whether a neighbour is nearer
  than the control.
- Rows with role treeitem, menuitemcheckbox or menuitemradio, and combobox,
  textbox, searchbox and spinbutton, now count as neighbours.

Both new tests fail on the previous head.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec c258015

@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 recommended. An engineering owner's exact-head /approve-spec now records the decision on the touch hit-area rule, and the two earlier fixes (cancelling a tap a neighbour is nearer inside the area, and counting menuitemcheckbox, menuitemradio and treeitem rows as neighbours) are in place and hold up in Chromium with real touch input. Two follow-ups, neither blocking: the JSDoc above onPress in packages/core/src/utils/hitArea.ts still describes the earlier outside-the-area check, and the first component that adopts the module should amend architecture:react-component-runtime INV13, whose list of shared touch modules does not include it.

[Automated review]

Comment on lines +455 to +459
* At a touch's start, re-measure the control it landed on, at once: if the
* touch is outside the fresh area (a neighbour moved nearer since the last
* measurement), the control's click is swallowed. The tap then does
* nothing rather than toggle the wrong control; nothing is re-sent to the
* neighbour, and the next tap uses the fresh insets.

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 JSDoc still describes the previous check. The click is now cancelled whenever a measured neighbour is nearer the touch than the control, inside the fresh area or past it, and a touch outside the area with no nearer neighbour stays the control's (checkPress, lines 493–502). Updating it keeps the Switch and press-group adoptions from reading the old rule.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…a#8076 cba0ad9) into this change

List FR10 keeps every row's geometry and gives it an invisible hit area
(meta#8085's utils/touchHitArea); this change's CheckboxList row floors are
dropped in the merge (List/listRow.stylex.ts is meta#8085's to remove).
@vjeux

vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

List's touch hit area (List FR10) builds on this module, and needs one more thing from it, with an offer.

The watch measures rows mid-motion. A row sliding in its list moves by transform, and its client rect reads where one frame puts it. In List's removal, the watch re-measured the rows about 100 ms into the slide and wrote fractional insets (1.5 px and 1.86 px): Chromium's trace shows 1 Layout and 2 Paints inside what is otherwise a compositor-only motion.

The offer, one commit on this head: vjeux/astryx@c258015...d3a7d99 (branch uikit/c11-8635-measure-at-rest).

  • A pass skips a control whose parent (the control included) has a finite animation running or held, and measures it once those end; an endless one, such as a spinner, holds nothing.
  • A case: mid-slide, no read and no write; afterwards, the at-rest insets. The same change on this module at e546b5e, with List's own guard taken out, clears List's removal trace (no Layout or Paint inside the motion) and passes List's hit-area proofs, twice in Chromium.
  • hitArea.test.ts: 39 passed; the new case fails without the change.

Your watched-owner change in 2c74369 already covers the other case List hit (two rows 2 px apart overlapping by 6 px, measured through their inner controls): that case passes on this head unchanged. Until this module measures at rest, List guards against it on its side. Take the commit, write your own, or say what you'd prefer instead.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
 9affd96 and meta#8076's guard) under the List reorder

Wave 2: the base re-folded onto main. Two resolutions:
- utils/hitArea.ts and its test take the base's copy (meta#8635 at
  bdab881, through the list stack's meta#8085), which observes nothing
  under a fine pointer and listens for presses once, on the document.
- ListItem.tsx keeps both sides: the list stack's shared touch hit area
  for an interactive row (useTouchHitArea, its fixed touch row heights
  gone) and this PR's trailing reorder handle, its row style and the
  handle-less row's name.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
spec:AST-094 FR24: the shared touch hit area this PR now takes from its
base (meta#8635 at bdab881, through the list stack) observes nothing, and
adds no timer or listener, while the primary pointer is fine. This pins
it for the reorder handle: with recording observers, a fine pointer
leaves the handle unobserved and adds no window resize listener; a
coarse one, its positive control, observes it. Against the hit area this
PR carried before, it fails.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…inter

spec:AST-094 FR24: the shared touch hit area (meta#8635 at bdab881,
through meta#8139's base) observes nothing, and adds no timer or listener,
while the primary pointer is fine. This pins it for reorder mode's hint
buttons: with recording observers, a fine pointer leaves them
unobserved; a coarse one, its positive control, observes them. Against
the hit area this stack carried before, it fails.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…rged) so this pull request's compare shows only its own change
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…'s current heads) into the toggle group's tracking
@cixzhang
cixzhang merged commit 844d734 into main Oct 27, 2026
68 checks passed
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…re-folded onto main with meta#8635) under the button group's tracking
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…lded onto main with meta#8635) into the toggle group's tracking
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…values

C11b's meta#8106 is re-folded onto main after meta#8635 merged (its shared hit area
byte-identical to main's): this takes it, so meta#8138 is merge-tree clean
against main again.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…#8106 000804f) under the List reorder

The list stack is re-folded onto main after meta#8635 merged: its shared hit
area is byte-identical to main's.
@github-actions
github-actions Bot deleted the feat/core-hit-area branch October 28, 2026 06:17

This branch was successfully deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants