Skip to content

feat(core): Switch follows a finger the way the iOS switch does - #8124

Draft
vjeux wants to merge 1955 commits into
meta:mainfrom
vjeux:uikit/c02-switch-touch
Draft

vjeux wants to merge 1955 commits into
meta:mainfrom
vjeux:uikit/c02-switch-touch

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8118 (which carries #8360) and #8123, merged in. Review only this PR's change: vjeux/astryx@c02-8124-base...uikit/c02-switch-touch

This push re-applies this PR's Switch rows on main's current Switch record (FR10, FR13–FR15), so it needs a fresh spec approval; it also merges #8118's fold onto #8360. This PR's code is unchanged apart from comments citing the new FR numbers.

Why

On a phone, Switch acts like a desktop checkbox: a finger can't drag its thumb (P3), and it freezes while the page runs scripts (P10).

What led to it

spec:AST-094 (current) asks every control to feel native under a finger: follow it, keep moving while scripts run, and never take the page's scroll. Switch had only the browser checkbox. #8123 measured iOS's own switch for this one to match; component:Switch FR10 and FR13–FR15 change here.

Options and tradeoffs

  1. CSS only: no script, but no drag, and transitions stall while the page is busy.
  2. A script drag that restyles the thumb each frame: it follows, but stutters under load and competes with scrolling.
  3. Passive tracking plus compositor animations (translate, scale, opacity only): follows the finger, keeps moving through a blocked main thread, and a vertical swipe still scrolls. It costs the shared touch layer (34.2 KB gzip), which every touch component reuses.

Two behaviours picked a side:

  • Slid off, then lifted: it toggles, as iOS does and spec:AST-020 FR9 allows (P19).
  • A hold, then a vertical drag: the page scrolls where iOS keeps the touch, because the switch only listens passively (spec:AST-094 FR11, FR20).

Why this choice

Only option 3 meets AST-094's motion and scroll rules; its cost is shared, and the mouse, keyboard and screen-reader paths are unchanged. No API change: the thumb keeps its circle and size, and under a coarse pointer its hit area grows toward 44 px, stopping halfway to a neighbour (P17).

How to check it

Open Storybook's Switch › Default on a phone or the iOS simulator:

  1. Tap: the thumb lifts, travels, and the switch turns on.
  2. Drag the thumb 30 pt right: it flips mid-drag and stays on (P3).
  3. Drag past the middle and back, then let go: nothing changes (P4).
  4. Slide off the switch and lift: it toggles (P19).
  5. In In scrolling list, swipe up from a switch: the page scrolls and nothing toggles (P7, P8).
  6. Reduce Motion: a tap cross-fades (P12). Right to left: dragging left flips (P13). Long-press the label: its text selects (P2). A mouse click toggles as before (P11).

Switch.interaction.browser.spec.ts runs these with real touch input in Chromium and WebKit. The one red check, "is absent from the root entry", is the old bundle test that #8671 replaces.

Evidence

iOS 26.2 simulator and Android 15 emulator: main, this PR, then iOS's own switch where shown; the red ring is the finger. P4 runs in Chromium; P11 uses a mouse. A3 and A8 show this PR only: main's takes drop frames.

P1
P2
P3
P4
P7
P8
P9
P10
P11
P12
P13
P14
P15
P16
P17
P18
P19
A1
A2
A3
A8
A9

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.

Writing rubric results

packages/core/src/Switch/Switch.spec.md:

  • R1 narration: none; FR10, FR13–FR15 and the re-applied sections are present tense on main's current record.
  • R2 research in the record: none.
  • R3 evidence naming a change: none; FR13 and FR14 cite the owner's dated direction, FR15 cites spec:AST-094 FR11 and spec:AST-020 FR9.
  • R4 rejected alternatives over one line or outside a decision: none.
  • R5 pull request or issue references: none.
  • R6 ruled open questions still listing options: none.

@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 1:53am UTC

Request Review

@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, 3:59 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 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.

Approval waits on an engineering-owner decision: under a coarse pointer this change replaces the pressed overlay that the current design:user-states DR1 requires for the Switch track and thumb, and it builds on the draft spec:AST-094, which no owner has approved yet. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), with the DR1 Switch row amended for coarse pointers; otherwise Switch keeps the overlay on touch. Whatever the decision, these need changes:

  • Land the stacked predecessors and rebase, so this pull request holds only the Switch commit instead of 141 files and about 25,700 changed lines.
  • Make the page-wide text-selection guard shared across switches, because tapping a second switch within half a second of the first leaves user-select: none on <html> until reload (useSwitchTouch.ts).
  • Paint the checked colour through the switch theme target again under a finger, and correct the changeset, which says scrolling never waits although a scroll that starts on a switch waits for a busy main thread in the default mode.

[Automated review]

Comment on lines +568 to +582
const previous = ['-webkit-user-select', 'user-select'].map(
property => [property, root.style.getPropertyValue(property)] as const,
);
for (const [property] of previous) {
root.style.setProperty(property, 'none');
}
this.unguardSelection = () => {
for (const [property, value] of previous) {
if (value === '') {
root.style.removeProperty(property);
} else {
root.style.setProperty(property, value);
}
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Each switch saves and restores the root's inline user-select on its own. Tap switch A, then switch B within 500 ms: B saves A's none, A's timer restores '', then B's timer restores none, and every later guard saves and restores that none, so <html> stays unselectable until reload. A single per-document guard with a count (save on the first acquire, restore on the last release) avoids it; a browser test that taps two switches inside the window would cover 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 by removal: the page-wide guard is gone. The fourth commit locks text selection on the input the finger lands on (an input's own -webkit-user-select: text ignores its ancestors, which is what opened the loupe), and the stacked gesture-host change locks held press trackers, so nothing writes to <html> any more.

Comment thread packages/core/src/Switch/Switch.tsx Outdated
Comment on lines +390 to +400
trackOnLayer: {
display: {default: 'none', [TOUCH]: 'block'},
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
pointerEvents: 'none',
backgroundColor: {
default: colorVars['--color-accent'],
'@media (forced-colors: active)': 'Highlight',
},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a coarse pointer this untargeted layer paints --color-accent over the switch theme target whenever the switch is on, so a theme that sets the target's checked fill (the probe theme does, probeTheme.ts:4567-4568) no longer shows it on touch devices. Keeping the checked fill on the switch target, or having this layer read a variable the target's checked rule sets, keeps the theming surface intact.

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 the sixth commit. Under a finger the track now keeps its own fill in both states, so the switch target paints it as it does under a mouse. The fade layer over it no longer paints the accent: it takes the track's fill in the other state, read from the track itself (switchFill.ts swaps the track to that state's class and attributes, reads the computed colour and swaps back in the same task), and fades it on the compositor. New browser cases check the probe theme's fill under a finger, off and on, on iOS and Android; they fail before this commit.

Comment thread .changeset/switch-touch-presentation.md Outdated
'@astryxdesign/core': patch
---

[feat] On a touch screen, Switch behaves like the iOS switch. A press lifts the thumb into a glass lens; a horizontal drag moves the thumb with the finger and flips the switch once the finger has travelled most of the track; lifting the finger commits, and a tap or a hold toggles. A vertical drag that starts on a switch scrolls the page, as before. The motion runs on the compositor, so it stays smooth while scripts run, and the switch never makes the page's scrolling wait for script. Mouse, keyboard and assistive technology behave as before.

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 switch never makes the page's scrolling wait for script" does not hold for the default claiming mode (SWITCH_CLAIMS_HELD_TOUCH = true): its non-passive touchmove listener makes a scroll that starts on a switch wait for a busy main thread, as switchTouchClaim.ts and the pull request body say. Stating that limit here would keep the release note accurate.

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: the changeset now says that a finger resting on the switch first keeps the touch, as in a UIKit list, so a scroll that starts on the switch waits for a busy main thread.

@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

Button (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 712B 712B 0B
Lines of Code 584 591 -
Complexity Very High Very High (83) -
ClickableCard (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 427B 427B 0B
Lines of Code 229 231 -
Complexity High High (20) -
Item (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 284B 284B 0B
Lines of Code 1372 1378 -
Complexity Very High Very High (122) -
SelectableCard (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 108B 108B 0B
Lines of Code 305 307 -
Complexity Very High Very High (33) -
Switch (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 398B 398B 0B
Lines of Code 569 1714 -
Complexity Very High Very High (55) -
Thumbnail (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 164B 164B 0B
Lines of Code 355 357 -
Complexity Very High Very High (46) -
TreeList (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 648B 648B 0B
Lines of Code 872 879 -
Complexity Very High Very High (100) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
charts/Chart N/A 67.0KB N/A N/A 20B N/A
charts/ChartAxis N/A 7.0KB N/A N/A 112B N/A
charts/ChartLegend N/A 56.3KB N/A N/A 20B N/A
charts/ChartTooltip N/A 56.2KB N/A N/A 20B N/A
core/AlertDialog 57.0KB 60.4KB +3.3KB ⚠️ 12.4KB 13.2KB +805B
core/AppShell 58.0KB 61.1KB +3.1KB ⚠️ 11.6KB 12.4KB +811B
core/AspectRatio 3.7KB 4.1KB +374B 3.5KB 3.7KB +261B
core/Avatar 37.6KB 37.9KB +360B 6.3KB 6.5KB +234B
core/AvatarGroup 39.9KB 40.3KB +360B 6.4KB 6.7KB +230B
core/AvatarGroupOverflow 40.1KB 42.8KB +2.7KB ⚠️ 6.4KB 6.6KB +234B
core/AvatarStatusDot 7.1KB 7.4KB +380B 3.4KB 3.7KB +264B
core/Badge 3.9KB 4.3KB +383B 3.8KB 4.1KB +255B
core/Banner 48.0KB 51.3KB +3.3KB ⚠️ 9.9KB 10.7KB +807B
core/BaseTypeahead 54.5KB 57.7KB +3.3KB ⚠️ 9.9KB 10.7KB +816B
core/Blockquote 3.5KB 3.9KB +383B 3.4KB 3.6KB +263B
core/BottomSheet 33.9KB 34.3KB +381B 7.9KB 8.1KB +250B
core/BottomSheetSwitcher 14.8KB 15.2KB +377B 3.3KB 3.5KB +264B
core/BreadcrumbItem 66.5KB 70.2KB +3.7KB ⚠️ 11.0KB 12.0KB +1.0KB ⚠️
core/Breadcrumbs 22.6KB 22.9KB +370B 3.3KB 3.5KB +263B
core/Button 43.4KB 46.7KB +3.3KB ⚠️ 9.2KB 10.0KB +806B
core/ButtonGroup 8.4KB 8.8KB +388B 3.3KB 3.5KB +264B
core/Calendar 55.1KB 58.4KB +3.3KB ⚠️ 10.0KB 10.8KB +801B
core/Card 6.4KB 6.8KB +376B 6.3KB 6.6KB +267B
core/Carousel 50.4KB 53.7KB +3.3KB ⚠️ 10.2KB 11.0KB +805B
core/Center 4.2KB 4.5KB +384B 4.6KB 4.8KB +266B
core/ChatComposer 60.8KB 64.1KB +3.3KB ⚠️ 10.8KB 11.6KB +805B
core/ChatComposerDrawer 24.0KB 24.4KB +414B 4.5KB 4.8KB +254B
core/ChatComposerInput 55.9KB 59.2KB +3.3KB ⚠️ 10.1KB 10.9KB +808B
core/ChatComposerTokenElement 55.9KB 59.2KB +3.3KB ⚠️ 10.1KB 10.9KB +808B
core/ChatDictationButton 46.6KB 50.0KB +3.3KB ⚠️ 9.5KB 10.3KB +805B
core/ChatLayout 49.0KB 52.3KB +3.3KB ⚠️ 9.9KB 10.7KB +803B
core/ChatLayoutScrollButton 46.5KB 49.9KB +3.3KB ⚠️ 9.6KB 10.3KB +805B
core/ChatMessage 23.2KB 23.6KB +382B 3.5KB 3.8KB +261B
core/ChatMessageBubble 7.0KB 7.4KB +379B 3.6KB 3.9KB +262B
core/ChatMessageList 29.1KB 29.4KB +381B 7.8KB 8.0KB +238B
core/ChatMessageMetadata 26.0KB 26.3KB +363B 3.9KB 4.1KB +246B
core/ChatSendButton 46.0KB 49.3KB +3.3KB ⚠️ 9.2KB 10.0KB +804B
core/ChatSystemMessage 6.7KB 7.1KB +386B 3.7KB 3.9KB +261B
core/ChatTokenizedText 6.9KB 7.3KB +370B 3.9KB 4.1KB +255B
core/ChatToolCalls 32.0KB 32.4KB +371B 8.9KB 9.1KB +239B
core/CheckIndicator 9.0KB 9.4KB +384B 3.6KB 3.8KB +261B
core/CheckboxIndicator 4.1KB 4.5KB +385B 3.8KB 4.0KB +241B
core/CheckboxInput 50.1KB 52.8KB +2.7KB ⚠️ 9.3KB 9.5KB +237B
core/CheckboxList 46.2KB 46.5KB +371B 8.1KB 8.4KB +237B
core/CheckboxListItem 54.2KB 57.5KB +3.3KB ⚠️ 10.5KB 11.2KB +767B
core/Citation 26.1KB 26.4KB +388B 4.1KB 4.4KB +262B
core/ClickableCard 12.8KB 15.9KB +3.1KB ⚠️ 6.9KB 7.7KB +813B
core/Code 3.5KB 3.9KB +383B 3.4KB 3.6KB +267B
core/CodeBlock 60.7KB 63.9KB +3.3KB ⚠️ 10.7KB 11.5KB +819B
core/Collapsible 11.7KB 14.4KB +2.7KB ⚠️ 4.2KB 4.5KB +260B
core/CollapsibleGroup 6.4KB 6.7KB +375B 3.2KB 3.4KB +263B
core/CommandPalette 51.0KB 53.8KB +2.8KB ⚠️ 11.8KB 12.1KB +236B
core/CommandPaletteEmpty 6.1KB 6.5KB +372B 3.3KB 3.6KB +265B
core/CommandPaletteFooter 23.4KB 23.8KB +391B 3.5KB 3.8KB +261B
core/CommandPaletteGroup 3.5KB 3.8KB +378B 3.3KB 3.6KB +262B
core/CommandPaletteInput 32.5KB 32.9KB +396B 8.1KB 8.4KB +244B
core/CommandPaletteItem 8.4KB 11.1KB +2.7KB ⚠️ 3.5KB 3.7KB +264B
core/CommandPaletteList 22.6KB 22.9KB +378B 3.3KB 3.5KB +265B
core/ComplexSelector 56.0KB 59.4KB +3.4KB ⚠️ 10.6KB 11.4KB +799B
core/ContextMenu 74.7KB 78.4KB +3.7KB ⚠️ 14.6KB 15.8KB +1.1KB ⚠️
core/ContextMenuItem 53.0KB 56.3KB +3.3KB ⚠️ 9.6KB 10.4KB +805B
core/DateInput 99.7KB 103.1KB +3.5KB ⚠️ 14.8KB 15.6KB +807B
core/DateRangeInput 69.5KB 73.0KB +3.4KB ⚠️ 11.2KB 12.0KB +794B
core/DateTimeInput 109.8KB 113.3KB +3.5KB ⚠️ 15.2KB 16.0KB +818B
core/Dialog 16.2KB 16.6KB +390B 7.1KB 7.3KB +224B
core/DialogHeader 49.0KB 52.4KB +3.4KB ⚠️ 9.8KB 10.6KB +807B
core/Divider 6.5KB 6.8KB +375B 3.6KB 3.8KB +264B
core/Drawer 31.6KB 32.0KB +415B 8.0KB 8.3KB +241B
core/DrawerHeader 49.1KB 52.5KB +3.4KB ⚠️ 11.2KB 12.0KB +806B
core/DropdownMenu 72.8KB 76.5KB +3.7KB ⚠️ 14.7KB 15.9KB +1.2KB ⚠️
core/DropdownMenuCheckboxItem 16.1KB 19.3KB +3.2KB ⚠️ 5.6KB 6.2KB +637B
core/DropdownMenuDivider 6.6KB 7.0KB +388B 3.6KB 3.8KB +263B
core/DropdownMenuGroup 6.1KB 6.4KB +381B 3.3KB 3.5KB +266B
core/DropdownMenuItem 15.4KB 18.6KB +3.2KB ⚠️ 5.2KB 5.8KB +657B
core/DropdownMenuRadioGroup 6.0KB 6.4KB +372B 3.2KB 3.5KB +264B
core/DropdownMenuRadioItem 16.2KB 19.3KB +3.1KB ⚠️ 5.6KB 6.2KB +637B
core/DropdownMenuSubMenu 58.2KB 61.3KB +3.1KB ⚠️ 9.5KB 10.1KB +631B
core/EmptyState 7.7KB 8.1KB +376B 3.5KB 3.8KB +263B
core/Field 44.4KB 44.7KB +366B 8.0KB 8.2KB +242B
core/FieldLabel 39.9KB 40.3KB +377B 6.5KB 6.8KB +234B
core/FieldStatus 10.4KB 10.8KB +380B 4.2KB 4.5KB +244B
core/FileInput 54.3KB 57.7KB +3.4KB ⚠️ 10.3KB 11.1KB +795B
core/FormLayout 6.3KB 6.7KB +386B 3.4KB 3.6KB +261B
core/Grid 4.3KB 4.7KB +388B 3.7KB 3.9KB +264B
core/GridSpan 3.4KB 3.8KB +383B 3.2KB 3.5KB +264B
core/HStack 7.3KB 7.7KB +392B 4.9KB 5.2KB +267B
core/Heading 9.1KB 9.5KB +381B 6.8KB 7.0KB +234B
core/Icon 8.8KB 9.2KB +371B 3.6KB 3.8KB +261B
core/IconButton 43.5KB 46.8KB +3.3KB ⚠️ 9.2KB 10.0KB +806B
core/InputGroup 45.0KB 45.4KB +392B 8.0KB 8.3KB +237B
core/InputGroupText 6.3KB 6.7KB +372B 3.7KB 4.0KB +261B
core/Item 11.4KB 14.7KB +3.4KB ⚠️ 4.7KB 5.3KB +659B
core/Kbd 6.7KB 7.0KB +376B 3.5KB 3.7KB +262B
core/LayerProvider 63.4KB 66.7KB +3.2KB ⚠️ 10.1KB 10.9KB +812B
core/Layout 8.1KB 8.5KB +397B 5.5KB 5.8KB +266B
core/LayoutContent 7.4KB 7.8KB +383B 4.9KB 5.2KB +268B
core/LayoutFooter 7.3KB 7.7KB +385B 4.8KB 5.1KB +267B
core/LayoutHeader 7.4KB 7.8KB +386B 4.8KB 5.1KB +268B
core/LayoutPanel 7.6KB 7.9KB +391B 4.8KB 5.1KB +267B
core/Lightbox 51.4KB 54.6KB +3.3KB ⚠️ 9.8KB 10.6KB +808B
core/Link 43.7KB 46.3KB +2.6KB ⚠️ 7.6KB 7.8KB +237B
core/List 6.7KB 7.1KB +384B 3.4KB 3.7KB +260B
core/ListItem 53.7KB 56.9KB +3.2KB ⚠️ 10.5KB 11.2KB +767B
core/Markdown 144.5KB 147.9KB +3.4KB ⚠️ 17.4KB 18.5KB +1.1KB ⚠️
core/MetadataList 23.3KB 23.7KB +380B 3.6KB 3.9KB +265B
core/MetadataListItem 6.5KB 6.8KB +380B 3.4KB 3.7KB +263B
core/MobileNav 51.2KB 54.4KB +3.2KB ⚠️ 10.1KB 10.8KB +809B
core/MobileNavToggle 46.4KB 49.7KB +3.3KB ⚠️ 9.4KB 10.2KB +803B
core/MoreMenu 73.2KB 76.7KB +3.6KB ⚠️ 14.7KB 15.9KB +1.2KB ⚠️
core/MultiSelector 73.1KB 76.3KB +3.2KB ⚠️ 14.6KB 15.4KB +796B
core/NavHeadingMenu 8.9KB 9.3KB +388B 3.2KB 3.5KB +264B
core/NavIcon 3.4KB 3.8KB +381B 3.3KB 3.6KB +266B
core/NumberInput 55.9KB 59.3KB +3.4KB ⚠️ 10.7KB 11.5KB +805B
core/Outline 28.8KB 31.4KB +2.6KB ⚠️ 4.0KB 4.3KB +261B
core/OverflowList 8.5KB 8.9KB +385B 3.5KB 3.8KB +260B
core/Overlay 19.5KB 19.9KB +341B 3.8KB 4.1KB +239B
core/Pagination 84.9KB 88.4KB +3.4KB ⚠️ 15.3KB 16.4KB +1.0KB ⚠️
core/Popover 50.0KB 53.1KB +3.1KB ⚠️ 12.1KB 12.9KB +797B
core/PowerSearch 158.2KB 161.7KB +3.5KB ⚠️ 19.9KB 20.9KB +1.0KB ⚠️
core/ProgressBar 7.7KB 8.1KB +366B 6.6KB 6.8KB +232B
core/RadioIndicator 3.8KB 4.2KB +385B 3.7KB 3.9KB +240B
core/RadioList 45.8KB 46.1KB +391B 8.0KB 8.2KB +240B
core/RadioListItem 52.4KB 55.5KB +3.2KB ⚠️ 9.7KB 10.3KB +626B
core/ResizeHandle 25.2KB 25.6KB +411B 3.9KB 4.1KB +261B
core/Section 7.5KB 7.9KB +396B 6.2KB 6.4KB +267B
core/SegmentedControl 38.9KB 39.3KB +407B 6.3KB 6.6KB +234B
core/SegmentedControlItem 8.6KB 11.3KB +2.7KB ⚠️ 3.9KB 4.1KB +257B
core/SelectableCard 12.8KB 15.9KB +3.1KB ⚠️ 7.1KB 7.9KB +813B
core/Selector 76.5KB 80.0KB +3.5KB ⚠️ 15.0KB 16.0KB +1.0KB ⚠️
core/SelectorOption 14.5KB 17.8KB +3.3KB ⚠️ 5.1KB 5.7KB +656B
core/SideNav 58.1KB 61.4KB +3.3KB ⚠️ 10.6KB 11.3KB +809B
core/SideNavCollapseButton 46.8KB 50.1KB +3.3KB ⚠️ 9.5KB 10.2KB +802B
core/SideNavHeading 54.9KB 58.1KB +3.2KB ⚠️ 10.1KB 10.9KB +809B
core/SideNavItem 54.6KB 58.0KB +3.3KB ⚠️ 10.1KB 10.9KB +812B
core/SideNavSection 6.5KB 6.9KB +366B 3.6KB 3.9KB +262B
core/Skeleton 3.8KB 4.1KB +374B 3.6KB 3.9KB +238B
core/Slider 48.6KB 49.0KB +390B 8.5KB 8.8KB +237B
core/Spinner 26.4KB 26.8KB +399B 7.5KB 7.8KB +235B
core/Stack 7.1KB 7.4KB +376B 4.9KB 5.2KB +267B
core/StackItem 6.0KB 6.4KB +372B 3.3KB 3.6KB +264B
core/StatusDot 19.0KB 19.3KB +334B 6.2KB 6.4KB +233B
core/Step 32.6KB 35.3KB +2.7KB ⚠️ 5.3KB 5.6KB +252B
core/Stepper 47.5KB 50.9KB +3.3KB ⚠️ 9.6KB 10.3KB +803B
core/Switch 48.5KB 81.0KB +32.4KB ⚠️ 9.3KB 10.4KB +1.1KB ⚠️
core/Tab 9.7KB 12.4KB +2.7KB ⚠️ 4.1KB 4.3KB +253B
core/TabList 40.3KB 40.7KB +378B 7.2KB 7.4KB +244B
core/TabMenu 51.8KB 55.1KB +3.3KB ⚠️ 10.0KB 10.8KB +801B
core/Table 83.9KB 87.5KB +3.6KB ⚠️ 15.1KB 16.3KB +1.1KB ⚠️
core/TableBody 3.3KB 3.6KB +375B 3.2KB 3.4KB +264B
core/TableCell 75.8KB 79.4KB +3.7KB ⚠️ 14.8KB 16.0KB +1.1KB ⚠️
core/TableFooter 3.3KB 3.7KB +375B 3.2KB 3.4KB +264B
core/TableHeader 3.3KB 3.7KB +375B 3.2KB 3.4KB +264B
core/TableHeaderCell 75.5KB 79.2KB +3.7KB ⚠️ 14.8KB 16.0KB +1.2KB ⚠️
core/TableRow 6.2KB 6.6KB +380B 3.5KB 3.8KB +259B
core/TableSelectionToolbar 52.5KB 55.8KB +3.3KB ⚠️ 11.2KB 12.0KB +812B
core/Text 8.6KB 9.0KB +385B 6.8KB 7.0KB +234B
core/TextArea 49.6KB 49.9KB +384B 9.4KB 9.6KB +237B
core/TextInput 53.7KB 57.1KB +3.4KB ⚠️ 10.5KB 11.3KB +808B
core/Thumbnail 50.8KB 54.1KB +3.3KB ⚠️ 11.0KB 11.8KB +800B
core/TimeInput 86.9KB 90.3KB +3.3KB ⚠️ 13.8KB 14.5KB +799B
core/Timer 9.2KB 9.6KB +396B 6.8KB 7.0KB +234B
core/Timestamp 27.7KB 28.1KB +486B 9.5KB 10.3KB +804B
core/Toast 60.0KB 63.3KB +3.4KB ⚠️ 9.7KB 10.5KB +806B
core/ToggleButton 44.3KB 47.6KB +3.3KB ⚠️ 9.3KB 10.1KB +806B
core/ToggleButtonGroup 6.3KB 6.7KB +385B 3.2KB 3.5KB +265B
core/Token 29.7KB 32.4KB +2.7KB ⚠️ 4.5KB 4.7KB +262B
core/Tokenizer 68.8KB 71.7KB +3.0KB ⚠️ 16.5KB 17.3KB +804B
core/Toolbar 39.9KB 40.3KB +401B 7.7KB 8.0KB +273B
core/TopNav 52.0KB 55.4KB +3.4KB ⚠️ 10.4KB 11.2KB +819B
core/TopNavHeading 54.3KB 57.5KB +3.2KB ⚠️ 9.9KB 10.7KB +816B
core/TopNavItem 9.0KB 11.7KB +2.7KB ⚠️ 3.8KB 4.1KB +261B
core/TopNavMegaMenu 54.3KB 57.6KB +3.3KB ⚠️ 10.8KB 11.6KB +810B
core/TopNavMegaMenuFeaturedCard 6.7KB 7.1KB +381B 3.4KB 3.7KB +261B
core/TopNavMegaMenuItem 9.1KB 11.8KB +2.7KB ⚠️ 4.1KB 4.3KB +261B
core/TopNavMenu 53.7KB 57.0KB +3.2KB ⚠️ 10.1KB 10.9KB +812B
core/TreeList 32.7KB 35.9KB +3.3KB ⚠️ 4.7KB 5.3KB +657B
core/Typeahead 61.6KB 65.0KB +3.4KB ⚠️ 11.3KB 12.1KB +800B
core/TypeaheadItem 6.2KB 6.6KB +372B 3.4KB 3.7KB +262B
core/VStack 7.3KB 7.7KB +391B 4.9KB 5.2KB +267B
lab/ChartDotGL N/A 7.7KB N/A N/A 20B N/A
lab/ChartDotGLInteractive N/A 8.6KB N/A N/A 20B N/A
lab/ChartHeatmapGL N/A 8.8KB N/A N/A 20B N/A
lab/ChartStreamGL N/A 7.7KB N/A N/A 20B N/A
lab/ChatEmojiPicker N/A 68.3KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 68.8KB N/A N/A 1.1KB N/A
lab/ChatReasoning N/A 7.3KB N/A N/A 967B N/A
lab/ChatTypingIndicator N/A 24.1KB N/A N/A 612B N/A
lab/ChatUnreadDivider N/A 3.9KB N/A N/A 348B N/A
lab/CircularProgress N/A 7.5KB N/A N/A 1.0KB N/A
lab/CodeEditor N/A 15.0KB N/A N/A 665B N/A
lab/CommentComposer N/A 97.3KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 50.2KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 219.7KB N/A N/A 2.4KB N/A
lab/Comments N/A 224.1KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 48.4KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 66.4KB N/A N/A 722B N/A
lab/InfoTip N/A 22.0KB N/A N/A 458B N/A
lab/ListInput N/A 78.3KB N/A N/A 1.6KB N/A
lab/LogStream N/A 8.3KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 162.2KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 94.5KB N/A N/A 775B N/A
lab/SVGIcon N/A 7.9KB N/A N/A 764B N/A
lab/Schedule N/A 63.2KB N/A N/A 3.9KB N/A
lab/Stat N/A 4.4KB N/A N/A 646B N/A
lab/ThreeDScatterGL N/A 7.9KB N/A N/A 20B N/A
lab/TourStep N/A 58.6KB N/A N/A 590B N/A
lab/TransferList N/A 70.4KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 76.1KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 274.3KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 216.1KB N/A N/A 454B N/A
richtext/RichTextView N/A 254.7KB 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/AlertDialog +3.3KB ⚠️ +805B
core/AppShell +3.1KB ⚠️ +811B
core/AvatarGroupOverflow +2.7KB ⚠️ +234B
core/Banner +3.3KB ⚠️ +807B
core/BaseTypeahead +3.3KB ⚠️ +816B
core/BreadcrumbItem +3.7KB ⚠️ +1.0KB ⚠️
core/Button +3.3KB ⚠️ +806B
core/Calendar +3.3KB ⚠️ +801B
core/Carousel +3.3KB ⚠️ +805B
core/ChatComposer +3.3KB ⚠️ +805B
core/ChatComposerInput +3.3KB ⚠️ +808B
core/ChatComposerTokenElement +3.3KB ⚠️ +808B
core/ChatDictationButton +3.3KB ⚠️ +805B
core/ChatLayout +3.3KB ⚠️ +803B
core/ChatLayoutScrollButton +3.3KB ⚠️ +805B
core/ChatSendButton +3.3KB ⚠️ +804B
core/CheckboxInput +2.7KB ⚠️ +237B
core/CheckboxListItem +3.3KB ⚠️ +767B
core/ClickableCard +3.1KB ⚠️ +813B
core/CodeBlock +3.3KB ⚠️ +819B
core/Collapsible +2.7KB ⚠️ +260B
core/CommandPalette +2.8KB ⚠️ +236B
core/CommandPaletteItem +2.7KB ⚠️ +264B
core/ComplexSelector +3.4KB ⚠️ +799B
core/ContextMenu +3.7KB ⚠️ +1.1KB ⚠️
core/ContextMenuItem +3.3KB ⚠️ +805B
core/DateInput +3.5KB ⚠️ +807B
core/DateRangeInput +3.4KB ⚠️ +794B
core/DateTimeInput +3.5KB ⚠️ +818B
core/DialogHeader +3.4KB ⚠️ +807B
core/DrawerHeader +3.4KB ⚠️ +806B
core/DropdownMenu +3.7KB ⚠️ +1.2KB ⚠️
core/DropdownMenuCheckboxItem +3.2KB ⚠️ +637B
core/DropdownMenuItem +3.2KB ⚠️ +657B
core/DropdownMenuRadioItem +3.1KB ⚠️ +637B
core/DropdownMenuSubMenu +3.1KB ⚠️ +631B
core/FileInput +3.4KB ⚠️ +795B
core/IconButton +3.3KB ⚠️ +806B
core/Item +3.4KB ⚠️ +659B
core/LayerProvider +3.2KB ⚠️ +812B
core/Lightbox +3.3KB ⚠️ +808B
core/Link +2.6KB ⚠️ +237B
core/ListItem +3.2KB ⚠️ +767B
core/Markdown +3.4KB ⚠️ +1.1KB ⚠️
core/MobileNav +3.2KB ⚠️ +809B
core/MobileNavToggle +3.3KB ⚠️ +803B
core/MoreMenu +3.6KB ⚠️ +1.2KB ⚠️
core/MultiSelector +3.2KB ⚠️ +796B
core/NumberInput +3.4KB ⚠️ +805B
core/Outline +2.6KB ⚠️ +261B
core/Pagination +3.4KB ⚠️ +1.0KB ⚠️
core/Popover +3.1KB ⚠️ +797B
core/PowerSearch +3.5KB ⚠️ +1.0KB ⚠️
core/RadioListItem +3.2KB ⚠️ +626B
core/SegmentedControlItem +2.7KB ⚠️ +257B
core/SelectableCard +3.1KB ⚠️ +813B
core/Selector +3.5KB ⚠️ +1.0KB ⚠️
core/SelectorOption +3.3KB ⚠️ +656B
core/SideNav +3.3KB ⚠️ +809B
core/SideNavCollapseButton +3.3KB ⚠️ +802B
core/SideNavHeading +3.2KB ⚠️ +809B
core/SideNavItem +3.3KB ⚠️ +812B
core/Step +2.7KB ⚠️ +252B
core/Stepper +3.3KB ⚠️ +803B
core/Switch +32.4KB ⚠️ +1.1KB ⚠️
core/Tab +2.7KB ⚠️ +253B
core/TabMenu +3.3KB ⚠️ +801B
core/Table +3.6KB ⚠️ +1.1KB ⚠️
core/TableCell +3.7KB ⚠️ +1.1KB ⚠️
core/TableHeaderCell +3.7KB ⚠️ +1.2KB ⚠️
core/TableSelectionToolbar +3.3KB ⚠️ +812B
core/TextInput +3.4KB ⚠️ +808B
core/Thumbnail +3.3KB ⚠️ +800B
core/TimeInput +3.3KB ⚠️ +799B
core/Toast +3.4KB ⚠️ +806B
core/ToggleButton +3.3KB ⚠️ +806B
core/Token +2.7KB ⚠️ +262B
core/Tokenizer +3.0KB ⚠️ +804B
core/TopNav +3.4KB ⚠️ +819B
core/TopNavHeading +3.2KB ⚠️ +816B
core/TopNavItem +2.7KB ⚠️ +261B
core/TopNavMegaMenu +3.3KB ⚠️ +810B
core/TopNavMegaMenuItem +2.7KB ⚠️ +261B
core/TopNavMenu +3.2KB ⚠️ +812B
core/TreeList +3.3KB ⚠️ +657B
core/Typeahead +3.4KB ⚠️ +800B

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 34.6KB 36.3KB +1.8KB

Accessibility Audit

Status: 7 accessibility violation(s) found — 7 serious.

Button pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/10 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Checkbox pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/3 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ClickableCard - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/5 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Radio group pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/11 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
RadioList - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/14 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
SelectableCard - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/4 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Thumbnail - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/12 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

55 of 502 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 waits on an engineering-owner decision: under a coarse pointer this change replaces the pressed overlay that the current design:user-states DR1 requires for the Switch track and thumb, and it implements the draft spec:AST-094, which no owner has approved yet. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), with the DR1 Switch row amended for coarse pointers; otherwise Switch keeps the overlay on touch. Whatever the decision, these need changes:

  • Land the stacked predecessors and rebase, so this pull request holds only the Switch commits instead of 141 files and about 26,000 changed lines; those commits alone are still about 2,270 lines.
  • Make the page-wide text-selection guard shared across switches, because tapping a second switch within half a second of the first leaves user-select: none on <html> until reload (useSwitchTouch.ts).
  • Paint the checked colour through the switch theme target again under a finger, and correct the changeset, which says scrolling never waits although a scroll that starts on a switch waits for a busy main thread in the default mode.
  • Fix the lint error at Switch.interaction.browser.spec.ts:203, where Number() wraps a value that is already a number.

[Automated review]

const effect = travel.effect as KeyframeEffect;
const offsets = effect
.getKeyframes()
.map(k => Number(k.computedOffset))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

k.computedOffset is already a number, so this Number() trips @typescript-eslint/no-unnecessary-type-conversion, the one error that fails the lint job on this revision. .map(k => k.computedOffset) keeps the same values.

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: the Number() is gone and lint passes.

@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 waits on an engineering-owner decision: under a coarse pointer this change replaces the pressed overlay that the current design:user-states DR1 requires for the Switch track and thumb, and it implements the draft spec:AST-094, which no owner has approved yet. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), with the DR1 Switch row amended for coarse pointers; otherwise Switch keeps the overlay on touch. Whatever the decision, these need changes:

  • Land the stacked predecessors and rebase, so this pull request holds only the Switch commits instead of 141 files and about 26,000 changed lines; those commits alone are still about 2,270 lines.
  • Make the page-wide text-selection guard shared across switches, because tapping a second switch within half a second of the first leaves user-select: none on <html> until reload (useSwitchTouch.ts).
  • Paint the checked colour through the switch theme target under a finger, and state in the changeset that a finger resting on a switch keeps the touch from scrolling and that a scroll starting on a switch waits for a busy main thread.
  • Move the interaction proofs into an existing CI lane instead of the separately routed pr-interaction job, which spec:AST-030 FR11 rules out for new coverage inside an existing surface.

[Automated review]

Comment thread .github/workflows/ci.yml Outdated
# them, so a change to an instrument is proven by its negative controls
# before a component spec relies on it. Chromium only on these runners;
# see the harness README for running WebKit.
pr-interaction:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pr-interaction routes its own scope (Check interaction scope below) over package, Storybook and harness paths. spec:AST-030 FR11 says new coverage inside an existing surface "MUST join those owners and MUST NOT create a separately routed lane or required context", so these proofs need to join an existing owner lane, or FR11 needs amending first.

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.

The pr-interaction job and its routing come from the interaction-harness PRs this one is stacked on, not from this pull request's own commits, so the AST-030 FR11 question belongs to that harness PR; I have passed it on.

@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 waits on an engineering-owner decision: under a coarse pointer this change replaces the pressed overlay that the current design:user-states DR1 requires for the Switch track and thumb, and it implements the draft spec:AST-094, which no owner has approved yet. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), with the DR1 Switch row amended for coarse pointers; otherwise Switch keeps the overlay on touch. The latest commit's fix checks out, since under a mouse the switch now looks exactly as it does without this change, but whatever the decision these still need changes:

  • Land the stacked predecessors and rebase, so this pull request holds only the Switch commits instead of 141 files and about 26,000 changed lines; those commits alone are still about 2,400 lines.
  • Share the page-wide text-selection guard across switches, because tapping a second switch within half a second of the first leaves user-select: none on <html> until reload (useSwitchTouch.ts).
  • Paint the checked colour through the switch theme target under a finger, since the new on layer hides a theme's checked fill (Switch.tsx).
  • State in the changeset that a finger resting on a switch keeps the touch from scrolling and that a scroll starting on a switch waits for a busy main thread.
  • Move the interaction proofs into an existing CI lane instead of the separately routed pr-interaction job, which spec:AST-030 FR11 rules out.

[Automated review]

@vjeux
vjeux force-pushed the uikit/c02-switch-touch branch from b0b01b3 to 30279c6 Compare October 24, 2026 22:18
@vjeux vjeux changed the title feat(core): Switch follows a finger the way the iOS switch does feat(core): Switch follows a finger on iOS the way the iOS switch does Oct 24, 2026
@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Replies to the automated review:

  • Stacking: this pull request is stacked by design (on the Switch values PR and the gesture-host change); its own commits are the last six, listed in the description.
  • The design:user-states DR1 amendment and spec:AST-094 are engineering-owner decisions; the description lists the amendment as proposed, pending owner review.
  • The other findings are answered in their threads: the page-wide selection guard is gone, the theme's checked fill shows under a finger, and the changeset states the scrolling limit.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

  • Touch gating. core/src/Switch/Switch.tsx:74 writes its own (pointer: coarse) check. This conflicts with AST-012 FR2 (touch is resolved once, through the closed adaptation vocabulary) and public-component-api INV16. Resolve through the shared touch resolver key so behavior matches the spec. If this behavior genuinely can't be expressed through the resolver, please raise it here as a spec question instead of shipping a separate check.
  • Reduced motion. core/src/Switch/Switch.tsx:284 handles reduced motion locally. Per design:reduced-motion (DR1–DR4), the animator is the one place that resolves it (touch cross-fades in 200 ms or less with no travel, a fine pointer changes at once), and core has no opt-out, so reducedMotion: 'keep' should be removed.

This is about where the behavior lives, not about the PR's intent.

@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 waits on an engineering-owner decision: on iPhone and iPad this change replaces the Switch's pressed overlay, which the current design:user-states DR1 requires, with the UISwitch touch presentation of the draft spec:AST-094, which no owner has approved. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), with the DR1 Switch row amended for iOS touch; otherwise the Switch keeps the overlay on iOS. The earlier selection-lock, theme-fill, changeset and lint findings are fixed, but whatever the decision these still need changes:

  • Under Reduce Motion, make the iOS Switch cross-fade in 200 ms or less instead of lifting, travelling and springing (reducedMotion: 'keep' in useSwitchTouch.ts), as the current design:reduced-motion DR1, DR2 and DR4 require.
  • Land the stacked predecessors and rebase, so this pull request holds only the Switch commits instead of 181 files and about 32,500 changed lines; those commits alone are still about 3,100 lines.
  • In the predecessor pull requests, keep the reduced-motion duration out of the themable motion roles (drop the 300 ms example), use the theme's compact definition instead of 744 px as spec:AST-012 FR9 requires, and move pr-interaction into an existing CI lane as spec:AST-030 FR11 requires.

[Automated review]

thumb.animate(
{x: this.toX(dragTarget(drag.start, x, this.geometry))},
{spring: TRACKING},
{at: time, reducedMotion: 'keep'},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every animation in this controller passes reducedMotion: 'keep' (also lines 736, 761, 770 and 821), so with Reduce Motion on, the iOS Switch still lifts, travels and springs; in Chromium a remote turn-on travels for about 460 ms. The current design:reduced-motion DR1, DR2 and DR4 allow only an opacity cross-fade of 200 ms or less here and exempt only content that follows the finger, so 'keep' fits the drag's tracking spring alone.

Comment thread packages/core/src/theme/defineTheme.ts Outdated
* // A softer sheet and a slower reduced-motion fade:
* motion: {
* springs: { 'springs.sheetPresent': { duration: 0.6, bounce: 0.1 } },
* durations: { 'timing.reducedMotionCrossFadeMs': 300 },

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 example, repeated in the CLI theme template, lengthens the reduced-motion cross-fade to 300 ms. design:reduced-motion DR2 caps a touch cross-fade at 200 ms and DR4 says theme values offer no way to keep a longer animation under reduced motion, so timing.reducedMotionCrossFadeMs belongs with press.scrollBrakeMs among the values a theme cannot retune.

export const TOUCH_QUERY = '(pointer: coarse)';

/** The viewport is compact: layout behaviours. */
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

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-012 FR9 defines compact as a width below the effective md point or a height below 500, and says the web touch presentation resolver keeps no size numbers of its own. With the default md of 768, coarse screens 744 to 767 px wide get the popover instead of the sheet in the five adaptive menus, and a theme that moves md moves nothing here.

Comment thread .github/workflows/ci.yml Outdated
# them, so a change to an instrument is proven by its negative controls
# before a component spec relies on it. Chromium only on these runners;
# see the harness README for running WebKit.
pr-interaction:

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-030 FR11 says new coverage inside an existing surface "MUST NOT create a separately routed lane or required context", and this job routes its own scope (Check interaction scope below). It needs to join an existing lane in the harness pull request, or FR11 amended first.

@vjeux

vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

On INV16: the Switch has no open surface, so, like Slider, it takes no presentation prop. Should INV16 also cover a control's own drag tracking? If so, we'll add the prop.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c02-switch-touch branch from 30279c6 to 9ed10c8 Compare October 25, 2026 00:19
@vjeux vjeux changed the title feat(core): Switch follows a finger on iOS the way the iOS switch does feat(core): Switch follows a finger the way the iOS switch does Oct 25, 2026
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

The converged head is up: 9ed10c8. The Switch's touch presentation keys only on the resolver's coarse pointer (no platform gate), reduced motion goes through the animator's policy, and the fade layer and the lens are now pseudo-elements on the track and the thumb box.

vjeux added 29 commits October 29, 2026 10:10
…ents asks

The host counts the attachments that pass actionGroup: true and keeps the
element a group while one remains, recomputed at each detach. An
attachment that passes false, as useGestureRecognizer does by default,
no longer turns a group off, and a group's last asking attachment taking
its leave ends the group.
… that content

The presented hit test takes the document's elementFromPoint, which
answers a shadow host for anything inside its open root, and compares
with Node.contains, which stops at the boundary: a recognizer on a
control inside a moving sheet's shadow root gets no finger, and the tap's
click goes to the host. Fails until the hit test descends into open roots
and compares along the flat tree.
Where a moving element is drawn away from its layout box, the host
hit-tests what is drawn under the finger with the document's
elementFromPoint, which answers a shadow host for anything inside its
open root. The hit test now descends through each open root's own
elementFromPoint and compares with the movers along the flat tree, so a
recognizer on a control inside a moving sheet's shadow root gets its
finger and the tap clicks that control.
…nt lets go

The host's touch-action rules sit in an adopted sheet or a style element
per document or shadow root, and were never removed, so a page whose
last recognizer detached kept them, against spec:AST-094 FR24 (nothing
touch-related remains after the last instance unmounts). Fails until a
root's rules go with its last marked element.
…th its last marked element

The touch-action writer records which elements it marked in each
document or shadow root, and when the last one is unmarked (its last
recognizer detached) it removes that root's adopted sheet or style
element, so nothing touch-related remains after the last instance
unmounts (spec:AST-094 FR24). A later mark writes the rules again; an
element marked in another root first lets go of the old one.
… 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.
…tion constant

From review on meta#8890: spec:AST-094 FR4 lists the 200 ms Reduce Motion cap
among the touch motion roles and says a component never plays a literal.
The release capped its clock at a literal 200 in pressFeedback.ts
(REDUCED_MOTION_MAX_MS) and at a literal 200ms in the overlay's reduced
release duration. Both now read the shared constant: the controller
timing.reducedMotionCrossFadeMs, the stylesheet
reducedMotionFadeConsts.duration. Both are 200 ms, so nothing on screen
changes; the built stylesheet's cap is still 200 ms.

Red first: a unit case that stubs timing.reducedMotionCrossFadeMs at
120 ms found the release still fading at 120 ms on the change before
this one; it now ends there.
…Motion constant too

The row press layer capped its release under reduced motion at a literal
200ms (CELL_RELEASE_REDUCED), the same cap the review on meta#8890 asked the
overlay to read from the shared constant (spec:AST-094 FR4: the 200 ms
Reduce Motion cap is a role, never a literal). It now reads
reducedMotionFadeConsts.duration, as the overlay does; both are 200 ms,
and the built stylesheet's cap is still 200 ms.

A unit case holds every reduced-motion cap in the layer and the overlay to
the constant. Red first: it found the layer's literal on the change before
this one.
…or the Space key's 200 ms, a list row's 200 ms

Measures each release on a touch-first device (component:Button FR15):
the release animation's own duration, read when `fading` is written;
`fading` lasting at least that clock; and `fading` going within a few
frames of the animation's end. Frames, not ms, bound the end, so a
loaded machine cannot fail it. Passes in Chromium and WebKit on this
head, before the fold onto meta#8117's new head, and is kept through it as
the proof that the fold changes no timing.
…#8890, meta#8360 045f666) into the press release

The release's 250 ms for a finger on a control stays where it was, in
the control press layer's CSS, and a mouse's or the Space key's press
keeps 200 ms through its input marker. The controller's own profile
clock goes with the profiles meta#8117 dropped: the release-end code from
meta#8890 waits for the running release animation, so a finger's release
still ends at 250 ms. This branch's own copy of that code gives way to
meta#8890's.
…caps read the Reduce Motion constant) into the meta#8118 review base
…o the press release

The overlay's reduced-motion release stays this branch's own arms, which
already read the Reduce Motion constant (reducedMotionFadeConsts); the
controller's clock cap and the row layer's take meta#8890's and meta#8077's.
…duce Motion constant

meta#8077's FR4 check (interactionOverlay.cell.test.ts) asks every release
cap in the overlay to read the Reduce Motion constant through a min().
This branch's reduced-motion arms used the constant directly; they now
take min(<the release's clock>, the constant), so a control's 250 ms
and a mouse's or the Space key's 200 ms both cap at 200 ms, as before.
The build's reduced-motion check reads the cap.
… or press profile

The selection hold and the press profiles are gone from the base, so
the Item case and the shared checks' notes no longer mention a held
arm, a cell marker or a profile clock; the checks themselves are
unchanged.
 045f666) into c02-8124-base

pressFeedback.ts and its test take meta#8118's file as published: it already
carries every earlier meta#8118 and meta#8270 head this base merged, so only this
base's own older unions of the two give way.
…-base (main's current Switch record)

Switch.spec.md takes main's current record as merged (it arrives through
meta#8118) and re-applies this pull request's touch contract to it: the thumb
circle, the thumb inside the track and UISwitch's gestures become
FR13-FR15 after main's FR1-FR12; FR10 states the 44 px coarse-pointer hit
area this pull request ships, closing its implementation gap and the
matching Current-state bullet; FR9 and FR10 note the cross-fade under a
finger with reduced motion; the motion roles, the accessibility contract's
pointer-cancellation sentence, PR1's touch listeners, one verification row
and the decision log come back on main's text.

pressFeedback.ts and its test take meta#8118's file as published, with this
pull request's own change re-applied on it: the drag-hold attribute a
Switch's thumb sets, its early return in onPointerMove, and its test.
… and FR13

Main's current Switch record numbers the coarse-pointer hit area FR10 and
this pull request's thumb circle FR13 (it was FR4 in the draft), so the
comments and test titles that cite them follow. No behavior change.

@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 pull request is on hold for an engineering owner's decision: it adds FR13–FR15 and a Motion roles table to the current component:Switch record and rewrites component:Button FR15, and Switch FR15's own click after a hold or a flipping drag, and its finger-only drag, depart from spec:AST-094 FR11 and FR43. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and amends AST-094 FR11 and FR43 on main, or says which requirement should change instead. Whatever that decision, these changes are still needed:

  • Land the stacked pull requests first, so this one carries only the Switch change (38,560 authored lines today).
  • Let the row's press release wait for the Switch's 250 ms press layers, which today end on the controller's 200 ms clock and snap off on a busy page (utils/pressFeedback.ts:352-377).
  • Read the writing direction at each touch, so a drag after a runtime dir change moves the thumb the right way (useSwitchTouch.ts:306).
  • Land #8671 or carry its check, so scripts/touch-support-bundle.test.mjs passes.
  • Add real iOS Safari and Android Chrome recordings, and justify the flagged Switch CSS.

[Automated review]

this.detach();
this.elements = elements;
this.measureGeometry();
this.direction = isRtlElement(elements.surface) ? -1 : 1;

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 direction is read once, at attach, and the attach effect re-runs only for isTouch or isDisabled (:1048-1068). After a page flips dir without remounting the switch, the thumb's CSS rest mirrors but every drag keeps the old sign (:645, :746, :752), so a drag toward the on end only rubber-bands and a drag away from it turns the switch on (design:bidirectionality DR13). Reading isRtlElement(elements.surface) in touchDown (:559-566), with a case that changes dir after mount, would fix it.

// UIButton's measured release, the table's internal constant
// (`button.highlight.releaseMs`, mirrored for the stylesheet and
// drift-tested): touch motion stays internal (spec:AST-094 DEC-11).
default: `${controlPressConsts.controlReleaseMs}ms`,

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 layer (and the thumb's at :567) releases over 250 ms, but the attribute it keys on lives on the row, and the shared controller ends the row's release on its 200 ms clock (PRESS_FADE_MS), waiting only for animations on the row itself or its own pseudo-elements, never a descendant's (utils/pressFeedback.ts:352-377). Under a coarse pointer the row has no animation of its own, so fading goes at 200 ms and both layers vanish mid-fade; when the lift's frame is busy they stay fully painted through the block and then disappear. The release-curve case freezes the page's timers before sampling (Switch.interaction.browser.spec.ts:790-795), so it cannot see this. Letting the row's release wait for these layers, plus a case that blocks the lift's frame with timers live, would cover it.

This branch was successfully deployed

1 active deployment
Preview — 884195d0 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 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