Conversation
vjeux
requested review from
cixzhang,
imdreamrunner and
josephfarina
as code owners
September 19, 2026 23:51
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
github-actions
Bot
requested review from
cvkxx,
ernestt,
kentonquatman and
rubyycheung
September 19, 2026 23:51
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsCalendar (@astryxdesign/core) · View in Storybook
CheckboxInput (@astryxdesign/core) · View in Storybook
ClickableCard (@astryxdesign/core) · View in Storybook
DateInput (@astryxdesign/core) · View in Storybook
Item (@astryxdesign/core) · View in Storybook
Outline (@astryxdesign/core) · View in Storybook
SelectableCard (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: 36 accessibility violation(s) found — 2 critical, 34 serious. Button pattern - 1 issue(s)
ChatComposer - 1 issue(s)
ChatComposerInput - 1 issue(s)
ChatReasoning - 1 issue(s)
ChatToolCalls - 1 issue(s)
Checkbox pattern - 1 issue(s)
CheckboxList - 1 issue(s)
ClickableCard - 1 issue(s)
CodeEditor - 1 issue(s)
CodeEditorPerf - 1 issue(s)
CodeEditorTheme - 1 issue(s)
CodeTheme - 1 issue(s)
DateRangeInput - 1 issue(s)
FileInput - 1 issue(s)
GridMasonry - 1 issue(s)
Heading - 1 issue(s)
Icon - 1 issue(s)
LogStream - 1 issue(s)
MediaTheme Auto - 1 issue(s)
PowerSearch - 1 issue(s)
ProgressBar - 1 issue(s)
Radio group pattern - 1 issue(s)
RadioList - 1 issue(s)
RichTextEditor - 2 issue(s)
SelectableCard - 1 issue(s)
Stepper - 1 issue(s)
TableGroupedRows - 1 issue(s)
TableTree - 1 issue(s)
Text - 1 issue(s)
Theme - 1 issue(s)
PopArt - 1 issue(s)
Thumbnail - 1 issue(s)
Timestamp - 1 issue(s)
Token - 1 issue(s)
Tokenizer - 1 issue(s)
Visual RegressionStatus: 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.
vjeux
force-pushed
the
touch-press-controller
branch
from
September 20, 2026 05:41
56e9a82 to
dea8f16
Compare
|
AI review status for this pull request.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
User need
On a phone, a press on an Astryx control paints with CSS
:active, and on iOS Safari:activeis 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)::activein iOS Safaripointercancelfires;:activemay or may not drop:activefor 47 ms, then bareSo 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 frompointerdown/pointermove/pointerup/pointercancel/touchcancel/dragstart/ capture-phasescroll/visibilitychange, finds the innermostdata-astryx-pressableelement under the touch, and writesdata-pressed="on"once the press is believed anddata-pressed="fading"for the release.pointercancelis the primary cancel: measured, it fires before the firstscrollevent, which puts the cancel one frame ahead of the list's motion the way UIKit'stouchesCancelledis. Under@media (pointer: coarse)the bare:activearm is dropped; a mouse keeps:activeuntouched.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.tsis 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) andinteractionOverlayStyles(compose itspressedAlphaon the marked element). The caller styles[data-pressed="on"]and[data-pressed="fading"]as the pressed token atvar(--astryx-press-alpha)strength and drops its:activeunder a coarse pointer, the way the built-in components do;hooks/usePressFeedback.doc.mjsdocuments it.CSS arms.
interactionOverlayStyles.*(all variants) and every component with its own:activerule gain the two touch arms and drop the bare:activeunder@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 bystylex.defineVars+stylex.types.number), read by the overlay aslinear-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.:active(mouse):activeunder(pointer: coarse)[data-pressed="on"][data-pressed="fading"]animation: 200ms cubic-bezier(0, 0, 0.2, 1) bothon the strengthThe release lands on nothing, not on the hover strength: under a finger there is no hover.
ClickableCard,SelectableCardandThumbnaildrive their::afteroverlay through a private custom property (--_press-overlay, documented) that the touch arms set to the samecolor-mix(their layer's own colour transition is held at0son both touch arms); Switch's track and thumb, Tab's hover layer and the owner-drawn::afterlayer 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) composesinteractionOverlayStyles.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:activeand the emulated:hoverunder a finger too) cannot paint the press twice.Supporting delta —
DropdownMenuItemgainshref(+target,rel). A navigation row renders as the anchor itself (<a role="menuitem">, through theLinkProvidercomponent), 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 firesonClickand closes the menu; a disabled row drops the address.Itemrenders aroled row withhrefas the anchor for the same reason (before,hrefwas ignored in parent-role mode).Supporting delta —
ItemgainsisUnreadandswipeActions.isUnreadsets the semibold label weight and the primary description colour and exposes anunreadstate on theitemtheme 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-*,draggableand the drag handlers still land on the row (tested). TypesItemSwipeAction,ItemSwipeActions,ItemSwipeActionToneare exported.Design and alternatives
:activeand tuning it. Not recoverable in CSS: the instant paint and the survival into a scroll are the platform's:activesemantics.requestAnimationFramefor 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.scrollalone. It arrives one frame after the motion;pointercancelleads it (measured), so it is the primary cancel andscrollthe 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.box-shadowtransitioning to transparent (the agentcloud client's own recipe) is out because composers ownbox-shadowfor 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'scolor-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 atransitionfor two reasons: composers own theirtransition-*longhands (Button, Item, Tab, Link, …), which the utility can neither append to nor override without taking the whole property from them, while no composer setsanimation-*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 transitionbackground-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 anddragstartall 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 (':activekey,[data-pressedarm, orinteractionOverlayStyles.) and holds it equal to the set that callsusePressFeedback(), 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 ofrole/aria-*/draggable/drag handlers to the row with and without swipe actions.DropdownMenu.test.tsx: thehrefrow is the anchor with the menu role through a customLinkProvidercomponent,target="_blank"getsrel, plain click firesonClickand closes, disabled dropshref, 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.tsreads the StyleX-injected rules for every overlay variant and forpressedAlphaand holds that the fading arm carries the release animation for exactlyPRESS_FADE_MS(the CSS clock is compared to the machine's constant, not to a second200), that the on arm animates nothing, that both touch arms paint the pressed token throughvar(--astryx-press-alpha)and declare it1, and that no fading arm mentions the hover token.Button,Switch,TabList,CheckboxInput,RadioList,ClickableCard,SelectableCardandThumbnailtests assert the same on their own elements (the owner runs the release; the painting descendant or::afterreads the strength; the RadioList row itself paints nothing, as under a mouse).scripts/build-css.test.mjsholds the shippedastryx.css: one@property --astryx-press-alpha { syntax: "<number>"; … initial-value: 0 }, the1 → 0keyframes on the fading arm atPRESS_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 existingguard:pressed-overlay): Playwright Chromium 149 (headless shell), touch emulation (pointer: coarsematched,hover: hovernot), the built Storybook's Buttons story, the first Button (primary; it transitionsbackground-imageitself). The attribute is written the way the controller writes it and the overlay's alpha is read off the computedbackground-imageon every frame:--astryx-press-alphaon, first framefading, t = 0.0 msfading, t = 100.1 msfading, t = 199.9 msWhole 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 msbackground-imagetransition 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
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.Testing