Repository navigation
chore(core): a shared touch hit area that grows toward 44 px and stops at half the gap to each neighbour - #8635
Conversation
…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).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
…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.
PR Analysis Report
|
…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>
…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>
There was a problem hiding this comment.
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. watchHitAreakeeps 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) { |
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
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.
… 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>
There was a problem hiding this comment.
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:
watchHitAreaonly 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
detail0).
[Automated review]
| candidates: ReadonlyArray<Candidate>, | ||
| ): {target: HitRect; insets: HitInsets} { | ||
| const target = plainRect(watch.element); | ||
| const reach = Math.max(0, watch.minSize / 2); |
There was a problem hiding this comment.
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.
| const crosses = | ||
| intersects(grown(target, full), grown(rect, half)) || | ||
| intersects(grown(rect, growth(rect, minSize)), grown(target, half)); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
resolveHitTargetpicks 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]
| for (const watch of entry.watches) { | ||
| const {target, insets} = measureOne(watch, candidates); | ||
| deliver(watch, insets); | ||
| isInside ||= contains(grown(target, insets), press.clientX, press.clientY); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
pointerdownand 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.
INTERACTIVEmissesmenuitemcheckbox,menuitemradioandtreeitemrows, which Core renders withtabindex="-1", so a watched control grows into them.
[Automated review]
| deliver(watch, insets); | ||
| isInside ||= contains(grown(target, insets), press.clientX, press.clientY); | ||
| } | ||
| if (!isInside) { |
There was a problem hiding this comment.
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.
| 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(','); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
INTERACTIVEmissesmenuitemcheckbox,menuitemradioandtreeitemrows, which Core renders withtabindex="-1", so a watched control grows into them.
[Automated review]
| if (contains(grown(target, insets), x, y)) { | ||
| isNearerNeighbour = false; | ||
| break; | ||
| } |
There was a problem hiding this comment.
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.
|
/approve-spec c258015 |
There was a problem hiding this comment.
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]
| * 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. |
There was a problem hiding this comment.
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.
|
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
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. |
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.
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.
…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.
…re-folded onto main with meta#8635) under the button group's tracking
…) under the reorder mode hint
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
Change
packages/core/src/utils/hitArea.ts(not exported from@astryxdesign/core/utils; a test checks):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:spec:AST-094FR24);spec:AST-103FR5 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
hitArea.test.ts(37 tests):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.
Scope
Testing
vitest run packages/core/src/utils/hitArea.test.ts: 37 passed.tsc --noEmit -p packages/coreand eslint: clean.