Skip to content

feat(Slider): every pointer drags a thumb by its travel, as UISlider does - #8190

Merged
cixzhang merged 9 commits into
meta:mainfrom
vjeux:uikit/c03-slider
Nov 2, 2026
Merged

cixzhang merged 9 commits into
meta:mainfrom
vjeux:uikit/c03-slider

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Batch: Slider · finger tracking

Implements component:Slider FR7 (#9407): every pointer drags a thumb by its travel, as UISlider does. It supersedes #8189, #8669 and #8168, which close when it merges. It runs on main's harness.

Evidence

iOS 26.2 simulator, Android 15 emulator; the gray dot is a finger.

Before
After
Track tap
Hold
Reduce Motion
Release
Busy thread
List
Catch
Paths

User need

A Slider jumped its thumb to the pointer and trapped vertical swipes. UISlider moves the thumb by the travel and coasts after a flick.

Change

  • Every pointer: a press focuses the thumb and changes no value. Past 10 px the value follows the pointer's travel. A finger or a pen claims only along the track.
  • A finger: each thumb's ::before is a 48 px target. It lifts and drops on the sliderLift and sliderDrop roles, and a drag into an end plays UISlider's haptic where the browser vibrates. A flick coasts on springs.sliderRelease, with onChange through it and onChangeEnd when it settles. A finger on the coast stops it where it is drawn, and the coast follows the track's live length.
  • Taps: a finger's tap on the track sets the value on its lift, a mouse press at once.
  • Cancel: disabling mid-drag ends the drag with one onChangeEnd.
  • Reduce Motion: no lift, a tap applies at once, a flick still coasts.

Public delta and caller burden

No API change. A press no longer sets a value, a mouse drag starts after 10 px, and onChangeEnd follows a flick's coast.

Size

About +37 KB gzip, shipping as is; the live-length re-map reuses core's shared ResizeObserver. A follow-up loads the touch layer after hydration or on the first pointer. A single slider renders 14 elements (11 on main), a range 19 (16): a travel box for container units, a value anchor for the tooltip, and the fill's clip. The touch target adds none.

Design and alternatives

Keeping jump-to-pointer for a mouse was rejected: FR7 covers every pointer. #4050's resize mid-drag is handled: a drag and a coast follow the track's length. #7516's off-grid max is not addressed: with max 100 and step 3, a drag reaches 100 only past the end.

Current specification

component:Slider FR5–FR7, ORD2, DEC-4; spec:AST-094 FR4, FR9, FR13, FR19, FR23, FR42, FR43; spec:AST-017 FR60; design:reduced-motion DR2.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at a610788: build, Storybook build, typecheck, strict lint (no warnings in changed files), check:knowledge, Slider's interaction specs in Chromium (23 passed) and WebKit (18 passed, 5 behind engine-limit guards), and the catch test ×80 at 4 workers on an exclusive 2-core pair (80 passed, no runner faults). Core and cli unit tests: 26,099 passed; 5 cli integration tests timed out at 30 s under machine load, and pass alone (211 of 211 in their three files), which this pull request does not touch. The provenance baseline check fails only on main's own four stale entries (ChatComposer, Item, layerSurfacePress, Token), which this pull request does not carry; its own entries match.

Red first: the focus case fails on the previous head for a finger and a pen; the disable-mid-drag case fails without the cancel; the zero-width case fails without the guard; a track tap that sets an end played the haptic before the pan check; two sliders made two observers before the shared one. On the previous stack, the catch's stop check failed 16 of 20 runs without the drawn-translate stop, and its visual check 20 of 20 without the live-length re-map.

The WebKit form of the busy-main-thread proof moves out of this pull request to a follow-up stacked on #9545, "Slider: the WebKit busy-main-thread proof on the paced touch play". On main's harness WebKit's injected touches bunch behind a stalled dispatch, and its JPEG cast moves in 2 px steps, so the proof cannot judge the coast there. Here the proof runs in Chromium, and its WebKit run carries the harness's engine-limit guard until the follow-up replaces it.

Writing rubric results

R1–R6 pass for every record edit: FR7's sentences, Slider's system specs, verification row and removed gap, AST-094's path list, the role table. No research, history or open questions.

@vercel

vercel Bot commented Oct 24, 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 Nov 2, 2026 9:51pm UTC

Request Review

@vjeux vjeux mentioned this pull request Oct 24, 2026
4 tasks done
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 10:09 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · a610788

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Slider · Storybook 🔴 81.7 kB (+37.3 kB) · 9.4 kB CSS (+489 B) 2,326 (+1,270) ✓ · ✓ · ✓
AppShell 65.9 kB (+62 B) · 12.1 kB CSS (0) — — · — · —
TreeList 37.9 kB (+47 B) · 5.0 kB CSS (0) — — · — · —
3 more components
Component Size LoC Visual · A11y · RTL
Collapsible 22.5 kB (+46 B) · 4.5 kB CSS (0) — — · — · —
SideNav 63.4 kB (+44 B) · 10.9 kB CSS (0) — — · — · —
PowerSearch 180.9 kB (+37 B) · 20.5 kB CSS (0) — — · — · —

Package size: @astryxdesign/core 5.85 MB packed (+35.4 kB); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 2 rows, core 23 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision: under a coarse pointer the Slider ignores track presses and fires onChangeEnd only after a coast, which changes component:Slider FR5, and the adaptive-menu threshold and the draft spec:AST-094 requirements have no current spec yet, so FR5 needs updating for the touch model and the threshold and the draft's requirements need approving or replacing with a governing spec. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Independently of that decision, please fire onChangeEnd from the handler that ends the touch interaction instead of the layout effect in packages/core/src/Slider/Slider.tsx, and shrink the 27,841-line stack by landing its dependency pull requests first. [Automated review]

Comment thread packages/core/src/Slider/Slider.tsx Outdated
Comment on lines +1011 to +1019
useIsomorphicLayoutEffect(() => {
if (touchSettle.count === 0) {
return;
}
touchController.handBack();
if (touchSettle.report) {
fireChangeEnd();
}
}, [touchSettle, touchController, fireChangeEnd]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

onChangeEnd fires here from a layout effect reacting to touchSettle, which architecture:react-component-runtime INV2 rules out, and it fires again if fireChangeEnd changes identity (a Slider switched between single and range values). Rendering the last value synchronously in the settle callback the controller calls when the interaction ends, and calling fireChangeEnd() there, would leave only handBack() in this effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

onChangeEnd has to come from the touch that ends the interaction, exactly once. Fixed in 889f550: the controller now ends an interaction from the handler that ends it (the lift, a cancel, the coast's last frame). flushSync renders the last value, the parts go back to the stylesheet, and onChangeEnd fires there with the rendered value (FR5). The layout effect is gone. A new test switches a single slider to a range after a touch: it fired twice through the old effect, and fires once now.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision on the touch model: under a coarse pointer the Slider ignores track presses and reports onChangeEnd only after a coast, which changes component:Slider FR5, and the adaptive-menu threshold and the draft spec:AST-094 requirements have no current spec yet. An engineering owner approves it by recording the Slider and menu-threshold changes in their component records and commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner; otherwise the touch path keeps FR5's track press and its end at release. Independently of that decision, please:

  • keep a way to set a value without dragging under touch, since a drag is now the only pointer path (WCAG 2.2 SC 2.5.7);
  • end a touch drag that is interrupted by disabling or unmounting the Slider without calling flushSync and onChangeEnd from the effect cleanup in useSliderTouch.ts;
  • land the dependency pull requests first so this one carries only the Slider commit.

[Automated review]

Comment thread packages/core/src/Slider/Slider.tsx Outdated
}
// A finger or a pen under the touch presentation belongs to the thumbs'
// touch targets; the track ignores it, as UISlider's does.
if (isTouchPresentation && e.pointerType !== 'mouse') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With this early return a finger or pen can change the value only by dragging the thumb: a tap on the track or a mark does nothing and a tap on the thumb changes nothing. WCAG 2.2 SC 2.5.7 (Dragging Movements, AA) asks for a single-pointer way that does not drag. Letting a tap that never passes the 10 px pan threshold set the value from the track, while a swipe still scrolls, would keep both.

if (!isEnabled) {
return undefined;
}
return controller.attach();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When this cleanup runs mid-drag (the Slider is disabled or unmounted), detaching the pan cancels it, and the cancel reaches finish: commit calls flushSync during React's commit (React logs "flushSync was called from inside a lifecycle method") and onChangeEnd fires from the cleanup, although dispose() says unmount stops without reporting. Ending live interactions silently before the recognizers detach, and naming the gesture host this Effect synchronizes, would keep the end report in the touch handlers.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
…fari)

Recordings of the iOS 26.2 simulator (iPhone 17 Pro) in Mobile Safari, cropped to
the page; the gray dot is the finger, drawn by the test page. main against each
pull request: meta#8151 coincident range thumbs, meta#8168 translate travel, meta#8190 the
touch presentation and its held-thumb fix.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision on the touch model: under a coarse pointer the Slider ignores track presses and reports onChangeEnd only after a coast, which changes component:Slider FR5 and ORD2, while the new adaptive-menu query and the draft spec:AST-094 requirements have no current spec yet. An engineering owner settles it by recording the Slider and menu-query changes in those component records and commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner; otherwise the touch path keeps FR5's track press and its end at release. Independently of that decision, please:

  • keep a single-pointer way to set a value without dragging under touch (WCAG 2.2 SC 2.5.7);
  • end a touch drag interrupted by disabling or unmounting the Slider without calling flushSync and onChangeEnd from the effect cleanup in useSliderTouch.ts;
  • land the dependency pull requests first so this one carries only the Slider commit.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision on the touch model: under a coarse pointer the Slider ignores track presses and reports onChangeEnd only after a coast, which changes component:Slider FR5, while the new adaptive-menu query and the draft spec:AST-094 requirements have no current spec yet. An engineering owner settles it by recording the Slider and menu-query changes in those component records and commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner; otherwise the touch path keeps FR5's track press and its end at release. Independently of that decision, please:

  • keep a single-pointer way to set a value without dragging under touch (WCAG 2.2 SC 2.5.7);
  • end a touch drag interrupted by disabling or unmounting the Slider without calling flushSync and onChangeEnd from the effect cleanup in useSliderTouch.ts;
  • add a pen case to the touch tests, since the track now ignores pens and the thumbs take them;
  • land the dependency pull requests first so this one carries only the Slider commits.

[Automated review]

direction: this.model.isHorizontal ? 'horizontal' : 'vertical',
maximumNumberOfTouches: 1,
cancelsTouchesInView: false,
allowedPointerTypes: ['touch', 'pen'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The thumbs now take pens and the track ignores them (Slider.tsx 888), but neither useSliderTouch.test.tsx nor the interaction spec drives a pen. architecture:interaction-modality asks a change adding pointer-dependent behaviour to verify mouse, touch and pen; one pen case (track ignored, thumb drag tracked) would cover it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision on the touch model: on iOS the Slider ignores track presses and reports onChangeEnd only after a coast, which changes component:Slider FR5, while the adaptive-menu threshold, the theme motion roles and platform condition, the module experimental subpaths and the draft spec:AST-094 requirements (whose FR4 says touch motion is not themable) have no current spec yet. An engineering owner settles it by recording these changes in their records and commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner. Independently of that decision, please:

  • keep a single-pointer way to set a value without dragging under touch on iOS (WCAG 2.2 SC 2.5.7);
  • end a touch drag interrupted by disabling or unmounting the Slider without calling flushSync and onChangeEnd from the effect cleanup in useSliderTouch.ts;
  • add a pen case to the touch tests, since the track ignores pens and the thumbs take them;
  • land the dependency pull requests first so this one carries only the Slider commits.

[Automated review]

Comment thread packages/core/src/Slider/Slider.tsx Outdated
}
// A finger or a pen under the touch presentation belongs to the thumbs'
// touch targets; the track ignores it, as UISlider's does.
if (isTouchPresentation && e.pointerType !== 'mouse') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With this early return a finger or pen on iOS can change the value only by dragging the thumb: a tap on the track or a mark does nothing, and a tap on the thumb changes nothing. WCAG 2.2 SC 2.5.7 (Dragging Movements, AA) asks for a single-pointer way that does not drag. Letting a touch on the track that ends before the pan begins set the value, while a swipe still scrolls, would keep both.

Comment on lines +873 to +878
useEffect(() => {
if (!isEnabled) {
return undefined;
}
return controller.attach();
}, [controller, isEnabled, thumbCount, isHorizontal]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When this cleanup runs mid-drag (the Slider is disabled or unmounted), detaching the pan cancels it and the cancel reaches finish: commit calls flushSync inside React's commit (React logs "flushSync was called from inside a lifecycle method") and onChangeEnd fires from the cleanup, although dispose() says unmount stops without reporting. Ending live interactions silently before the recognizers detach, and naming the gesture host this Effect synchronizes, would keep the end report in the touch handlers.

direction: this.model.isHorizontal ? 'horizontal' : 'vertical',
maximumNumberOfTouches: 1,
cancelsTouchesInView: false,
allowedPointerTypes: ['touch', 'pen'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The thumbs take pens here and the track ignores them (Slider.tsx 905), but neither useSliderTouch.test.tsx nor the interaction spec drives a pen. One pen case (track ignored, thumb drag tracked) would cover the mouse, touch and pen coupling for pointer-dependent behaviour.

measures (0.55 for a scroll view and a sheet), and a release MUST pick its
rest point by projecting its velocity the way its UIKit counterpart does:
with a deceleration rate, or with a measured projection time.
- **FR4 — Physics are not themable in touch presentations.** A touch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FR4 says touch physics are not themable, and DEC-1 rejects tuning touch motion per theme, but this head lets defineTheme({motion}) retune every touch spring, duration and curve by role and resolves slider.releaseSpring under the thumb's theme. One of the two needs to change before an owner can approve the record.

follows.
- **FR7 — Exits animate as UIKit's do.** A touch presentation MUST play the
exit its UIKit counterpart plays. **Proposed; pending owner review:** this
departs from the draft `spec:AST-090` FR1 (exits off by default) and FR10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

spec:AST-090 is not in the repository at this head or on main, so the departure FR7 and FR8 propose cannot be checked against it.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A platform condition widens the adaptation condition vocabulary, which the current theme authoring contract keeps fixed; it needs that contract amended (or an owner's decision) alongside the motion roles.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
…eta#8190 24030a1)

iOS 26.2 simulator, Mobile Safari. meta#8190's clips are re-recorded on the build
whose lifted thumb grows around its center (the earlier clips showed it 5.5 px
off the track); the catch is now side by side with the same flick alone (77
against 63); the past-the-end clip runs through the lift; the tap is held
0.3 s. meta#8151's tooltip clip hides the row label so the tooltips read clearly,
and its coincident clip names the off-center press precisely.
@vjeux vjeux changed the title feat(Slider): under a finger the thumb tracks like UISlider feat(Slider): every pointer drags a thumb by its travel, as UISlider does Nov 2, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. Slider's new tracking matches component:Slider FR7 as it now stands on main, so no spec decision is pending, but a few implementation points need fixing before approval.

  • Split the change (about 6,700 authored lines): the interaction-harness pacing and cast-step work and the UIKit slider table can land on their own.
  • Play the lift and drop springs through motion roles named in component:Slider, as the release coast already does (spec:AST-094 FR4).
  • Build the 48 px touch target without a touch-only element per thumb, for example as the thumb's ::before (spec:AST-017 FR60), and add before-and-after element counts for the new wrappers.
  • Focus the thumb on a finger or pen press as the base did, which FR6 relies on for coincident thumbs, or get FR6 amended for touch in this pull request.
  • Remove the two layout Effects that only mirror values into refs, play the end haptic only for touch and record its iOS gap in component:Slider (spec:AST-094 FR23), and drop the changeset's wheel clause, since a wheel never changed the value.

[Automated review]

}
animator.animate(
{scale: UIKIT_SLIDER.liftScale},
{spring: UIKIT_SLIDER.liftSpring},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This spring (and dropSpring at 1081) reaches the animator as a literal: neither is a registered motion role, while spec:AST-094 FR4 says a component "MUST NOT play a literal". Naming them in component:Slider FR7, adding them to SPRING_ROLES and resolving them like the coast's springs.sliderRelease keeps one gesture on one tempo when a theme sets its motion knobs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in fcc9bcc. The lift and the drop play the springs.sliderLift and springs.sliderDrop roles, which component:Slider FR7 now names. The generated role table lists both.

Comment thread packages/core/src/Slider/Slider.tsx Outdated
undefined,
positionStyle,
)}>
{hasTouchTargets && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This span exists only under touch, so a touch Slider renders one more element per thumb than a mouse one; spec:AST-017 FR60 keeps controls at their fine-pointer count, at rest and pressed. A ::before on the thumb with the insets as custom properties (the shape utils/hitArea.ts's HIT_INSET_PROPERTIES uses) gives the same 48 px target, and the recognizers can then attach to the thumbs in both presentations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in fcc9bcc. Each thumb's ::before is its touch target, drawn from four inset custom properties on the thumb, and the span is gone. A single slider renders 14 elements and a range 19, at rest and pressed, in every presentation (main: 11 and 16; the previous head added a span per thumb under a finger). The three added elements are the travel box for container units, the value anchor for the tooltip, and the fill's clip.

Comment thread packages/core/src/Slider/Slider.tsx Outdated
});
// Tells the pointer handlers, which run outside React's render, whether
// the touch layer takes touches.
useIsomorphicLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

useSliderTouch returns its isEnabled input unchanged, and handlePointerDown already returns on isDisabled (line 1016), so touchLiveRef.current is always true where line 1029 reads it. This Effect names no external system (architecture:react-component-runtime INV3); deleting it and the ref leaves the behaviour unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in fcc9bcc. The press handler no longer reads it: any press on a thumb focuses the thumb and returns, and a finger or a pen on the track returns before the mouse path.

const getPartsRef = useRef(getParts);
// Keeps the parts getter current for the controller, which reads it from
// gesture callbacks outside React's render.
useIsomorphicLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getParts reads only refs (Slider.tsx 1288-1305), so the first getter stays correct and this per-render Effect synchronizes nothing (INV3). Handing getParts to the controller once in the useState initializer is enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in fcc9bcc. Slider's parts getter reads only refs, so the controller keeps the first one. The hook returns nothing now.

}
const end = this.endOf(value);
if (end != null && end !== interaction.atEnd) {
playHaptic(UIKIT_SLIDER.endHaptic);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

record() runs for mouse drags too, so a mouse drag reaching an end vibrates wherever navigator.vibrate exists. spec:AST-094 FR23 ties a haptic to the moments a touch presentation's UIKit counterpart plays one, and asks the component record to state that iOS Safari plays nothing at these mid-drag and coast moments. Gating this on a touch interaction and adding that gap to component:Slider would settle both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in fcc9bcc. The end haptic plays only for a finger or a pen, and FR7 says iOS Safari has no haptic for that moment.

'@astryxdesign/core': patch
---

[fix] Slider: every pointer drags a thumb by its travel, as UISlider does. A press on a thumb no longer jumps the value to the pointer: once the pointer has moved 10 px, the value follows its further travel along the track. A finger or a pen claims the drag only along the track, so a swipe across the slider scrolls the page; a mouse claims in any direction. On a touch screen each thumb takes a finger or a pen through a 48 px target, capped at half the gap to a range's other thumb, and the held thumb lifts. After a finger's flick the thumb coasts on UISlider's measured spring for about 0.6 s, `onChange` firing through the coast, and `onChangeEnd` reports the value once it settles; a slow drag, a mouse drag and a pen drag end at the release. A tap on the track sets the value when the finger lifts, and a mouse click where it lands; a right click, a macOS ctrl-click, and a wheel or trackpad scroll change nothing. Under reduced motion the thumb does not lift and a tap's value applies at once.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The base Slider has no wheel handling, so "a wheel or trackpad scroll change nothing" already held; in a [fix] entry it reads as a change. Dropping that clause, or saying "as before", keeps the release note to what this pull request changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped in fcc9bcc.

vjeux added a commit to vjeux/astryx that referenced this pull request Nov 2, 2026
…y proof reads a still run against the cast's own step

Near a coast's rest, WebKit's 2 px cast shows a new position about every
step / pace frames, so a frame delivered early can leave a two-frame
plateau right after two changes in a row. The rule "a run more than one
frame longer than the one before it is a stall" read that as a stall (2 of
20 runs of meta#8190's Slider coast proof at four CPUs). Where the pace around
the run is under the cast's own step, a proof with settledWithinSteps now
allows a run of up to ceil(step / pace) frames plus one early frame; a
longer run is still a stall. The pace around the run is the smaller of the
motion over up to four frames before it and after the change that ends
it, never across it, and the step is read from the cast, never the motion.
These still fail: a three-frame freeze at 1.2 px a frame that resumes from
where it held, a 40 ms freeze in a WebKit or a Chromium tail, a one-frame
hold at two steps a frame or more, and a three-frame hold at 2 px a frame
on a 1 px cast. A proof with settledWithin judges as before.
vjeux added 8 commits November 2, 2026 11:17
…does (slider A, end A)

A press on a thumb changes no value; once the pointer has moved 10 px the
value follows its travel, so the thumb never jumps to the pointer. A finger
or a pen claims the drag only along the track, so a swipe across it scrolls
the page; a mouse claims in any direction. Under a coarse pointer each thumb
takes a finger or a pen through a 48 px target capped at half the gap to a
range's other thumb, the held thumb lifts, a release coasts on UISlider's
measured spring, and a tap on the track sets the value on lift. A mouse
press on the track sets the value where it lands; a secondary click and a
wheel or trackpad scroll change nothing. onChangeEnd fires once a finger's
or pen's coast settles. UISlider's measured values live in one table.
…o rest

It waited up to 4 s for the list's deceleration to finish, then went on
whether or not the list had stopped. It now polls until two reads 100 ms
apart agree and fails if they never do, so the horizontal drag that
follows never runs on a moving list.
…nal capture names

The table and its test named the lab's capture and case labels, which
mean nothing outside the lab. They now cite the measurement the way the
other UIKit tables do: the UIKit lab, the iOS 26.2 simulator, and what
was read.
…ey measure is on screen

The hit-area proof checks that both thumbs and both touch targets have boxes,
and that where the thumbs have room each target is the full 48 px square
around its thumb, before measuring the narrowed targets. The list proof
checks its thumbs are visible before a swipe that must leave them still, the
no-layout proof checks that its drag moved the value, and the desktop-style
proof checks the thumb has a box before reading what it lacks.
… own frame

The proof waited in the page for the coast, then asked for the block in a
second round trip from the test. On a busy machine that trip outlasted the
coast's motion and the block landed after the thumb had settled (a judged
window of -17 to +8 ms in 10 of 10 runs on two pinned cores). The block now
starts from the frame in which the coast passes 48 ms: the page watches the
fill, which only a coast animates, from before the release, as
blockMainThread asks.
…l, from a paced flick, to two cast steps from rest

The busy-main-thread proof judges the coast against a compositor-only
control recorded in the same screencast. Its flick, 900 px/s over 60 ms,
keeps the script's spacing in WebKit, whose page reads the release velocity
from when the moves arrive, so a stalled dispatch no longer bunches the moves
behind it into a far faster throw whose coast reaches the end before the
block. It judges until two of the cast's own position steps from rest, so
WebKit's 2 px steps read as the cast's resolution, not a stall. The proof and
the release filter's stillness reset now run in WebKit too.
…e, with no coast

A finger's release coasts; a pen's, like a mouse's, ends where it lets go (component:Slider FR7). The case lifts a pen at 375 px/s, where a finger would coast, and fails if the release coasts.
…oaded runner cannot let the coast settle first

The coast runs on the compositor's clock and reaches its target about 0.4 s after the lift. The test read the value during the throw, then the thumb's box, then played the catching tap on its own, and on a loaded runner those round trips sometimes outlasted the coast: the finger landed on a thumb that had settled at the target (1 of 40 runs at four workers on two cores). The lift value now comes from the script, and a second finger lands 48 ms after the lift, about where the coast has carried the thumb, in the same paced script.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

The rest of the review is done in fcc9bcc. A press on a thumb focuses it for every pointer, as before; a unit case covers a mouse, a finger and a pen, and fails on the previous head for the finger and the pen. The harness work lives in #9545, and this pull request runs on main's harness. The UIKit slider table stays here because the controller reads it: landing it alone would ship an unused table. Element counts are in the target thread and the body.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec fcc9bcc

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: Slider's new tracking matches component:Slider FR7, and the two FR7 clauses this pull request adds are approved by an engineering owner at this head, but three things need fixing first.

  • Split the change (about 6,100 authored lines); the translate positioning rewrite and its geometry spec can land on their own.
  • Call observeResize from utils/sharedResizeObserver.ts instead of creating a ResizeObserver per slider in sliderTouchController.ts.
  • Play the end haptic only after a pan: a finger's tap on the track that sets an end vibrates today, which UISlider never does and FR7 does not ask for.

[Automated review]

// paint, so nothing jumps at the hand-back.
const Observer = parts.control.ownerDocument.defaultView?.ResizeObserver;
if (Observer != null) {
const observer = new Observer(() => this.followTrackLength());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Each slider creates its own ResizeObserver here, for every pointer, while it is enabled. Core already owns this: observeResize in utils/sharedResizeObserver.ts (react-component-runtime INV16, INV7). Suggest detachers.push(observeResize(parts.control, () => this.followTrackLength())); its first synchronous call is a no-op while no interaction is live.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a610788. attach() calls observeResize(parts.control, () => this.followTrackLength()), and its first synchronous call returns early with no live interaction. A unit case renders two sliders and counts one shared observer; the per-slider code made two.

const end = this.endOf(value);
// UISlider's end haptic is a touch moment: a finger or a pen plays it,
// and a mouse does not.
if (end != null && end !== interaction.atEnd && !interaction.isMouse) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A finger's track tap builds its interaction with isMouse: false, so a tap that sets an end vibrates, a moment UISlider does not have (spec:AST-094 FR23; the proposed FR7 limits it to a drag). Suggest adding && interaction.startValue != null, which only a begun pan sets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a610788. The haptic also needs interaction.startValue != null, which only a begun pan sets. A unit case taps the track's end, which played the haptic before and plays none now, then drags into the minimum, which plays it once.

Comment thread packages/core/src/Slider/Slider.tsx Outdated
// handlers below.
const isTouchPresentation = useTouchPresentation().isTouch;
const hasTouchTargets = isTouchPresentation && !isDisabled;
// Whether the slider's gestures are attached (useSliderTouch): the pointer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this comment describes a flag that no longer exists and has no code under it. Delete it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleted in a610788.

Comment thread packages/core/src/Slider/Slider.tsx Outdated
},
change: (index, next, from) =>
updateValueRef.current(index, next, true, from),
// A finger paints the thumb pressed and opens its value tooltip. It does

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: "It does not focus it" contradicts line 1029-1030 and 1243, where every press on a thumb focuses it. Suggest: "A finger paints the thumb pressed and opens its value tooltip. handlePointerDown already focused it."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a610788, with your wording.

…is drawn, and a coast that follows the track's live length, on main's harness

- The lift and the drop play the `springs.sliderLift` and
  `springs.sliderDrop` motion roles, which `component:Slider` FR7 now
  names, as the release coast does with `springs.sliderRelease`.
- Each thumb's 48 px touch target is its own `::before`, so a touch
  presentation renders no more elements than a mouse one, and the
  recognizers attach to the thumbs for every pointer.
- A press on a thumb focuses it for every pointer, as the base did and FR6
  relies on for coincident thumbs; a unit case fails on the previous Slider
  for a finger and a pen.
- The two Effects that only mirrored values into refs are gone, and the
  changeset drops the wheel clause. The end haptic plays only in a finger's
  or a pen's drag and its coast: a mouse and a track tap play none, as on
  UISlider (FR7 records iOS Safari's gap).
- A finger stops a coast where the browser draws the thumb: the stop reads
  the thumb's computed translate, not the animator's model of it, which a
  busy main thread reads past the frame the finger saw.
- A coast and a drag follow the track's live length: core's shared
  ResizeObserver (`observeResize`) watches the track, and a length change
  re-plays a running coast scaled to the new travel or re-places a drag
  before the next paint. A value label that takes a digit mid-gesture no
  longer makes the thumb jump when the parts hand back.
- Source comments keep only their reasons; `component:Slider`'s system
  specs and `spec:AST-094`'s path list carry the links the comments cited.
- The proofs run on main's interaction harness. The busy-main-thread proof
  runs in Chromium; its WebKit form moves to a follow-up on the harness's
  paced touch play, since WebKit's injected touches bunch behind a stalled
  dispatch and its JPEG cast moves in 2 px steps.
- The catch test sends its finger in its own play once the throw's lift has
  gone out, so a stalled dispatch cannot bunch the catch behind the lift. It
  reads the lift's value and the track's length at the lift, as the slider
  computes its coast from them.
- Disabling a slider mid-drag cancels the drag: `onChangeEnd` fires once,
  with the value the owner rendered, after the Effect's cleanup. An unmount
  stays silent.
- A collapsed track maps no travel, so a drag on it keeps the value.
- The docs say what a pen does.
- Comments use short sentences, one topic each, and file headers stay
  short.
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 2, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 2, 2026 21:25
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

On the split: this is a batch pull request, Batch: Slider · finger tracking (its body's own line), under the batch cap of about 8,000 authored lines, as the maintainers ruled. The translate positioning and its geometry spec are part of that batch. The two code points are fixed in a610788: the shared observeResize, and the end haptic after a pan only.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec a610788

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks ready to merge: every behavior change matches component:Slider FR5–FR7 and DEC-4, and the FR7 clauses this revision adds carry an engineering owner's approval for it. The shared resize observer and the drag-only end haptic resolve the two points from the previous revision, and the unit, Chromium and WebKit proofs pass. At about 6,200 authored lines the size is fine as an owner-directed batch under the cap.

[Automated review]

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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.

3 participants