Skip to content

feat(Slider): under a finger the thumb tracks like UISlider - #8190

Draft
vjeux wants to merge 1792 commits into
meta:mainfrom
vjeux:uikit/c03-slider
Draft

vjeux wants to merge 1792 commits into
meta:mainfrom
vjeux:uikit/c03-slider

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8360 (which carries #8076), #8168, #8189 and #8671. Review only this PR's change: vjeux/astryx@e40f7b6...7eb9b4c (since the approval: base merges, the review fixes, and the record change).

User need

Part of Astryx's UIKit-parity work (spec:AST-094), which makes touch presentations behave as on iOS. Under a finger, a Slider jumped to the touch and blocked scrolling; UISlider drags the thumb by the finger's travel and coasts on release.

Problem and solution fit

Under a coarse primary pointer (FR13) each thumb gets a 48 px target; close range thumbs split the gap (FR42). The value follows the finger's travel past 10 px with no render per move (FR9), then coasts; a track tap sets it on lift, and a swipe scrolls the page. Under Reduce Motion nothing grows, a tap applies at once, and the coast keeps moving (design:reduced-motion DR2). A held finger selects nothing; a mouse keeps today's slider.

Current specification

component:Slider FR5 (onChangeEnd waits for the coast), and FR6 and DEC-4, added here as the record's owner decided: no prop restores the old touch behavior. design:user-states DR1 (the lifted thumb keeps its pressed look).

Public delta and caller burden

No API change. Under a finger onChange keeps firing about 0.4 s past the lift, and the thumb takes no focus. Size (component-size.js, main to head): JavaScript 48.5 → 81.3 KB gzip, CSS 8.5 → 8.9 KB.

Design and alternatives

  • UISlider's travel, not a 1:1 drag: a 1:1 drag (Android's Material slider) jumps the thumb to the finger; the cost of travel is a 10 px threshold.
  • A compositor coast, not a script animation: it keeps moving through a busy main thread.
  • A track tap sets the value on lift: UISlider ignores it; WCAG 2.5.7 asks for a single-pointer path.
  • Pending owner decisions: the tooltip lags a composited thumb; UIKit's 37 × 24 thumb over theme shapes.

Evidence

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

Android before: jumps, no coast

Android after: lift, travel, coast, tap

iOS: track tap on lift

iOS: a held thumb selects nothing

iOS Reduce Motion: before fades; after, the coast moves on and a tap lands at once

Coast, slow and early lifts

iOS: coast through a busy thread

List: a vertical swipe scrolls

Catching the coast

Past the end, RTL, vertical

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

In Storybook's Slider stories, on a phone or with touch emulation:

  1. Drag a thumb: it grows, holds for 10 px, then follows without jumping.
  2. Lift while moving: it coasts and settles. Lift after resting: it stays.
  3. Tap the track: the value moves there on lift. Swipe across it: the page scrolls.
  4. With Reduce Motion: no growth; a tap sets the value at once; a release still coasts.
  5. With a mouse: as before.

Run on this head, locally (fork pull requests start no CI): each step is a unit or real-touch browser test in Chromium and WebKit, all passing, as are the full core suite, typecheck, lint, the builds and pnpm check:knowledge. It carries #8671 per-component bundle check: Slider's touch layer puts gesture code in the root entry, which the old check forbids, and the per-component check passes here.

Writing rubric results

  • R1 narration: none; FR6 and DEC-4 are present tense, and Compatibility states the released difference, as that section does.
  • R2 research: none; the UIKit numbers live in motion/uikit/slider.
  • R3 evidence: FR6 cites the UIKit measurements and DEC-4.
  • R4 rejected alternatives: one, in DEC-4, one line.
  • R5 change references: none.
  • R6 open questions: none touched.

@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 Oct 30, 2026 12:44am 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

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 1:05 AM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

Slider (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 389B 389B 0B
Lines of Code 986 2028 -
Complexity Very High Very High (173) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.3KB 57.6KB +290B 12.5KB 12.5KB 0B
core/AppShell 66.8KB 67.0KB +261B 11.8KB 11.8KB 0B
core/AvatarGroupOverflow 40.3KB 40.6KB +302B 6.4KB 6.4KB 0B
core/Banner 48.5KB 48.8KB +289B 10.1KB 10.1KB 0B
core/BaseTypeahead 55.0KB 55.2KB +273B 10.1KB 10.1KB 0B
core/BreadcrumbItem 67.3KB 67.6KB +266B 11.2KB 11.2KB 0B
core/Button 43.7KB 43.9KB +274B 9.3KB 9.3KB 0B
core/Calendar 55.6KB 55.9KB +260B 10.2KB 10.2KB 0B
core/Carousel 50.9KB 51.1KB +265B 10.3KB 10.3KB 0B
core/ChatComposer 61.3KB 61.6KB +257B 11.0KB 11.0KB 0B
core/ChatComposerInput 56.2KB 56.4KB +256B 10.2KB 10.2KB 0B
core/ChatComposerTokenElement 56.2KB 56.4KB +260B 10.2KB 10.2KB 0B
core/ChatDictationButton 47.2KB 47.4KB +275B 9.6KB 9.6KB 0B
core/ChatLayout 49.5KB 49.8KB +303B 10.1KB 10.1KB 0B
core/ChatLayoutScrollButton 47.0KB 47.3KB +278B 9.7KB 9.7KB 0B
core/ChatSendButton 46.4KB 46.7KB +277B 9.3KB 9.3KB 0B
core/CheckboxInput 50.5KB 50.8KB +264B 9.5KB 9.5KB 0B
core/CheckboxListItem 54.7KB 54.9KB +275B 10.6KB 10.6KB 0B
core/ClickableCard 12.8KB 13.1KB +289B 6.9KB 6.9KB 0B
core/CodeBlock 61.2KB 61.4KB +272B 10.8KB 10.8KB 0B
core/Collapsible 18.7KB 19.0KB +262B 4.4KB 4.4KB 0B
core/CommandPalette 51.5KB 51.8KB +269B 12.0KB 12.0KB 0B
core/CommandPaletteItem 8.4KB 8.7KB +280B 3.5KB 3.5KB 0B
core/ComplexSelector 56.5KB 56.8KB +286B 10.8KB 10.8KB 0B
core/ContextMenu 75.3KB 75.6KB +281B 14.8KB 14.8KB 0B
core/ContextMenuItem 53.5KB 53.8KB +259B 9.8KB 9.8KB 0B
core/DateInput 100.2KB 100.5KB +288B 14.9KB 14.9KB 0B
core/DateRangeInput 70.1KB 70.4KB +277B 11.4KB 11.4KB 0B
core/DateTimeInput 110.4KB 110.7KB +289B 15.4KB 15.4KB 0B
core/DialogHeader 49.5KB 49.8KB +295B 10.0KB 10.0KB 0B
core/DrawerHeader 49.6KB 49.8KB +261B 11.4KB 11.4KB 0B
core/DropdownMenu 73.4KB 73.7KB +280B 15.0KB 15.0KB 0B
core/DropdownMenuCheckboxItem 16.4KB 16.6KB +276B 5.7KB 5.7KB 0B
core/DropdownMenuItem 15.6KB 15.9KB +268B 5.3KB 5.3KB 0B
core/DropdownMenuRadioItem 16.4KB 16.7KB +275B 5.7KB 5.7KB 0B
core/DropdownMenuSubMenu 58.7KB 59.0KB +263B 9.7KB 9.7KB 0B
core/FileInput 54.8KB 55.0KB +267B 10.5KB 10.5KB 0B
core/IconButton 43.7KB 44.0KB +279B 9.3KB 9.3KB 0B
core/Item 11.4KB 11.6KB +273B 4.7KB 4.7KB 0B
core/LayerProvider 64.0KB 64.3KB +269B 10.3KB 10.3KB 0B
core/Lightbox 51.9KB 52.1KB +254B 10.0KB 10.0KB 0B
core/Link 44.1KB 44.4KB +274B 7.7KB 7.7KB 0B
core/ListItem 54.1KB 54.4KB +253B 10.6KB 10.6KB 0B
core/Markdown 145.2KB 145.5KB +268B 17.8KB 17.8KB 0B
core/MobileNav 51.7KB 51.9KB +240B 10.2KB 10.2KB 0B
core/MobileNavToggle 46.9KB 47.1KB +270B 9.6KB 9.6KB 0B
core/MoreMenu 73.7KB 74.0KB +284B 15.0KB 15.0KB 0B
core/MultiSelector 73.6KB 73.9KB +295B 14.8KB 14.8KB 0B
core/NumberInput 56.4KB 56.7KB +307B 10.9KB 10.9KB 0B
core/Outline 29.0KB 29.3KB +286B 4.0KB 4.0KB 0B
core/Pagination 85.5KB 85.7KB +263B 15.5KB 15.5KB 0B
core/Popover 50.3KB 50.5KB +278B 12.2KB 12.2KB 0B
core/PowerSearch 166.6KB 166.9KB +305B 20.1KB 20.1KB 0B
core/RadioListItem 52.8KB 53.1KB +276B 9.8KB 9.8KB 0B
core/SegmentedControlItem 8.6KB 8.9KB +285B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.1KB +294B 7.1KB 7.1KB 0B
core/Selector 77.0KB 77.3KB +277B 15.2KB 15.2KB 0B
core/SelectorOption 14.7KB 15.0KB +262B 5.2KB 5.2KB 0B
core/SideNav 64.6KB 64.9KB +249B 10.7KB 10.7KB 0B
core/SideNavCollapseButton 47.3KB 47.5KB +266B 9.6KB 9.6KB 0B
core/SideNavHeading 59.2KB 59.4KB +273B 10.4KB 10.4KB 0B
core/SideNavItem 55.3KB 55.5KB +258B 10.3KB 10.3KB 0B
core/Slider 49.1KB 82.0KB +32.9KB ⚠️ 8.7KB 9.1KB +401B
core/Step 33.0KB 33.3KB +274B 5.5KB 5.5KB 0B
core/Stepper 48.1KB 48.3KB +270B 9.7KB 9.7KB 0B
core/Switch 49.0KB 49.3KB +279B 9.5KB 9.5KB 0B
core/Tab 9.7KB 10.0KB +294B 4.1KB 4.1KB 0B
core/TabMenu 55.9KB 56.2KB +261B 10.2KB 10.2KB 0B
core/Table 84.7KB 84.9KB +259B 15.6KB 15.6KB 0B
core/TableCell 76.5KB 76.7KB +280B 15.3KB 15.3KB 0B
core/TableHeaderCell 76.1KB 76.3KB +261B 15.0KB 15.0KB 0B
core/TableSelectionToolbar 52.8KB 53.1KB +277B 11.3KB 11.3KB 0B
core/TextInput 54.2KB 54.5KB +265B 10.6KB 10.6KB 0B
core/Thumbnail 51.4KB 51.6KB +271B 11.1KB 11.1KB 0B
core/TimeInput 87.5KB 87.7KB +220B 13.9KB 13.9KB 0B
core/Timestamp 27.9KB 27.9KB -1B 9.7KB 9.7KB 0B
core/Toast 60.6KB 60.8KB +270B 9.9KB 9.9KB 0B
core/ToggleButton 44.5KB 44.8KB +245B 9.4KB 9.4KB 0B
core/Token 30.2KB 30.4KB +268B 4.6KB 4.6KB 0B
core/Tokenizer 69.3KB 69.6KB +257B 16.7KB 16.7KB 0B
core/TopNav 52.5KB 52.8KB +257B 10.5KB 10.5KB 0B
core/TopNavHeading 58.6KB 58.9KB +284B 10.2KB 10.2KB 0B
core/TopNavItem 9.0KB 9.3KB +283B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 55.0KB 55.3KB +271B 11.0KB 11.0KB 0B
core/TopNavMegaMenuItem 9.1KB 9.4KB +281B 4.1KB 4.1KB 0B
core/TopNavMenu 58.3KB 58.5KB +265B 10.3KB 10.3KB 0B
core/TreeList 40.4KB 40.6KB +273B 4.8KB 4.8KB 0B
core/Typeahead 62.2KB 62.4KB +275B 11.4KB 11.4KB 0B
lab/ChatEmojiPicker N/A 66.0KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 66.6KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 95.0KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 48.2KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 217.0KB N/A N/A 2.4KB N/A
lab/Comments N/A 221.6KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.4KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.8KB N/A N/A 722B N/A
lab/ListInput N/A 76.1KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 147.5KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 93.0KB N/A N/A 809B N/A
lab/Schedule N/A 60.7KB N/A N/A 3.9KB N/A
lab/TourStep N/A 55.9KB N/A N/A 590B N/A
lab/TransferList N/A 67.7KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 73.3KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.7KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 213.3KB N/A N/A 454B N/A
richtext/RichTextView N/A 252.1KB N/A N/A 2.3KB N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/Slider +32.9KB ⚠️ +401B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 35.4KB 35.7KB +276B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 504 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


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

@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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 added 9 commits October 29, 2026 10:16
… the inline style it reads

- The motion registry, the host's engine gate and the README name the
  presented hit test as an engine workaround (spec:AST-013 FR6) with its
  review condition (re-measure on each iOS release; remove it once iOS
  WebKit hit-tests a compositor-moved element where it is drawn) and its
  regression fixture.
- While a curve runs the animator holds the value it starts from inline,
  so presentation.ts and the README no longer say the inline style holds
  the end.
…core/internal/touch

Core ships the internal entry @astryxdesign/core/internal/touch, which
re-exports the gesture primitives for Astryx's own packages, and the
Lab-import guard fails a Lab import of core outside core's exports
(architecture:react-component-runtime INV13). The README and the barrel's
header named internal workspace imports and said nothing here is
exported; they now name that entry.
…ill needs the element, and the listeners that cancel

The release note, the stream's header, the host's comment and the README
said the stream is one passive listener per event type and that a
document-capture stopPropagation no longer starves the recognizers. Such a
stop never hid a listener on the same node, and some subscriptions cancel.
They now say what changed: an app's window-capture stopPropagation, or a
document-capture stopImmediatePropagation registered before core, no
longer hides a touch; a recognizer that must claim still needs the touch to
reach its element; the gesture host's touchend, click and contextmenu
cancel while a claimer is attached and the page hold's touchmove while it
holds; and the stream stays ahead of an app's window-capture listener only
until a type's listener is re-added. The README's list of blocking
touchmove listeners gains the claimed touch's target and the document
while a claiming element moves.
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
…ads it

Slider's touch presentation (meta#8190) reads the table, and a release that
ships both would print the old line in its notes. The changeset now says
only that the table is not exported.
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

This needs an owner's call on recording Slider's touch presentation in component:Slider. AST-094 FR13 leaves it to Slider's record. Under a finger the thumb tracks the finger's travel past 10 px and coasts on release, and a track tap sets the value on lift; onChangeEnd waits for the coast. The options: record it as built, or add an opt-out prop back to the pointer behavior (a new public prop). My pick is to record it as built, since it's iOS's measured behavior. The spec change follows the owner's word.

vjeux added 9 commits October 29, 2026 12:30
# Conflicts:
#	.github/workflows/ci.yml
meta#8320), meta#8189 at ca43cee, meta#8168 at 1cb400e

# Conflicts:
#	packages/cli/test/__golden__/no-icon-set/search-icon-set.json
#	packages/cli/test/__golden__/no-icon-set/search-icon-set.txt
…r path

Each case fails on this head:

- Under Reduce Motion a release's momentum keeps moving and a track tap
  sets its value at once, both at full opacity (design:reduced-motion DR2:
  a throw's momentum is direct movement; an in-place value change applies
  at once). The thumb cross-fades both today.
- Range thumbs closer than their touch targets split the gap, so a finger
  on the lower thumb's exposed side takes the lower thumb (spec:AST-094
  FR42). Today the upper thumb's 48 px target covers it.
- A valueDisplay change that remounts the thumbs keeps their gestures.
  Today the new touch targets have none.
- A finger's drag moves the thumb even when the owner renders nothing, its
  position written straight to the thumb (spec:AST-094 FR9). Today the
  thumb moves only through the owner's render.

The pen case now advances the frames before it asserts, so it checks what a
pen's tap on the track does: it sets the value on its lift, as a finger's
does.
…s gap, remounts, and a drag off the render path

- Under Reduce Motion a release's coast plays as momentum (the animator's
  decay), so it keeps moving, and a tap on the track sets its value at once:
  design:reduced-motion DR2 calls a throw's momentum direct movement and
  an in-place value change immediate. Neither cross-fades now.
- Each thumb's touch target grows to UISlider's 48 pt, but on the side
  facing a range's other thumb only to half the gap between them
  (spec:AST-094 FR42), computed in container units so it needs no layout
  read. A finger on the lower thumb no longer drags the upper one.
- The thumbs' gestures re-attach when a valueDisplay change wraps or
  unwraps the thumbs in their value tooltips, which mounts new targets.
- Each move of a drag writes the thumb's and the fill's positions straight
  to their translates (spec:AST-094 FR9), with onChange still reported per
  value; the parts go back to the stylesheet when the interaction ends, and
  a finished interaction no longer hands back the parts of one still being
  dragged.
- The Effects that hand the controller its model and parts getter, and the
  one that disposes it, name what they synchronize
  (architecture:react-component-runtime INV3).
…EC-4)

AST-094 FR13 leaves each component's touch presentation to its own record.
FR6 records Slider's: under a coarse primary pointer a thumb takes a finger
or a pen through UISlider's 48 pt target, clamped to half the gap facing a
range's other thumb; a drag moves the value by the finger's travel past
the 10 px threshold with no render per move; the release coasts on
UISlider's measured spring; a tap on the track sets the value on lift; and
Reduce Motion follows DR2. FR5's interaction ends, under a finger, when the
coast settles. DEC-4 records the decision, with no prop to restore the
released behavior.
…ame window

Both 'a dip held before its first frame lands once the hold is …' proofs
waited for no running animation, then one frame and a timeout, assuming
the next step starts within a frame. The dip lands on a timer due on the
call's own clock, though, and the proofs passed only because the evaluate
round trips covered the gap: in WebKit on a loaded box the window closed
first and the proof read the element mid-dip (opacity 0, translate none;
reproduced 1 in 4 with the round trips removed). They now wait on the
call's own finished, then on the browser's own animations still running
or waiting for their first frame, and read nothing from the animator.
…ment at once and settles then

A Reduce Motion dip whose landing runs more than its whole fade back in
late (a hold released long after the dip was due) played a 1 ms opacity
animation, which WebKit keeps pending until its next frame, and settled
the call on a 1 ms timer: the call's finished could resolve while the
element still showed opacity 0. The animator's notes say the element
appears at once there. Fails now: the landing plays one opacity animation
and the call has not settled.
…element at once and settles

A Reduce Motion landing that runs at least the whole fade back in late
had nothing left of the cross-fade, but still played a 1 ms opacity
animation and settled the call 1 ms later. WebKit keeps that animation
pending until its next frame, so the call's finished could resolve with
the element still at opacity 0. Such a landing now writes the end opacity
at once (handing a borrowed one back to the stylesheet), plays nothing,
and settles the call then, as the animator's notes say.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:33
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 7eb9b4c, answering the Oct 28 review point by point. Each fix came test-first: every new test failed on the previous head.

@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.

Approval is on hold for an engineering owner's decision: this revision writes Slider's touch presentation into component:Slider (FR6, an FR5 clause that ends a finger's interaction after the coast, and DEC-4, which rejects a prop restoring today's touch behaviour), and an engineering owner needs to approve or reject those requirements by commenting /approve-spec with the full head SHA, which the author may do as an engineering owner. That decision should also settle DEC-4 against spec:AST-017 DEC-23 and FR3, which ask a touch default for a way back, or state why architecture:public-component-api INV16's exemption for a component's own touch-only behaviour covers Slider. Whatever the decision, these still need changes:

  • Register the slider's springs as motion roles (AST-094 FR4) and record the end haptic's iOS Safari gap in FR6 (FR23).
  • Carry the touch target without an extra element under touch, or record an exception with element counts, and report counts for the new travel elements (AST-017 FR60, INV7).
  • Land or split the four dependency branches behind the 32,667 changed lines, including the gesture host's coarse-only gate (AST-094 FR19, FR43), and add the WebKit busy-thread and real-device evidence AST-094 asks for.

[Automated review]

upper: (upperValue - min) * pxPerValue,
// UIKit's release spring, an internal constant (touch motion has no
// theme surface).
spring: UIKIT_SLIDER.releaseSpring,

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 spring, like the lift and drop springs at 948 and 961, is passed inline, so the animator plays it as a literal. spec:AST-094 FR4 says a component's own measured roles "join through the generated motion role table (DEC-5)" and that a component "MUST NOT play a literal"; registering the slider table with registerMotionTable (as motion/uikit/navigation.ts and motion/uikit/scroll.ts do) and naming the roles in FR6 would conform.

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

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 end haptic fires mid-drag and mid-coast, where iOS Safari can play nothing. spec:AST-094 FR23 asks that "the component records that gap"; FR6 in component:Slider does not mention it yet.

undefined,
positionStyle,
)}>
{hasTouchTargets && (

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 span exists only under touch, so a touch Slider renders one more element per thumb than a mouse one. spec:AST-017 FR60: "Under touch, a component renders no more elements than under a fine pointer"; a hit area drawn by a pseudo-element on the thumb (with the recognizers on the thumb), or an owner exception with element counts in FR6, would resolve it.


Under a finger Slider takes iOS's measured slider behavior (FR6), the touch presentation `spec:AST-094/FR13` leaves to this record: the thumb follows the finger's travel rather than jumping to it, and a release coasts. A tap on the track sets the value on lift, where UISlider ignores it, so a drag is not the only pointer path (WCAG 2.2 SC 2.5.7).

Rejected: a prop that restores the released behavior under a finger.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-017 DEC-23 rejects "a touch presentation or open-surface touch mode callers cannot pin", and FR3 asks the changeset to name the restoring input. If Slider's tracking is meant to be touch-only behaviour its own record defines (architecture:public-component-api INV16), saying so here would let the approver decide both at once.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…fore and after (a release coasts on; a tap on the track lands at once)
cixzhang pushed a commit that referenced this pull request Oct 30, 2026
…nothing else does (#8671)

## Why

Every pull request that gives a component a gesture fails `test-node` once merged with main, and the failure names no component: `scripts/touch-support-bundle.test.mjs` requires Core's root and hooks entries to carry no gesture code. At least six open pull requests (#8142, #8190, #8337, #8159, #8098, #8658) fail it this way.

## What led to it

This comes from the work to make Astryx's touch components behave as native iOS ones do. Since #8465, a component that uses a gesture imports the touch modules directly (`architecture:react-component-runtime` INV13), and the root entry re-exports every component, so it carries gesture code as soon as one component does.

## Options and tradeoffs

- Exempt the root entry: unblocks everyone, but stops catching a component that pulls gesture code it does not use.
- Check each public import on its own: catches exactly that, at the cost of a list of the imports allowed to carry gesture code.

## Why this choice

The per-import check keeps INV13's promise, a component without gestures ships none of their code, while letting gesture users through by name. It was offered on #8562 and aimed at main's test.

## What changes

- Each public import of Core is built the way the size report builds it, and may carry gesture code only if `GESTURE_USERS` names it. A failure lists the import, its gesture modules with their sizes, and the chain of imports that brings them.
- The root entry may carry only what a named import carries; the utils entry stays gesture-free.
- A strict case keeps Button and TextInput free of gesture code whatever the list says.
- `GESTURE_USERS` starts empty, so every import stays as strict as on main. The pull request that gives a component a gesture adds that component's import, and each import that reaches it, with a comment naming the gesture, so the new gesture user shows up in review.

## How to check it

1. Run `pnpm exec vitest run --project node scripts/touch-support-bundle.test.mjs` on main with this branch merged: it passes.
2. On a tree whose component uses a gesture, run it without naming that import in `GESTURE_USERS`: the test fails and names that import and the chain that brings the gesture code. Name it, and the test passes.
3. Import a gesture module from Button: the strict case fails.

## Evidence

- With the list empty, the test passes on main: no public import carries gesture code today.
- On open trees that give a component a gesture (AppShell's scroll-edge bar, Slider, the List reorder), the per-import case fails naming exactly those imports and their chains, and passes once each is named.
- No product code changes.

## Scope

- [x] No intended public API, product behavior, visual direction, or policy change.
- [x] Product changes discovered during the work were removed or split.
- [x] Public text and artifacts contain no internal Meta context.

This branch was successfully deployed

1 active deployment
Preview — 7eb9b4c3 Deployed Oct 30, 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants