Skip to content

Touch press model: a delegated controller replaces :active under a coarse pointer - #6383

Open
vjeux wants to merge 5 commits into
facebook:mainfrom
vjeux:touch-press-controller
Open

vjeux wants to merge 5 commits into
facebook:mainfrom
vjeux:touch-press-controller

Conversation

@vjeux

@vjeux vjeux commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Built on #6380 (pressed state for eight components), which is now on main (6203871). Rebased onto it: this PR carries only the controller stack — Touch press model: a delegated controller replaces :active under a coarse pointer, Press release fades over the release clock instead of stepping, and three small fixes. #6380 landed the checkbox and radio pressed paint on the owner (an ::after layer on CheckboxInput's and RadioListItem's indicator wrapper, read off indicatorScope) rather than on the Indicator components, so the touch arms live there too: that layer drops its :active under a coarse pointer and paints the data-pressed arms at the row's strength.

User need

On a phone, a press on an Astryx control paints with CSS :active, and on iOS Safari :active is not a press. Measured on an iPhone simulator driving the real Settings and Contacts apps and an instrumented page with real HID touches (the agentcloud client's spec 278):

Moment Native (UIKit) list cell CSS :active in iOS Safari
Touch lands nothing yet; a 0.15 s timer starts painted within 6–14 ms
Finger travels ~10 px highlight cancelled 45 ms before the list moves pointercancel fires; :active may or may not drop
List scrolling under the finger nothing painted, all gesture long still painted 1.6 s into the scroll
20 ms tap paint appears at the lift and stays :active for 47 ms, then bare
1.5 s hold painted for 1.34 s (0.15 s delay) painted from the touch

So a flick flashes every row it crosses, a drag paints a stripe that hops down a virtualized list, and a real press paints no sooner than a flick. Everyone building a touch surface on Astryx gets this, and no composition fixes it: the paint is CSS state the page cannot reach. This is the second of two PRs from the agentcloud client's spec 305 (the first, #6380, gives the eight components without a pressed state one); after it, every Astryx control presses correctly under a finger, with no consumer wiring.

Problem and solution fit

Three clocks decide everything, and they are UIKit's own: a delay before the press is believed (delaysContentTouches, 0.15 s), a distance after which the gesture is a scroll (10 px), and a fade after the finger lifts (0.2 s, the deselect crossfade), plus a 0.1 s flash for a tap too short for the delay and a 0.15 s brake for a touch that stops a gliding list. A pure state machine (utils/pressGesture.ts) carries them; one document-level controller (utils/pressFeedback.ts) drives it from pointerdown / pointermove / pointerup / pointercancel / touchcancel / dragstart / capture-phase scroll / visibilitychange, finds the innermost data-astryx-pressable element under the touch, and writes data-pressed="on" once the press is believed and data-pressed="fading" for the release. pointercancel is the primary cancel: measured, it fires before the first scroll event, which puts the cancel one frame ahead of the list's motion the way UIKit's touchesCancelled is. Under @media (pointer: coarse) the bare :active arm is dropped; a mouse keeps :active untouched.

Why delegated and not a hook per element: a virtualized list can carry more than a thousand rows, and four listeners plus a state hook per row is not a cost such a list can pay. This is six listeners per document; a press costs one attribute write on one element, no React render (a regression test asserts no listener is added to a row). It also makes the behaviour uniform for free: usePressFeedback() marks a surface and installs the controller (counted; the first mount installs, the last unmount removes), and every surface that paints a press calls it.

Current specification

None in this repository settles touch press timing; interactionOverlay.stylex.ts is the current owner of the pressed state and is extended in place (its header documents the two pointers). The measurements and the model are the agentcloud client's spec 278 ("iOS list press state"); this PR carries its constants and its ported unit tests.

Public delta and caller burden

  • No caller changes for Astryx components. Every component that paints a press now carries data-astryx-pressable; the controller installs itself on first mount. Under a finger the pressed overlay follows the model above; under a mouse nothing changes.

  • New public API (@astryxdesign/core/hooks, @astryxdesign/core/utils) for a local component that paints its own press: usePressFeedback() returns {'data-astryx-pressable': ''} to spread on the painting element; installPressFeedback(), pressableProps, PRESSABLE_ATTRIBUTE, PRESSED_ATTRIBUTE, the clocks (PRESS_ONSET_MS, PRESS_SLOP_PX, PRESS_FLASH_MS, PRESS_FADE_MS, PRESS_SCROLL_BRAKE_MS), pressVars (the strength variable, below) and interactionOverlayStyles (compose its pressedAlpha on the marked element). The caller styles [data-pressed="on"] and [data-pressed="fading"] as the pressed token at var(--astryx-press-alpha) strength and drops its :active under a coarse pointer, the way the built-in components do; hooks/usePressFeedback.doc.mjs documents it.

  • CSS arms. interactionOverlayStyles.* (all variants) and every component with its own :active rule gain the two touch arms and drop the bare :active under @media (pointer: coarse). The touch paint goes through ONE number, --astryx-press-alpha: a registered custom property (@property … syntax: "<number>"; initial-value: 0, declared once in the shipped stylesheet by stylex.defineVars + stylex.types.number), read by the overlay as linear-gradient(color-mix(in srgb, var(--color-overlay-pressed) calc(var(--astryx-press-alpha) * 100%), transparent), …). Gradients do not interpolate; a registered number does, and every declaration reading it re-resolves on each frame.

    Arm Strength Paint Motion
    rest 0 none
    :active (mouse) the pressed token, as before composer's own
    :active under (pointer: coarse) dropped
    [data-pressed="on"] 1 the pressed token at strength 1, as a background image (discrete: no composer transition can fade the onset in) none — first frame
    [data-pressed="fading"] declared 1; the release animation takes it 1 → 0 the same paint animation: 200ms cubic-bezier(0, 0, 0.2, 1) both on the strength
    attribute removed (fade end, or a cancel) 0 none none — a cancel steps off

    The release lands on nothing, not on the hover strength: under a finger there is no hover. ClickableCard, SelectableCard and Thumbnail drive their ::after overlay through a private custom property (--_press-overlay, documented) that the touch arms set to the same color-mix (their layer's own colour transition is held at 0s on both touch arms); Switch's track and thumb, Tab's hover layer and the owner-drawn ::after layer over CheckboxInput's and RadioListItem's indicator paint the same image off their owner's scope marker and inherit the owner's strength — the owner (the row, the tab) composes interactionOverlayStyles.pressedAlpha, which is the strength and the release with no paint of its own. On the colour variants the colour arms yield (transparent) while the controller paints, so a touchscreen beside a mouse (which matches :active and the emulated :hover under a finger too) cannot paint the press twice.

  • Supporting delta — DropdownMenuItem gains href (+ target, rel). A navigation row renders as the anchor itself (<a role="menuitem">, through the LinkProvider component), so modifier and middle clicks open it in a new tab, the address shows in the status bar and can be copied; a plain click still fires onClick and closes the menu; a disabled row drops the address. Item renders a roled row with href as the anchor for the same reason (before, href was ignored in parent-role mode).

  • Supporting delta — Item gains isUnread and swipeActions. isUnread sets the semibold label weight and the primary description colour and exposes an unread state on the item theme target for the row's ground (no colour is decided at the call site). swipeActions ({leading, trailing?}, each {label, icon?, onAction, tone?}) is a touch-only horizontal swipe: the axis is decided at 10 px of travel (a vertical drag stays the scroller's), the panel behind grows with the travel, release past a third of the row (72–160 px) or a fling fires the action after the row slides out, short of it the row springs back. With it set, the row is wrapped in a plain clipping container; ref, role, aria-*, draggable and the drag handlers still land on the row (tested). Types ItemSwipeAction, ItemSwipeActions, ItemSwipeActionTone are exported.

Design and alternatives

  • Rejected: keeping :active and tuning it. Not recoverable in CSS: the instant paint and the survival into a scroll are the platform's :active semantics.
  • Rejected: a hook per pressable element. Per-row listeners and state in a virtualized list (see the cost argument above); a delegated controller also gives every surface the same behaviour.
  • Rejected: requestAnimationFrame for the onset delay. iOS throttles frame callbacks during a scroll; a frame-based delay fires late and paints a row nobody is pressing. The delay is a timer, the cancel is an event.
  • Rejected: cancelling on scroll alone. It arrives one frame after the motion; pointercancel leads it (measured), so it is the primary cancel and scroll the backstop. The brake is deliberately narrow: only a scroll driven by a finger inside the same scroller within the last 3 s counts, so a transcript scrolling itself, or a tap somewhere else, never swallows a press.
  • The release is a registered-property animation, not a transition and not a shadow. Three mechanisms were candidates for making a gradient overlay fade. (1) An inset box-shadow transitioning to transparent (the agentcloud client's own recipe) is out because composers own box-shadow for elevation, selection rings and focus (Button, SegmentedControlItem, Slider, SelectableCard, Thumbnail), and StyleX is last-wins per property. (2) An opacity transition on a dedicated overlay pseudo-element only exists on the three cards. (3) A registered <number> custom property that the overlay's color-mix() reads works for every carrier without changing the paint at rest, so it is the one mechanism. It is driven by a keyframes animation rather than a transition for two reasons: composers own their transition-* longhands (Button, Item, Tab, Link, …), which the utility can neither append to nor override without taking the whole property from them, while no composer sets animation-* on the element that composes it; and a transition needs the declared value to change at the style change, which would make every property reading the strength change computed value at the lift — Button, Token and TreeListItem transition background-image, and Chromium interpolates same-shape gradients, so their 175 ms fade would run over this one. With keyframes owning both ends, the fading arm declares strength 1 like the on arm and only the animation moves; the Chromium trace below follows the release curve exactly, with Button's own transition present. The curve is a gentle ease-out (cubic-bezier(0, 0, 0.2, 1), the reference implementation's) rather than the system's --ease-standard, which is so front-loaded (strength 0.05 at the midpoint) that the release would read as half its clock. Engines without @property (Safari < 16.4, Firefox < 128) animate the unregistered value discretely — the paint holds for half the release, then goes — a step, no worse than before.

Evidence

  • utils/pressGesture.test.ts: one case per requirement of the model (FR1–FR9 of the spec: nothing before the delay; instant full paint on onset; travel cancels and a shaky hand does not; the scroller's claim cancels with no fade; a cancelled gesture is dead until a new touch, including a late onset timer; a press after a scroll paints; a quick tap answers at the lift and is not clipped by the platform's late cancel; the release fades and ends idle).

  • utils/pressFeedback.test.ts: the paint lands on the element the finger landed on and never moves during a scroll; innermost wins; a mouse is ignored (paint and brake clocks); the brake (a finger-driven scroll within 150 ms) and its narrowings (programmatic scroll, another scroller, a touch elsewhere, a mouse release); pointercancel, scroll, travel and dragstart all cancel; no gesture ends painted (a jittering finger still gets its press; a cancel inside the flash or the fade still resolves; a sweep over every gesture shape); no listener is added to a row; install counting.

  • utils/pressableCoverage.test.ts: derives, from the source, the set of files that paint a press (':active key, [data-pressed arm, or interactionOverlayStyles.) and holds it equal to the set that calls usePressFeedback(), resolving ancestor-scoped arms (an indicator reading its owner row's scope marker) and styles-only modules to their owners. 33 files today.

  • Item.test.tsx: isUnread, swipeActions (reveal, commit past the point, spring back short of it, vertical drag left to the scroller, no trailing without a trailing action, mouse ignored, disabled row inert), and the passthrough of role/aria-*/draggable/drag handlers to the row with and without swipe actions. DropdownMenu.test.tsx: the href row is the anchor with the menu role through a custom LinkProvider component, target="_blank" gets rel, plain click fires onClick and closes, disabled drops href, and the action row is unchanged.

  • Storybook: Core/Press feedback (touch) — buttons, rows (a scrolling list, a menu with a navigation row, a card), the eight controls, and swipe actions — written for a coarse pointer (DevTools device toolbar or a phone).

  • The release fade, declared: utils/pressFeedback.test.ts reads the StyleX-injected rules for every overlay variant and for pressedAlpha and holds that the fading arm carries the release animation for exactly PRESS_FADE_MS (the CSS clock is compared to the machine's constant, not to a second 200), that the on arm animates nothing, that both touch arms paint the pressed token through var(--astryx-press-alpha) and declare it 1, and that no fading arm mentions the hover token. Button, Switch, TabList, CheckboxInput, RadioList, ClickableCard, SelectableCard and Thumbnail tests assert the same on their own elements (the owner runs the release; the painting descendant or ::after reads the strength; the RadioList row itself paints nothing, as under a mouse). scripts/build-css.test.mjs holds the shipped astryx.css: one @property --astryx-press-alpha { syntax: "<number>"; … initial-value: 0 }, the 1 → 0 keyframes on the fading arm at PRESS_FADE_MS, and every touch-arm paint reading the strength.

  • The release fade, in an engine (pnpm guard:press-release-fade, .github/scripts/press-release-fade.js, alongside the existing guard:pressed-overlay): Playwright Chromium 149 (headless shell), touch emulation (pointer: coarse matched, hover: hover not), the built Storybook's Buttons story, the first Button (primary; it transitions background-image itself). The attribute is written the way the controller writes it and the overlay's alpha is read off the computed background-image on every frame:

    --astryx-press-alpha overlay alpha
    rest 0 none
    on, first frame 1 0.1020 (the pressed token's own alpha — full on the first frame, no fade-in; unchanged when held)
    fading, t = 0.0 ms 1.0000 0.1020
    fading, t = 100.1 ms 0.1608 0.0164
    fading, t = 199.9 ms 0.0000 0.0000
    attribute removed 0 none

    Whole trace, 17 frames (ms:alpha): 0:0.102 17:0.075 33:0.057 50:0.043 67:0.032 83:0.023 100:0.016 117:0.011 133:0.007 150:0.004 167:0.002 183:0.000 200:0.000 … — three distinct, monotonically falling samples, the pressed strength at the start, nothing at the clock, and no bump where Button's own 175 ms background-image transition would have taken over. Two runs, identical to the fourth decimal. WebKit could not be run on the build box (Playwright's WebKit needs system libraries the box lacks and cannot install); Safari 16.4+ has @property, color-mix() and custom-property animation, so it is expected to match, and is the one thing left for a device pass.

Scope

  • One capability is added: the touch press model, reaching every pressable surface.
  • The two supporting deltas (DropdownMenuItem href, Item isUnread/swipeActions) are the pieces the adopting consumer needs from the same components; each is small and tested, and called out above.
  • Consumer docs (hook doc, component docs, changeset), focused tests and stories ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm -F @astryxdesign/core test         # full suite from the repo root (core's script runs vitest at the root)
pnpm -F @astryxdesign/core lint         # 0 errors (pre-existing warnings only)
pnpm -F @astryxdesign/core typecheck
pnpm -F @astryxdesign/core typecheck:docs
pnpm check:changesets && pnpm check:sync && pnpm check:knowledge
pnpm storybook:build && pnpm exec playwright install chromium && pnpm guard:press-release-fade   # the engine trace above

@vercel

vercel Bot commented Sep 19, 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 Sep 20, 2026 5:48am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 19, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Sep 19, 2026
github-actions Bot added a commit that referenced this pull request Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Calendar (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1479 -
Complexity N/A Very High (138) -
CheckboxInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 358 -
Complexity N/A Very High (42) -
ClickableCard (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 220 -
Complexity N/A Medium (14) -
DateInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 3308 -
Complexity N/A Very High (359) -
Item (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 928 -
Complexity N/A Very High (86) -
Outline (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 676 -
Complexity N/A High (28) -
SelectableCard (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 289 -
Complexity N/A High (28) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: 36 accessibility violation(s) found — 2 critical, 34 serious.

Button pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/18 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ChatComposer - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/18 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ChatComposerInput - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/14 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ChatReasoning - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 4/5 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ChatToolCalls - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 10/11 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Checkbox pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/33 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
CheckboxList - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 4/17 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ClickableCard - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/5 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
CodeEditor - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/7 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
CodeEditorPerf - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/2 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
CodeEditorTheme - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 13/14 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
CodeTheme - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 14/16 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
DateRangeInput - 1 issue(s)
  • 🔴 critical: Ensure an element's role supports its ARIA attributes
    • Rule: aria-allowed-attr · Affects 1/21 stories · Learn more
    • WCAG: 4.1.2 (Level A)
FileInput - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/15 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
GridMasonry - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/3 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Heading - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/16 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Icon - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/18 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
LogStream - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/3 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
MediaTheme Auto - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/5 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
PowerSearch - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/26 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ProgressBar - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/18 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Radio group pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/26 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
RadioList - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/14 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
RichTextEditor - 2 issue(s)
  • 🟠 serious: Ensure every ARIA input field has an accessible name
    • Rule: aria-input-field-name · Affects 2/18 stories · Learn more
    • WCAG: 4.1.2 (Level A)
  • 🔴 critical: Ensure every form element has a label
    • Rule: label · Affects 1/18 stories · Learn more
    • WCAG: 4.1.2 (Level A)
SelectableCard - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/4 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Stepper - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/30 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
TableGroupedRows - 1 issue(s)
  • 🟠 serious: Ensure ARIA attributes are used as described in the specification of the element's role
    • Rule: aria-conditional-attr · Affects 3/3 stories · Learn more
    • WCAG: 4.1.2 (Level A)
TableTree - 1 issue(s)
  • 🟠 serious: Ensure ARIA attributes are used as described in the specification of the element's role
    • Rule: aria-conditional-attr · Affects 8/9 stories · Learn more
    • WCAG: 4.1.2 (Level A)
Text - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/23 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Theme - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/5 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
PopArt - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/6 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Thumbnail - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/12 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Timestamp - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/20 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Token - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/10 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Tokenizer - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/24 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

Status: Skipped — Broad stable scope is deferred to the daily release gate. It covers 386 trusted baseline shots instead of recapturing them for this PR. View the report


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

…arse pointer

On iOS Safari `:active` is not a press: measured against real HID touches, it
paints within 14 ms of the touch and was still painted 1.6 s into a scroll, so
a flick flashes every row it crosses and a drag paints a stripe. A native
list waits 0.15 s, cancels the moment a scroll claims the gesture, never
brings the paint back inside that gesture, answers a quick tap at the lift
and fades the release over 0.2 s.

This ports that model into the design system:

- utils/pressGesture.ts: the pure state machine and its clocks (150 ms onset,
  10 px slop, 100 ms flash, 200 ms fade, 150 ms scroll brake)
- utils/pressFeedback.ts: one document-level controller (six listeners for
  the whole document; a press costs one attribute write) that finds the
  innermost `data-astryx-pressable` element and writes `data-pressed="on"`
  once the press is believed and `data-pressed="fading"` for the release;
  scroll, `pointercancel`, `touchcancel` and `dragstart` cancel; a touch on a
  list still gliding under a finger of its own is a brake, not a press; a
  mouse is ignored; disabled surfaces paint nothing
- hooks/usePressFeedback.ts: marks a surface and installs the controller,
  counted, on first mount; public, with the attributes and clocks, for a local
  pressable
- interactionOverlay.stylex.ts: the two `data-pressed` arms on every variant
  (the on-arm paints as a background image too, so a colour transition
  cannot delay it), and the bare `:active` arm dropped under
  `@media (pointer: coarse)`; every component with its own `:active` rule
  gets the same in its own stylex, and every surface that paints a press
  carries the marker (pressableCoverage.test.ts holds the two sets equal,
  deriving both from the source)
- ClickableCard, SelectableCard, Thumbnail drive their `::after` overlay
  through a custom property so the touch arms can outrank the mouse arm

Riders:

- DropdownMenuItem gains `href` (+ `target`/`rel`): the row renders as the
  anchor itself, with the menu role, through the LinkProvider, so modifier
  and middle clicks open it in a new tab; Item renders a `role`d row with
  `href` as the anchor for the same reason
- Item gains `isUnread` (semibold label, primary description, an `unread`
  state on the `item` theme target for the ground) and `swipeActions`
  (touch-only leading/trailing panels revealed by a horizontal drag, fired
  past a commit point or by a fling), plus a test that `role`, `aria-*`,
  `draggable` and the drag handlers reach the row

Tests: pressGesture.test.ts (one case per requirement of the model),
pressFeedback.test.ts (brake, nothing ends painted, innermost wins, mouse
ignored, install counting), pressableCoverage.test.ts, and the riders'.
Storybook: a touch gallery under Core/Press feedback (touch).
Under a finger, `[data-pressed="fading"]` stepped the pressed overlay down to
the hover strength: the overlay is a gradient layer, and gradients do not
interpolate, so the 200 ms release the controller times (PRESS_FADE_MS, UIKit's
deselect crossfade) painted as a step on every surface but the cards.

The touch paint now goes through one number, `--astryx-press-alpha`: a
registered custom property (`@property … syntax: "<number>"; initial-value: 0`,
declared once in the shipped stylesheet by `stylex.defineVars` +
`stylex.types.number`) that the overlay reads as
`linear-gradient(color-mix(in srgb, pressed calc(alpha * 100%), transparent), …)`.
`on` sets the strength to 1 and paints, on the first frame (an image change is
discrete). `fading` keeps the paint and runs a keyframes animation 1 → 0 over
200 ms (`cubic-bezier(0, 0, 0.2, 1)`); the controller removes the attribute on
the same clock. The release lands on nothing, not on the hover strength: under
a finger there is no hover to land on.

An animation, not a transition: StyleX is last-wins per property and composers
own their `transition-*` longhands (Button, Item, Tab, Link, …), which the
utility can neither append to nor override without taking the whole property;
no composer sets `animation-*` on the element that composes it. And with the
keyframes owning both ends, the fading arm declares 1 like the on arm, so
nothing a composer transitions changes computed value at the lift — Button,
Token and TreeListItem transition `background-image`, and Chromium interpolates
same-shape gradients, so their own 175 ms fade would otherwise run over this
one. The curve is a gentle ease-out rather than `--ease-standard`, which is so
front-loaded that the release would read as half its clock.

Every carrier, one mechanism:

- interactionOverlayStyles.* (all variants) carry the strength, the release
  and the image arms; the colour variants' colour arms yield (`transparent`)
  while the controller paints, so a touchscreen beside a mouse cannot paint the
  press twice. New `pressedAlpha`: the strength and release alone, for an owner
  whose paint lives elsewhere.
- ClickableCard, SelectableCard, Thumbnail: `--_press-overlay` is the same
  color-mix on both touch arms, the layer's own colour transition held at 0s
  there; the card composes `pressedAlpha`.
- Switch (row → track and thumb), Tab (tab → hover layer, now an image so the
  layer's colour transition cannot fade the onset in), CheckboxIndicator and
  RadioIndicator (row → indicator): the owner composes `pressedAlpha`, the
  descendant paints the image at the inherited strength.
- RadioListItem's row suppresses the utility's touch image the way it already
  suppressed the colour (only the circle paints, as under a mouse); a selected
  nav item paints no overlay on the fading arm either.

Public API: `pressVars` and `interactionOverlayStyles` from
`@astryxdesign/core/utils`, so a local pressable fades on the same clock; the
hook doc says how.

Tests: pressFeedback.test.ts holds every variant's fading arm to the release
animation at exactly PRESS_FADE_MS (compared to the machine's constant, not a
second 200), the on arm animation-free, both touch arms reading the strength at
1, and no hover token on the release. Button, Switch, TabList, CheckboxInput,
RadioList, ClickableCard, SelectableCard and Thumbnail assert the same on their
own elements. scripts/build-css.test.mjs holds the shipped CSS: one @Property
registration, the 1 → 0 keyframes on the fading arm at the clock, every
touch-arm paint through the strength. `pnpm guard:press-release-fade`
(.github/scripts/press-release-fade.js) drives the built Storybook's Buttons
story in Playwright Chromium under touch emulation and samples the overlay on
every frame: alpha 0.1020 → 0.0164 (100 ms) → 0.0000 (200 ms), monotonic, full
on the first frame of `on`, nothing left after the attribute goes.
React's act() returns a thenable even for a synchronous callback, so the
five `act(() => vi.advanceTimersByTime(250))` calls in the swipeActions
tests failed @typescript-eslint/no-floating-promises in the root lint
(`pnpm lint`). The callbacks are synchronous and act flushes their updates
before returning, so the thenable is intentionally ignored with `void`, as
BottomSheetPanel.test.tsx does for the same shape.
…der next/babel

The sandbox build (`pnpm -F @astryxdesign/sandbox build`) failed to
compile utils/interactionOverlay.stylex.ts: @stylexjs/babel-plugin's
transformStyleXCreate threw "Referenced constant is not defined." on an
internal (synthetic) node.

Cause: the five overlay variants spread a hoisted `touchPress` object
whose keys were computed (`[PRESS_ALPHA]`, `[PRESSED_ON]`,
`[PRESSED_FADING]`). StyleX evaluates a stylex.create call from the raw
AST when Babel enters the call, so computed keys written inside the call
are fine; but an object declared earlier in the file has already been
through every other plugin by then, and next/babel under the default
browserslist lowers its computed keys into `_defineProperty` helper calls
the StyleX evaluator cannot resolve. The sandbox has no browserslist (the
docsite pins "last 1 Chrome version", which is why the same file compiles
there), and a consumer compiling core source through @astryxdesign/build
is in the sandbox's position, so the fix belongs in the source rather
than in the sandbox's build config.

The hoisted objects are now `pressStrength` and `pressReleaseAnimation`,
written with literal keys, and the `[PRESS_ALPHA]` key — which cannot be
a literal, since the variable's name is generated — is spelled inside
each variant. A comment at the definition records the constraint.

Verified: the file compiles with the sandbox's babel.config.js under
NODE_ENV=production (as do all 241 StyleX files in packages/core/src),
and `pnpm -F @astryxdesign/core build:css` produces a byte-identical
astryx.css before and after.
docsite-test and registry-contract failed with "Generated ShadCN names or
routes changed". Refreshing the lock (UPDATE_SHADCN_ROUTE_LOCK=1 node
apps/docsite/scripts/generate-data.mjs) shows the only difference is one
new entry, `hook-use-press-feedback` at `hooks/use-press-feedback`,
generated from the new hooks/usePressFeedback.doc.mjs. No existing name
or route moved, so nothing needs a doc.registry.aliases entry; the lock
is refreshed as the message directs.

The `test` job failed only as the aggregate of this registry-contract
result — test-ui, test-node and test-build all passed on the same head.
@astracat-bot

astracat-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Sep 21, 2026, 9:31 PM UTC

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.

1 participant