Skip to content

fix(ScrollableArea): touch screens keep the platform's scroll indicator - #8319

Closed
vjeux wants to merge 73 commits into
meta:mainfrom
vjeux:uikit/c10-touch-scroll-indicator
Closed

vjeux wants to merge 73 commits into
meta:mainfrom
vjeux:uikit/c10-touch-scroll-indicator

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

User impact

Under a finger, ScrollableArea replaced the platform's scroll indicator with a faint bar. Its viewport sets scrollbar-color to the neutral token (6% black), which iOS Safari and Android Chrome apply to their indicators: nearly invisible while scrolling, and on iOS the same bar showed in the gap a pull-to-refresh opens. UIScrollView's indicator is dark and shows only while scrolling.

Problem and solution fit

  • With a coarse primary pointer the viewport leaves scrollbar-color at auto, so the platform draws its own indicator. A fine pointer keeps the neutral thumb and transparent track.

Expected behavior and authority

component:ScrollableArea FR3 (current) keeps native scrollbar behavior authoritative and restores platform presentation in forced colors. This PR extends that restoration to a coarse primary pointer; the FR3 amendment is proposed; pending owner review. spec:AST-025 FR15: native scrollbars are the default presentation.

Smallest restoration

  • One @media (pointer: coarse) value on the viewport's scrollbarColor, the literal key core's other touch rules use. FR3, its representative states and the viewport's design row say so.

Evidence

ScrollableArea Playground, full screen (URL text filled), swipes down the list and back (on iOS after a pull at the top); the overlay shows the computed scrollbar-color; dots mark fingers. Rows: iOS 26.2 Safari, Android 15 Chrome.

main this PR
main this PR
main, Android this PR, Android

The list mid-scroll, zoomed (main above, this PR below):

Indicator mid-scroll

Writing rubric results

R1 narration: FR3's new clause is present tense; none found.
R2 research: none in the record; the comparison lives in the clips above.
R3 evidence: no basis cell changed.
R4 rejected alternatives: none.
R5 change references: none.
R6 open questions: none.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • vitest run packages/core/src/ScrollableArea packages/core/src/Table packages/core/src/CodeBlock packages/core/src/BottomSheet (41 files, 1,000 tests pass; the new one fails on main)
  • Playwright Chromium on built Storybooks, touch and mouse profiles: computed scrollbar-color per profile as above
  • pnpm check:knowledge, strict eslint (CI=true) and prettier --check on changed files, node scripts/check-changesets.mjs

vjeux added 30 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A file that reads only defineConsts values from a .stylex.ts module lost
that import under the legacy {stylexOptions} form Storybook uses: the
babel wrapper's StyleX instance ran without treeshakeCompensation (the
unplugin applies its Vite default only to its own instance, which runs
after the wrapper), so once stylex.create was compiled the TypeScript
transform dropped the import and the defining module never reached the
build. A constant used as a media-query key failed the build with
"Invalid empty selector"; one used as a value shipped as a custom
property nothing declares. The wrapper now gets treeshakeCompensation by
default.

The layer split also substituted constants only within each layer, so a
constant defined on the library side and read on the product side stayed
a placeholder in either form; every layer now sees every constant.

A production build now warns, naming the variable and the rule that reads
it, when the StyleX stylesheet reads a generated custom property nothing
declares; undefinedVariables: 'error' fails the build instead, and this
repo's Storybook and Sandbox configs set it so CI catches the next case.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.
…tarts at the commit

play() bakes curves given as value and velocity functions of time (a spring
chasing a moving target, a fitted UIKit curve) like a spring.
createAnimator(dialog, {pseudoElement: '::backdrop'}) drives a
pseudo-element: a static filling animation holds its end value.
animatorFor(element) shares one animator per element, so callers animating
different axes compose in one translate track; atRest() waits out every
curve on its properties, and rebase() moves them by a layout shift keeping
their springs. A call from rest gets no start time, so the browser starts it
at its commit, as Core Animation does, and handle.started reports that
start. Every animation and set() promotes its channels to a compositing
layer in the same task (will-change, or a host custom property for a
pseudo-element) until 500 ms after the last motion: Mobile Safari only runs
a script-started curve through a block on an element that already has a
layer. Under Reduce Motion the substituted cross-fade can follow a
presentation's own spring, and crossfadeAs gives a slide that changes no
opacity its direction. The header lists the iOS Safari limits measured on
the simulator.
…eraction harness

useAnimator wraps the animator for React without re-rendering per frame.
Browser proofs on the interaction harness, in Chromium and WebKit on the
phone profile: a spring keeps advancing on every screencast frame through a
1 s main-thread block and every frame sits on its analytic curve; a spring
ending inside the block keeps its end state; no Layout or Paint runs while
it animates; the engine plays the curve when seeked; a finished animation
leaves within a frame or two of the curve's end; a dialog's ::backdrop
follows the curve.
A touch presentation is now also an idiom: ios for WebKit on iOS or iPadOS
under a coarse pointer (CSS.supports('-webkit-touch-callout: none'), which
no other engine implements), default everywhere else, Android included,
which keeps today's behaviour until a Material variant exists.
useTouchPresentation and readTouchPresentation report it, useTouchIdiom
reads it alone, and useMotionPresentation now chooses UIKit motion only in
the iOS idiom. Styles gate the same way with two nested StyleX keys, the
media query outside and the supports query inside (IOS_TOUCH_STYLE_KEYS),
as core's 16px input floor already does; a test keeps every such key in
core spelled and nested that way. A real-engine spec reads Chromium as an
Android phone and WebKit as default.
Every spring, duration (a key ending in Ms) and curve of a motion table is
a motion role named by its table path (springs.sheetPresent,
timing.curves.easeInOut, sheet.present). defineTheme({motion}) takes
springs, durations and curves keyed by role, at the root or in an
adaptation rule, and writes one CSS variable per role it sets
(--motion-springs-sheet-present: 0.6s 0.1); a role it leaves out keeps the
table's measured value. The duration scale becomes all-or-nothing so a
theme can set roles alone. Adaptation rules gain a platform condition (ios
or other) that lowers to @supports (-webkit-touch-callout: none) around the
media block. Gesture thresholds and the arbitration timings (the
content-touch delay, the scroll brake) are not roles.

The motion docs carry a table of every role with its CSS variable and
default, generated from the tables (pnpm generate:motion-roles, checked in
check:repo).
# Conflicts:
#	packages/core/src/motion/README.md
…me sets it

resolveMotion(element, role) reads a motion role's CSS variable from the
element's theme scope (the nearest [data-astryx-theme], or the root) when a
motion starts: a style read, no layout, cached per scope until a theme
attribute changes, theme CSS is inserted or edited, the window resizes or a
pointer, contrast or reduced-motion preference changes. An empty or
malformed value, a detached element or a value that is not a role gives the
table's value back. The animator resolves every spring it is handed, the
Reduce Motion fade's duration, curve and spring, and a decay's edge bounce,
so call sites keep passing table entries. A browser proof seeks two boxes
animated with springs.sheetPresent, one inside a theme that sets it: each
plays its own spring within 0.05 px in Chromium and WebKit.
vjeux added 2 commits October 24, 2026 18:31
… entrance

A travel call that came a few ms into another call's dip read the opacity
rising from the dip's first frames back to where it was heading, planned an
entrance, and landed at once with a fade of a few thousandths. A list row's
drop, its reorder's rebase and the move to the new slot did that. Only the
call's own opacity, or its crossfadeAs role, now makes it an entrance or an
exit; otherwise travel dips once, from where it is drawn. Test: the drop,
the rebase and the move a few ms later dip once and never move while
visible (fails on 5cb103f).
`spec:AST-012` FR2 closes the adaptation vocabulary at width, pointer,
contrast and motion, and nothing needs a platform condition yet, so the
`platform` condition (and the feature query it lowered to) leaves the stable
`defineTheme` input. The motion docs' example adapts on a coarse pointer
instead, and the theme guide's condition table and the changeset drop it.
vjeux added 4 commits October 24, 2026 19:21
defaultIsCoarsePointer and the two Reduce Motion queries spelled
(pointer: coarse) themselves; they now build on the touch presentation's
TOUCH_QUERY, so one constant names the touch condition.
timing.reducedMotionCrossFadeMs is not a role (design:reduced-motion DR4),
so the changeset names navigation.transitionMs instead, and the defineTheme
example's comment says what that duration paces.
…tes with

resolveMotion registered every shared table before it resolved a role, so
anything that animated (the animator imports resolveMotion) carried the
springs and navigation tables whether it read them or not, and each
component table added there would have reached every touch page.

Each table module now exports an idempotent register<Name>Table() beside
its data (its id and excluded keys inside; nothing runs on import), and the
touch code that animates with a table calls it before its first motion. The
animator registers timing for the Reduce Motion fade and scroll for a
decay's bounce, the two table values it resolves itself. resolveMotion
imports no table; a leaf of a table nothing registered plays its own value.
registerMotionTable is a no-op for a table already registered under its id,
and registerTables.ts is gone.

The generated role list and the role registry test register every table
through one discovery helper (scripts/motion-table-files.mjs); defineTheme
checks role ids against that generated list, as before.
…tered

It filtered the registry to the four shared tables, so the first component
table that discovery found would fail it. It now compares every table the
discovery loop registered against the generated role kinds (a probe table
added under motion/uikit passes once the list is regenerated).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This touch-screen fix intentionally changes ScrollableArea's FR3, which today keeps the neutral thumb for every pointer except forced colors, so approval waits on an engineering owner deciding whether a coarse primary pointer should also restore platform scrollbar presentation, as the rewritten FR3, the new coarse primary pointer state and the Viewport design row propose. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so if they are an engineering owner. Independently of that decision, please rebase so the branch carries only its own commit: it now includes all of #8331 (about 31,700 changed lines in 215 files, including new experimental subpaths, theme motion roles, a platform adaptation condition and the draft AST-094), which belongs in that pull request's review.

  • Please also drop "(and Table, CodeBlock and BottomSheet's body, which use it)" from .changeset/scrollable-area-touch-indicator.md: those components call useScrollableArea directly and already use the platform scrollbar colour.

[Automated review]

vjeux added 4 commits October 24, 2026 20:12
touchKeys (utils/touchPresentation.stylex.ts, exported as
@astryxdesign/core/utils/touchPresentation.stylex) holds the touch
presentation resolver's style keys. Core keeps spelling them literally: an
imported defineConsts at-rule key compiles to a different StyleX priority
than the same literal and reorders the cascade. Tests pin every literal in
core to the resolver's queries and to touchKeys, refuse computed touch and
Reduce Motion keys, and keep the iOS gate on the 16px floor.
Compiles interactionOverlay.stylex.ts with the StyleX plugin and checks
the priorities: the [data-astryx-press="on"] paint must sit above the
coarse-pointer :active reset, or WebKit (which applies :active during a
touch) loses the touch-down paint. With an imported defineConsts key the
reset compiled at 6210, above the paint's 6040; with the literal it sits
below.
The viewport's neutral scrollbar-color replaced iOS Safari's own scroll
indicator with a faint bar: hard to see while scrolling, and visible in
the gap a pull-to-refresh opens. Under a coarse primary pointer the
viewport now leaves scrollbar-color at auto, so iOS draws its indicator
only while scrolling, as UIScrollView does. Mouse and trackpad scrollbars
keep the neutral thumb; the component record's FR3 says so.
…rals match"

This reverts commit f2dd979, which this
branch carries from meta#8331. That export is ruled out: no new public
surface without its own spec decision, and no module-level experimental
subpaths. The touch keys stay internal. The ScrollableArea change in
7ea82de spells its key literally and did not depend on the export.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 8dc9bcb

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 25, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The ScrollableArea fix itself has what it needs: an engineering owner approved the FR3 update at this head, and the coarse-pointer rule restores the platform's scroll indicator without changing mouse or forced-colors rendering. Two changes are needed before this can merge:

  • Rebase so the branch carries only the ScrollableArea commit, which applies cleanly to main; it now includes the whole #8076/#8331 stack (about 30,700 changed lines in 192 files), and that belongs in those pull requests' reviews.
  • Drop "(and Table, CodeBlock and BottomSheet's body, which use it)" from .changeset/scrollable-area-touch-indicator.md, because those components call useScrollableArea directly and already use the platform scrollbar colour.

[Automated review]

…llableArea

Table, CodeBlock and BottomSheet's body call useScrollableArea directly
and never took ScrollableArea's scrollbar colour, so the changeset no
longer names them.
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in c646929: the changeset no longer names Table, CodeBlock or BottomSheet's body, which call useScrollableArea directly and never took ScrollableArea's scrollbar colour. The branch carries the #8076/#8331 stack until those merge. If you'd rather have this rebased onto main as one commit, which drops 8dc9bcb since it would be empty, say so and I'll do it.

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #8372, which carries this change on main. The iOS and Android clips in this description show the same code.

@vjeux vjeux closed this Oct 25, 2026
@vjeux
vjeux deleted the uikit/c10-touch-scroll-indicator branch October 25, 2026 05:34
cixzhang added a commit that referenced this pull request Oct 25, 2026
…or (#8372)

Touch screens keep the platform scroll indicator in ScrollableArea (coarse pointer leaves scrollbarColor to the platform); mouse, trackpad, and forced-colors rendering unchanged. Supersedes #8319.

Co-authored-by: vjeux <vjeux@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — c6469297 Deployed Oct 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants