Skip to content

feat(lab): NavigationStack keeps the pushing row selected until the pop - #8114

Closed
vjeux wants to merge 1497 commits into
meta:mainfrom
vjeux:uikit/c14-navigation-ch01
Closed

vjeux wants to merge 1497 commits into
meta:mainfrom
vjeux:uikit/c14-navigation-ch01

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8108 and #8082. Review only this PR's change.

User need

What led here: we are bringing Astryx's touch components to iOS quality on the mobile web. On iOS, the row you tapped stays selected under the pushed screen and fades as you come back, following a back swipe. That fade shows where you came from; without it, a long list loses your place.

Problem and solution fit

The stack holds the pushed row's selection (Item, ListItem or TreeList) from the tap's own task. A pop deselects it over UIKit's 0.35 s, on the cards' clock; a back swipe scrubs it with the finger and finishes or restores it on release; a pop to root deselects the row in the screen it returns to. Under Reduce Motion it is a 200 ms fade.

Current specification

The hold mirrors #8082's selection hold. Core's internal touch entry does not export it yet, so the stack keeps a copy (selectionHold.ts) until it does. Proposed, for the owner's review.

Public delta and caller burden

No API change: rows that push get this under a finger. Size (gzip JavaScript of NavigationStack's import, base → head): 59,047 → 60,419 B.

Design and alternatives

  • The stack owns the clock: the deselect starts with the cards, so row and screens stay in sync with no per-frame callback.
  • Rejected: holding at the pop, by which time the press release has already faded the row.

Evidence

iOS 26.2 simulator: held under a push, deselected on the pop, a back swipe scrubs it, a cancelled swipe selects it again, pop to root, a 600 ms block during the pop. Chromium: Reduce Motion. Android 15 emulator: push and pop, a back swipe.

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 Chrome's device toolbar (a phone, touch), "Lab/NavigationStack › Settings":

  1. Tap General: the General row stays highlighted under the pushed screen.
  2. Tap Back: the highlight fades as Settings returns.
  3. Push again, drag back slowly and stop halfway: the highlight follows the finger; let go before halfway and the row is selected again.

What I ran: the unit tests and the browser proofs (Chromium and WebKit, including ListSelectionHold) passed. With the stack's hold stubbed out, 4 of 6 unit and 3 of 6 Chromium push and pop cases fail. A test-ui red in CodeBlockHeaderPlugin.test.tsx is a rich-text race that #8569 fixes, not this change.

Takes #8108's latest test fix at its next push, or when #8108 merges; it does not merge before that. Recorded on the iOS 26.2 simulator and the Android 15 emulator; a real-device check is the release gate. Includes #8562 (Core's internal entry points); if those land differently, this follows.

@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 Error Error Oct 27, 2026 12:46pm 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 28, 2026, 2:43 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c14-navigation-ch01 branch from 3a64377 to dc0c38b Compare October 24, 2026 06:18

@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 needs an engineering owner's decision before it can be approved. No current spec covers CH-01 (the list row kept selected under a pushed NavigationStack screen), holdSelection as a stable @astryxdesign/core/utils export, or the new presentation="adaptive" thresholds, and the draft spec:AST-094 added here (FR1–FR23) is unapproved and does not mention CH-01. To settle it, an engineering owner provides or identifies the governing spec for CH-01 and records a decision on AST-094 by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, these need changes:

  • Land the predecessor pull requests first, or retarget, so this diff is the 246-line CH-01 commit rather than 30,305 authored lines.
  • Release a selection hold when it is replaced, when its push never lands, and when the stack unmounts (push() and the release loop in NavigationStack.tsx).
  • On popToRoot(), deselect the row on the revealed screen with the pop instead of snapping it, and release holds for replaced paths.
  • Fix the failing checks: list the contentPopHysteresis measurement that uikit.test.ts expects, then regenerate packages/cli/assets/discover/official-packages.json and internal/shadcn-registry/routes.lock.json.

[Automated review]

const key = getKeyRef.current
? getKeyRef.current(entry, index)
: index;
holdsRef.current.set(`screen-${key}`, hold);

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 stores the hold before the app has accepted the path. If onPathChange refuses or defers the push, the row stays held with no screen over it. A later push at the same depth then overwrites this entry without end(), and since the press controller never clears held, the first row stays highlighted until it unmounts. Consider ending any hold already stored under the key, keeping the new one pending (as pendingSourceRef is) until the push transition commits, and ending every hold in an unmount cleanup.

Comment on lines +863 to +868
for (const [key, held] of holdsRef.current) {
if (!present.has(key) && key !== top) {
held.end(true);
holdsRef.current.delete(key);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On popToRoot() from two levels, this loop ends the hold keyed by the dropped middle screen. That hold belongs to the row on the revealed root, so the row the person sees snaps off, while the deselect below plays on a row of the middle screen that is no longer rendered. Animating the hold of the first removed screen, the one directly above under, and ending the rest would keep the visible row's fade.

Comment on lines +846 to +851
if (transition.kind === 'none') {
// Settle every screen where it rests.
view.models.forEach((model, index) => {
settle(model.key, index === view.models.length - 1 ? 'top' : 'covered');
});
return;

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 replaced path (kind: 'none') returns here, before the release loop below, whose comment says replaced paths release their rows at once. The covered row stays held until the next pop.

Comment on lines +83 to +85
* @measure the exact threshold and the faster start: at 1000 pt/s the swipe
* began on the first move event (~8 pt), so above some speed between 600
* and 1000 pt/s it begins sooner; bracket both with finer drags.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uikit.test.ts:152 expects @measure M-… with an id listed in motion/MEASUREMENTS.md. This free-text @measure fails test-ui (1/4) at this head.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview. CI concluded failure.

Modified Components

Item (@astryxdesign/core)
Metric Before After Delta
Entry File (ESM) 284B 284B 0B
Lines of Code 1372 1377 -
Complexity Very High Very High (122) -
TreeList (@astryxdesign/core)
Metric Before After Delta
Entry File (ESM) 648B 648B 0B
Lines of Code 872 878 -
Complexity Very High Very High (100) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 57.4KB +478B 12.4KB 12.5KB +24B
core/AppShell 57.9KB 58.3KB +399B 11.6KB 11.7KB +22B
core/AspectRatio 3.7KB 3.8KB +66B 3.5KB 3.5KB +40B
core/Avatar 37.5KB 37.5KB +63B 6.3KB 6.3KB +23B
core/AvatarGroup 39.8KB 39.9KB +60B 6.4KB 6.5KB +22B
core/AvatarGroupOverflow 39.9KB 40.4KB +475B 6.4KB 6.4KB +23B
core/AvatarStatusDot 7.1KB 7.1KB +61B 3.4KB 3.4KB +41B
core/Badge 3.9KB 4.0KB +64B 3.8KB 3.9KB +39B
core/Banner 47.8KB 48.3KB +460B 9.9KB 10.0KB +22B
core/BaseTypeahead 54.3KB 54.7KB +404B 9.9KB 9.9KB +22B
core/Blockquote 3.5KB 3.6KB +66B 3.4KB 3.4KB +39B
core/BottomSheet 34.0KB 34.0KB +69B 7.9KB 7.9KB +24B
core/BottomSheetSwitcher 14.8KB 14.8KB +67B 3.3KB 3.3KB +41B
core/BreadcrumbItem 66.4KB 67.3KB +940B 11.0KB 11.4KB +412B
core/Breadcrumbs 22.4KB 22.5KB +80B 3.3KB 3.3KB +40B
core/Button 43.3KB 43.7KB +457B 9.2KB 9.2KB +21B
core/ButtonGroup 8.4KB 8.5KB +67B 3.3KB 3.3KB +40B
core/Calendar 54.9KB 55.4KB +471B 10.0KB 10.0KB +22B
core/Card 6.4KB 6.5KB +66B 6.3KB 6.4KB +42B
core/Carousel 50.2KB 50.7KB +493B 10.2KB 10.2KB +21B
core/Center 4.2KB 4.2KB +63B 4.6KB 4.6KB +42B
core/ChatComposer 60.6KB 61.1KB +463B 10.8KB 10.8KB +19B
core/ChatComposerDrawer 23.9KB 24.0KB +76B 4.5KB 4.6KB +39B
core/ChatComposerInput 55.7KB 56.2KB +447B 10.1KB 10.1KB +21B
core/ChatComposerTokenElement 55.7KB 56.2KB +446B 10.1KB 10.1KB +21B
core/ChatDictationButton 46.5KB 46.9KB +447B 9.5KB 9.5KB +20B
core/ChatLayout 48.8KB 49.3KB +451B 9.9KB 10.0KB +20B
core/ChatLayoutScrollButton 46.4KB 46.8KB +429B 9.6KB 9.6KB +20B
core/ChatMessage 23.1KB 23.1KB +69B 3.5KB 3.6KB +39B
core/ChatMessageBubble 7.0KB 7.1KB +67B 3.6KB 3.6KB +42B
core/ChatMessageList 28.9KB 29.0KB +82B 7.8KB 7.8KB +21B
core/ChatMessageMetadata 25.8KB 25.9KB +70B 3.9KB 3.9KB +23B
core/ChatSendButton 45.8KB 46.3KB +446B 9.2KB 9.2KB +21B
core/ChatSystemMessage 6.7KB 6.8KB +71B 3.7KB 3.7KB +39B
core/ChatTokenizedText 6.9KB 7.0KB +63B 3.9KB 3.9KB +40B
core/ChatToolCalls 31.9KB 31.9KB +76B 8.9KB 8.9KB +22B
core/CheckIndicator 9.0KB 9.1KB +68B 3.6KB 3.6KB +39B
core/CheckboxIndicator 4.1KB 4.1KB +65B 3.8KB 3.8KB +22B
core/CheckboxInput 49.9KB 50.4KB +452B 9.3KB 9.3KB +22B
core/CheckboxList 46.0KB 46.1KB +72B 8.1KB 8.2KB +22B
core/CheckboxListItem 54.0KB 54.9KB +936B 10.5KB 11.0KB +541B
core/Citation 25.9KB 26.0KB +79B 4.1KB 4.2KB +41B
core/ClickableCard 12.8KB 13.3KB +470B 6.9KB 7.0KB +46B
core/Code 3.5KB 3.6KB +63B 3.4KB 3.4KB +41B
core/CodeBlock 60.5KB 61.0KB +459B 10.7KB 10.7KB +27B
core/Collapsible 11.7KB 12.1KB +452B 4.2KB 4.3KB +39B
core/CollapsibleGroup 6.4KB 6.4KB +71B 3.2KB 3.2KB +40B
core/CommandPalette 50.9KB 51.4KB +455B 11.8KB 11.8KB +23B
core/CommandPaletteEmpty 6.1KB 6.2KB +64B 3.3KB 3.4KB +42B
core/CommandPaletteFooter 23.3KB 23.4KB +79B 3.5KB 3.6KB +39B
core/CommandPaletteGroup 3.5KB 3.5KB +64B 3.3KB 3.4KB +39B
core/CommandPaletteInput 32.3KB 32.4KB +70B 8.1KB 8.1KB +22B
core/CommandPaletteItem 8.4KB 8.9KB +449B 3.5KB 3.5KB +40B
core/CommandPaletteList 22.4KB 22.5KB +74B 3.3KB 3.3KB +41B
core/ComplexSelector 55.8KB 56.3KB +495B 10.6KB 10.7KB +20B
core/ContextMenu 77.7KB 78.6KB +931B 14.6KB 15.2KB +550B
core/ContextMenuItem 16.6KB 17.4KB +831B 5.2KB 5.6KB +420B
core/DateInput 99.9KB 100.3KB +384B 14.8KB 14.8KB +20B
core/DateRangeInput 69.4KB 69.8KB +439B 11.2KB 11.2KB +21B
core/DateTimeInput 110.1KB 110.5KB +404B 15.2KB 15.2KB +20B
core/Dialog 16.2KB 16.3KB +64B 7.1KB 7.1KB +23B
core/DialogHeader 48.8KB 49.3KB +464B 9.8KB 9.8KB +20B
core/Divider 6.5KB 6.5KB +64B 3.6KB 3.6KB +43B
core/Drawer 31.6KB 31.7KB +84B 8.0KB 8.1KB +23B
core/DrawerHeader 48.9KB 49.3KB +432B 11.2KB 11.2KB +22B
core/DropdownMenu 72.7KB 73.6KB +922B 14.7KB 15.3KB +545B
core/DropdownMenuCheckboxItem 16.1KB 17.0KB +900B 5.6KB 6.0KB +404B
core/DropdownMenuDivider 6.6KB 6.6KB +61B 3.6KB 3.6KB +42B
core/DropdownMenuGroup 6.1KB 6.1KB +69B 3.3KB 3.3KB +39B
core/DropdownMenuItem 15.4KB 16.3KB +898B 5.2KB 5.6KB +421B
core/DropdownMenuRadioGroup 6.0KB 6.1KB +62B 3.2KB 3.2KB +43B
core/DropdownMenuRadioItem 16.2KB 17.1KB +886B 5.6KB 6.0KB +404B
core/DropdownMenuSubMenu 58.1KB 59.0KB +905B 9.5KB 9.9KB +408B
core/EmptyState 7.7KB 7.8KB +64B 3.5KB 3.6KB +42B
core/Field 44.2KB 44.3KB +82B 8.0KB 8.0KB +22B
core/FieldLabel 39.8KB 39.8KB +66B 6.5KB 6.5KB +23B
core/FieldStatus 10.4KB 10.5KB +74B 4.2KB 4.3KB +23B
core/FileInput 54.1KB 54.5KB +421B 10.3KB 10.4KB +23B
core/FormLayout 6.3KB 6.3KB +66B 3.4KB 3.4KB +40B
core/Grid 4.3KB 4.4KB +67B 3.7KB 3.7KB +40B
core/GridSpan 3.4KB 3.5KB +63B 3.2KB 3.3KB +40B
core/HStack 7.3KB 7.3KB +62B 4.9KB 4.9KB +44B
core/Heading 9.1KB 9.2KB +64B 6.8KB 6.8KB +23B
core/Icon 8.8KB 8.9KB +60B 3.6KB 3.6KB +39B
core/IconButton 43.3KB 43.8KB +462B 9.2KB 9.2KB +21B
core/InputGroup 44.8KB 44.9KB +71B 8.0KB 8.1KB +21B
core/InputGroupText 6.3KB 6.4KB +66B 3.7KB 3.8KB +40B
core/Item 11.4KB 12.4KB +1.0KB 4.7KB 5.1KB +423B
core/Kbd 6.7KB 6.7KB +67B 3.5KB 3.5KB +40B
core/LayerProvider 63.3KB 63.8KB +480B 10.1KB 10.1KB +23B
core/Layout 8.1KB 8.2KB +63B 5.5KB 5.5KB +43B
core/LayoutContent 7.4KB 7.5KB +64B 4.9KB 5.0KB +43B
core/LayoutFooter 7.3KB 7.4KB +68B 4.8KB 4.9KB +42B
core/LayoutHeader 7.4KB 7.4KB +65B 4.8KB 4.9KB +42B
core/LayoutPanel 7.6KB 7.6KB +65B 4.8KB 4.9KB +43B
core/Lightbox 51.2KB 51.6KB +433B 9.8KB 9.8KB +21B
core/Link 43.5KB 44.0KB +464B 7.6KB 7.6KB +25B
core/List 6.7KB 6.8KB +59B 3.4KB 3.5KB +40B
core/ListItem 53.5KB 54.4KB +906B 10.5KB 11.0KB +541B
core/Markdown 144.5KB 145.4KB +922B 17.4KB 17.9KB +545B
core/MetadataList 23.1KB 23.2KB +75B 3.6KB 3.6KB +42B
core/MetadataListItem 6.5KB 6.5KB +64B 3.4KB 3.4KB +43B
core/MobileNav 51.0KB 51.4KB +412B 10.1KB 10.1KB +22B
core/MobileNavToggle 46.2KB 46.7KB +435B 9.4KB 9.4KB +20B
core/MoreMenu 72.9KB 73.8KB +907B 14.7KB 15.3KB +545B
core/MultiSelector 76.2KB 76.6KB +443B 14.6KB 14.7KB +24B
core/NavHeadingMenu 8.9KB 9.0KB +65B 3.2KB 3.3KB +42B
core/NavIcon 3.4KB 3.5KB +63B 3.3KB 3.3KB +42B
core/NumberInput 55.7KB 56.1KB +461B 10.7KB 10.7KB +31B
core/Outline 28.6KB 29.1KB +462B 4.0KB 4.1KB +40B
core/OverflowList 8.5KB 8.6KB +67B 3.5KB 3.5KB +40B
core/Overlay 19.5KB 19.6KB +43B 3.8KB 3.9KB +17B
core/Pagination 87.7KB 88.6KB +859B 15.3KB 15.7KB +395B
core/Popover 49.8KB 50.2KB +408B 12.1KB 12.1KB +20B
core/PowerSearch 155.1KB 155.9KB +879B 18.6KB 19.0KB +406B
core/ProgressBar 7.7KB 7.8KB +66B 6.6KB 6.6KB +20B
core/RadioIndicator 3.8KB 3.9KB +61B 3.7KB 3.7KB +20B
core/RadioList 45.6KB 45.6KB +71B 8.0KB 8.0KB +22B
core/RadioListItem 52.1KB 53.0KB +889B 9.7KB 10.1KB +401B
core/ResizeHandle 25.1KB 25.2KB +82B 3.9KB 3.9KB +41B
core/Section 7.5KB 7.6KB +67B 6.2KB 6.2KB +42B
core/SegmentedControl 38.8KB 38.9KB +84B 6.3KB 6.4KB +22B
core/SegmentedControlItem 8.6KB 9.0KB +450B 3.9KB 3.9KB +41B
core/SelectableCard 12.8KB 13.2KB +464B 7.1KB 7.2KB +46B
core/Selector 79.1KB 80.1KB +966B 15.0KB 15.4KB +403B
core/SelectorOption 14.5KB 15.5KB +1.0KB 5.1KB 5.5KB +421B
core/SideNav 58.0KB 58.4KB +425B 10.6KB 10.6KB +21B
core/SideNavCollapseButton 46.6KB 47.1KB +434B 9.5KB 9.5KB +20B
core/SideNavHeading 54.7KB 55.1KB +425B 10.1KB 10.2KB +22B
core/SideNavItem 54.5KB 54.9KB +435B 10.1KB 10.1KB +22B
core/SideNavSection 6.5KB 6.6KB +59B 3.6KB 3.6KB +38B
core/Skeleton 3.8KB 3.8KB +60B 3.6KB 3.6KB +22B
core/Slider 48.5KB 48.5KB +68B 8.5KB 8.5KB +21B
core/Spinner 26.3KB 26.4KB +68B 7.5KB 7.5KB +19B
core/Stack 7.1KB 7.1KB +62B 4.9KB 4.9KB +44B
core/StackItem 6.0KB 6.1KB +63B 3.3KB 3.3KB +38B
core/StatusDot 19.0KB 19.0KB +59B 6.2KB 6.2KB +19B
core/Step 32.4KB 32.9KB +463B 5.3KB 5.3KB +22B
core/Stepper 47.4KB 47.8KB +425B 9.6KB 9.6KB +20B
core/Switch 48.4KB 48.9KB +469B 9.3KB 9.4KB +21B
core/Tab 9.7KB 10.2KB +472B 4.1KB 4.1KB +41B
core/TabList 40.2KB 40.3KB +75B 7.2KB 7.2KB +23B
core/TabMenu 51.6KB 52.0KB +445B 10.0KB 10.0KB +21B
core/Table 84.0KB 84.9KB +917B 15.1KB 15.7KB +537B
core/TableBody 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableCell 78.8KB 79.7KB +950B 14.8KB 15.4KB +534B
core/TableFooter 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableHeader 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableHeaderCell 78.6KB 79.5KB +932B 14.8KB 15.3KB +547B
core/TableRow 6.2KB 6.3KB +66B 3.5KB 3.6KB +40B
core/TableSelectionToolbar 52.4KB 52.8KB +465B 11.2KB 11.2KB +21B
core/Text 8.6KB 8.6KB +66B 6.8KB 6.8KB +23B
core/TextArea 49.4KB 49.5KB +80B 9.4KB 9.4KB +21B
core/TextInput 53.5KB 54.0KB +466B 10.5KB 10.5KB +22B
core/Thumbnail 50.7KB 51.1KB +470B 11.0KB 11.0KB +25B
core/TimeInput 86.8KB 87.3KB +452B 13.8KB 13.8KB +21B
core/Timer 9.2KB 9.3KB +70B 6.8KB 6.8KB +23B
core/Timestamp 27.5KB 27.6KB +85B 9.5KB 9.6KB +21B
core/Toast 59.8KB 60.3KB +426B 9.7KB 9.7KB +22B
core/ToggleButton 44.1KB 44.5KB +422B 9.3KB 9.3KB +21B
core/ToggleButtonGroup 6.3KB 6.4KB +68B 3.2KB 3.3KB +42B
core/Token 29.6KB 30.1KB +471B 4.5KB 4.5KB +42B
core/Tokenizer 64.8KB 65.3KB +496B 11.7KB 11.7KB +21B
core/Toolbar 39.8KB 39.8KB +75B 7.7KB 7.8KB +46B
core/TopNav 51.9KB 52.3KB +433B 10.4KB 10.4KB +23B
core/TopNavHeading 54.2KB 54.5KB +405B 9.9KB 10.0KB +20B
core/TopNavItem 9.0KB 9.5KB +455B 3.8KB 3.9KB +39B
core/TopNavMegaMenu 54.2KB 54.6KB +453B 10.8KB 10.9KB +21B
core/TopNavMegaMenuFeaturedCard 6.7KB 6.8KB +66B 3.4KB 3.4KB +38B
core/TopNavMegaMenuItem 9.1KB 9.6KB +456B 4.1KB 4.1KB +38B
core/TopNavMenu 53.6KB 54.0KB +431B 10.1KB 10.1KB +23B
core/TreeList 32.5KB 33.5KB +994B 4.7KB 5.1KB +423B
core/Typeahead 61.5KB 61.9KB +459B 11.3KB 11.3KB +21B
core/TypeaheadItem 6.2KB 6.3KB +60B 3.4KB 3.5KB +39B
core/VStack 7.3KB 7.3KB +61B 4.9KB 4.9KB +44B
lab/NavigationScreen N/A 59.1KB N/A N/A 994B N/A
lab/NavigationStack N/A 59.1KB N/A N/A 994B 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.

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
@astryxdesign/lab 4.8KB 1.9KB 2.0KB +73B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 35.1KB +555B
@astryxdesign/lab 11.1KB 11.3KB +178B

Accessibility Audit

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

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)

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

This needs an engineering owner's decision before it can be approved: no current spec covers CH-01 (the list row kept selected under a pushed NavigationStack screen), holdSelection as a stable @astryxdesign/core/utils export, or the new presentation="adaptive" thresholds, and the draft spec:AST-094 added here (FR1–FR23) is unapproved and does not mention CH-01. An engineering owner records that decision, naming the governing spec for CH-01 and approving or rejecting AST-094, by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these need changes:

  • Land the predecessor pull requests first, or retarget, so this diff is the 246-line CH-01 commit rather than 31,251 authored lines.
  • Release a selection hold when it is replaced, when its push never lands, and when the stack unmounts (push() and the release loop in NavigationStack.tsx).
  • On popToRoot(), fade the row on the revealed screen with the pop instead of clearing it, and release holds for replaced paths.
  • Add a Changeset naming @astryxdesign/cli for the regenerated official-packages.json, which lint now fails on.

[Automated review]

},
{
"kind": "component",
"name": "NavigationScreen"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regenerating this list adds the lab NavigationScreen and NavigationStack to the CLI's discover output, and lint now fails Changeset coverage: "@astryxdesign/cli: this change ships to consumers without a Changeset naming @astryxdesign/cli." Adding a Changeset that names @astryxdesign/cli (pnpm changeset:new --packages @astryxdesign/cli --category <fix|feat>) should clear 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.

This needs an engineering owner's decision before it can be approved: no current spec covers CH-01 (the list row kept selected under a pushed NavigationStack screen), holdSelection as a stable @astryxdesign/core/utils export, or the new presentation="adaptive" thresholds, and the draft spec:AST-094 added here (FR1-FR23) is unapproved and does not mention CH-01. An engineering owner records that decision, naming the governing spec for CH-01 and approving or rejecting AST-094, by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, these need changes:

  • Land the predecessor pull requests first, or retarget, so this diff is the 246-line CH-01 commit rather than 31,228 authored lines, and get a passing lint run.
  • Release a selection hold when it is replaced, when its push never lands, and when the stack unmounts (push() in NavigationStack.tsx).
  • On a multi-level pop such as popToRoot(), fade the row on the revealed screen with the pop instead of clearing it, and release holds for replaced paths.
  • Return focus to the control that pushed when it sits inside the held row.
  • Add the ## Writing rubric results for spec:AST-094 to the description, which the review gate reads before a record can be approved.

[Automated review]

Comment on lines +829 to +831
if (hold != null && hold.element.isConnected) {
hold.focus({preventScroll: true});
return;

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 sends focus to hold.focus(), the row's first focusable descendant (selectionHold.ts:163-172), even when source is another control inside the same row. When a trailing control in an interactive Item pushes, a keyboard or screen-reader user lands on the row's primary button after the pop, while the component doc still says focus returns to the control that pushed. Focusing source when it is connected and inside hold.element, and falling back to hold.focus() otherwise, keeps both cases right.

@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 needs an engineering owner's decision before it can be approved: no current spec covers CH-01 (the list row kept selected under a pushed NavigationStack screen), holdSelection as a stable @astryxdesign/core/utils export, or the new presentation="adaptive" thresholds, and the draft spec:AST-094 added here (FR1-FR23) is unapproved and does not mention CH-01. An engineering owner records that decision, naming the governing spec for CH-01 and approving or rejecting AST-094, by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, these need changes:

  • Land the predecessor pull requests first, or retarget, so this diff is the 457-line CH-01 commit rather than 31,549 authored lines.
  • Move focus after a pop only once the revealed screen is no longer inert; today a keyboard Back leaves focus on the page body.
  • Release a selection hold when its push is refused, when it is overwritten or its path replaced, and when the stack unmounts (push() and the transition effect in NavigationStack.tsx).
  • Fix AST-094's citation of a spec:AST-090 that is not in the repository, and add its ## Writing rubric results to the description.

[Automated review]

hold?.end(true);
if (under != null) {
settle(under, 'top');
focusAfter('pop', revealedKey ?? top);

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 runs right after setView clears leaving, before React re-renders, so the revealed screen still carries inert (isCovered at line 1318) and the focus call does nothing. In Chromium a keyboard Back in the Settings story leaves focus on the page body, while the component doc says focus returns to the control that pushed. Keeping the target in a ref and focusing it in a layout effect once leaving is null (or removing inert from the revealed screen first) would fix it; the jsdom focus tests cannot see this because jsdom ignores inert.

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 does not exist in the repository, at this head or on main, so an owner cannot read the FR1 and FR10 this departs from. Cite the record or pull request that holds them, or drop the note; the Current-state impact repeats it.

@vjeux
vjeux force-pushed the uikit/c14-navigation-ch01 branch from a72c634 to c7b3f89 Compare October 24, 2026 17:47
vjeux added 3 commits October 27, 2026 01:24
…xposes no ontouchstart is taken once

Chromium fixes ontouchstart when the page is created, so turning touch
emulation on later sends Touch Events without it: the host reads Pointer
Events for the finger and must not read its Touch Events too. A one-finger
long press fails here, the finger tracked twice.
…from their pointer events

install() reads a finger from its pointer events when the window has no
ontouchstart, but the four touch handlers still took the Touch Events a
browser sends anyway (Chromium after touch emulation turns on), tracking
the finger twice, so a one-finger long press never recognized. Each touch
handler now returns while usesTouchEvents is false, after noting the
fingers down, as install()'s comment promised.

@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 needs an engineering owner's decision before it can be approved: no current spec covers CH-01 (the list row kept selected under a pushed NavigationStack screen) or what ships with it (divider hiding beside a pressed or held List row, isRtlElement on @astryxdesign/core/hooks, the lab NavigationStack, and the springs and deselect timing it plays outside the spec:AST-094 FR4 motion roles), so an engineering owner needs to provide or identify the governing spec, or record the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these need changes:

  • Return focus to the row after a finger or mouse pop: a tap on a row's label focuses the label's span, which cannot take focus.
  • Read the back swipe's release velocity from the recognizer at the lift (spec:AST-100 FR2/FR3) and cancel any release moving back toward the start (spec:AST-094 FR37).
  • Undo a refused push and a declined pop when the app re-renders with an equal path.
  • Land the predecessor pull requests first, or retarget, so the diff is the CH-01 change rather than 34,385 authored lines.
  • Keep one implementation of the selection hold, route the animator through internal/touch as architecture:react-component-runtime INV13 names it, name the external system in the two changed Effects, and correct the [fix] tag of the list-row-press-compositor Changeset.

[Automated review]

Comment on lines +982 to +987
if (
source instanceof HTMLElement &&
source.isConnected &&
hold.element.contains(source)
) {
source.focus(focusOptions);

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 tap or click on the row's label makes source the label's <span> (rememberActivation stores the deepest target, :1263-1274; Item renders the label in a span inside its button). hold.element.contains(source) is true, so this calls focus() on a span that cannot take focus and focus is lost after the pop. Resolving the activation to its nearest focusable ancestor, or falling back to hold.focus() when source is not focusable, keeps the doc's promise that a pop returns focus to the control that pushed; a test that clicks the label text would catch it.

Comment on lines +1194 to +1201
win?.requestAnimationFrame(() => {
if (
pendingHoldRef.current === pending &&
pathRef.current === before
) {
pendingHoldRef.current = null;
pending.hold.end(true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pathRef.current is reassigned on every render (:1158-1159), so an app that refuses the push and re-renders with a new but equal array before the next frame fails this check; with unchanged keys no transition runs (:670-703), and the row stays held under no screen until the next push. The declined-pop spring-back at :1367-1373 has the same check. Comparing whether the pending key became the top screen (or length and keys) avoids the identity dependence.

Comment on lines +172 to +183
// UIKit's release velocity: the mean of the pan's last three
// velocities, the lift's own update included. A pause keeps it.
let v = 0;
if (recognizer.state === 'ended') {
swipe.velocities.push(
popVelocity(recognizer.velocity().x, width, direction),
);
const recent = swipe.velocities.slice(
-navigation.interactivePopVelocitySamples,
);
v = recent.reduce((sum, value) => sum + value, 0) / recent.length;
}

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-100 FR2 makes the release velocity 0 after the contact stays still and says no component reads another estimate, FR3 forbids deriving a second velocity, and architecture:interaction-modality INV10 says no Lab component keeps its own sample buffer. This mean of the last three pan velocities ("A pause keeps it") lets a finger that rests and then lifts still fling the pop. Comparing the recognizer's own release velocity at the lift would conform; otherwise the record needs amending first.

Comment on lines +121 to +131
export function shouldFinishPop(
progress: number,
velocityWidthsPerSecond: number,
): boolean {
return (
progress +
navigation.interactivePopFinishProjection * velocityWidthsPerSecond >
navigation.interactivePopFinishThreshold
);
}

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-094 FR37: "A release whose velocity points back toward the start MUST cancel, whatever the travel so far." Here shouldFinishPop(0.9, -0.5) is 0.9 − 0.175 = 0.725 > 0.5, so a swipe released moving back from 90 % still pops. Cancelling whenever the velocity points back, with [0.9, -0.5, false] in the test table, would conform.

vjeux added 16 commits October 27, 2026 02:07
…Core's internal entries

Main's internal/touch, its test, the touch-support bundle test, the
internal-entries test, sync-exports and the exports map are kept exactly;
this branch adds only what main lacks: internal/motion, the named
re-exports Lab builds on (the pan recognizer, the gesture types, the touch
presentation queries), their tests and the COVERED list.

# Conflicts:
#	packages/core/package.json
#	packages/core/src/internal/touch.test.ts
#	packages/core/src/internal/touch.ts
#	scripts/core-internal-entries.test.mjs
#	scripts/sync-exports.js
#	scripts/touch-support-bundle.test.mjs
…xperimental touch exports

The motion README's Files table is the union: main's rows, with meta#8663's
resolveSnapPoint.ts and selectPage.ts, plus this branch's, in main's
order.

# Conflicts:
#	packages/core/src/motion/README.md
…ntries and internal/motion

Main's resolveSnapPoint test (meta#8663) keeps the release policy internal:
not on the package root, not in a barrel, and no package export naming
motion. The last check also caught ./internal/motion, Core's internal
entry for Astryx's own packages (architecture:react-component-runtime
INV13), which re-exports no release policy. The export check now covers
the public keys only, and a new assertion keeps the claim as strong:
internal/motion must not re-export resolveSnapPoint either.

On this branch with main the test failed on ./internal/motion; it now
passes, and it fails when internal/motion re-exports resolveSnapPoint.
The bases merged with none of this PR's own change, so a compare from here
shows only that change.
…c65f0): meta#8320 ca71fce and meta#8562 cd3ad72

Core's internal entry files come from meta#8562 as they are; the next commit adds back
only the named re-exports this PR's imports need that meta#8562 lacks.
…on drives

Under load, in a frame that comes a few milliseconds late, WebKit evaluates
two layers that animate on one clock about a millisecond apart, their boxes
and their computed translates alike, so two layers read in one frame disagree
by a pixel or two while the screen shows them together. "a back swipe holds
the revealed bar still..." held the top bar to its card within 1 px through
the release's spring back as well, and failed there (1.03 px; 2 of 10 runs at
6 workers). traceBars now reads each layer's computed translate (at rest a
layer's box is its translate, checked when the trace starts) and records
whether an animation drives the frame; the proof keeps 1 px while the finger
drives both layers and takes the other bar proofs' 1 % of the width through
the release.
…e demoted

The story's own first motion leaves its layers promoted for the animator's
linger, half a second after that motion ends. The push started about 100 ms
after it, so the root bar title's demotion (a style pass and one Layout) came
386-400 ms into the push, at the 380 ms edge of the counted window, and inside
it when the push's first frame came late under load (373 ms; 2 of 24 runs at 8
workers). The proof now waits until no layer is promoted before it clicks, so
the window holds only the push's own work.
The bases merged with none of this PR's own change, so a compare from here
shows only that change. pressFeedback.ts's header names both sides' exports.

@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 needs an engineering owner's decision before it can be approved: no current spec covers CH-01 (the list row kept selected under a pushed NavigationStack screen) or what ships with it (divider hiding beside a pressed or held List row, isRtlElement on @astryxdesign/core/hooks, and the stack's touch presentation with the springs and deselect timing it plays outside the spec:AST-094 FR4 motion roles), so an engineering owner needs to provide or identify the governing spec, or record the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these need changes:

  • Return focus to the row after a finger or mouse pop: a tap or click on a row's label remembers the label's span, which cannot take focus.
  • Read the back swipe's release velocity from the recognizer at the lift (spec:AST-100 FR2/FR3) and cancel any release moving back toward the start (spec:AST-094 FR37).
  • Release a refused push's hold when the app re-renders with an equal path before the next frame.
  • Land the predecessor pull requests first, or retarget, so the diff is the CH-01 change rather than 34,263 authored lines.
  • Keep one implementation of the selection hold, route the animator through internal/touch as architecture:react-component-runtime INV13 names it, name the external system in the two changed Effects, and correct the [fix] tag of the list-row-press-compositor Changeset.

[Automated review]

Comment on lines +982 to +987
if (
source instanceof HTMLElement &&
source.isConnected &&
hold.element.contains(source)
) {
source.focus(focusOptions);

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 tap or click on the row's label makes source the label's <span>, which the row contains, so this focuses an element that cannot take focus and focus is left on the page body (measured in Chromium after a tap and after a mouse click). Resolve the activation to its nearest focusable ancestor when it is remembered, or fall back to hold.focus() when source cannot take focus, and add a test that clicks the label text.

Comment on lines +172 to +182
// UIKit's release velocity: the mean of the pan's last three
// velocities, the lift's own update included. A pause keeps it.
let v = 0;
if (recognizer.state === 'ended') {
swipe.velocities.push(
popVelocity(recognizer.velocity().x, width, direction),
);
const recent = swipe.velocities.slice(
-navigation.interactivePopVelocitySamples,
);
v = recent.reduce((sum, value) => sum + value, 0) / recent.length;

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 keeps a Lab sample buffer and releases on the mean of the last three pan velocities, so a finger that rests before lifting still flings the pop. spec:AST-100 FR2/FR3 and architecture:interaction-modality INV10 make the recognizer's release velocity the only one a component reads (0 after a still pause); compare that value instead.

Comment on lines +121 to +131
export function shouldFinishPop(
progress: number,
velocityWidthsPerSecond: number,
): boolean {
return (
progress +
navigation.interactivePopFinishProjection * velocityWidthsPerSecond >
navigation.interactivePopFinishThreshold
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

shouldFinishPop(0.9, -0.5) is 0.725, so a release moving back toward the start from 90 % still pops; spec:AST-094 FR37 says a release whose velocity points back MUST cancel, whatever the travel. Cancel for any velocity pointing back and add [0.9, -0.5, false] to the table.

Comment on lines +1194 to +1202
win?.requestAnimationFrame(() => {
if (
pendingHoldRef.current === pending &&
pathRef.current === before
) {
pendingHoldRef.current = null;
pending.hold.end(true);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pathRef.current is reassigned on every render, so an app that refuses the push and re-renders with a new, equal path array before this frame fails the check, and the row stays held with no screen over it (a scratch test with path={[]} and a state update in onPathChange kept it held). Decide from whether the pending key became the top screen instead.

@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Thanks. Cindy decided that keeping a tapped row selected is the product's call through ListItem's existing isSelected. Core gains no selection-hold behavior; details are on #8082. If NavigationStack keeps a back-swipe fade, please build it in Lab on Core's public APIs rather than on Core's internal holdSelection.

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Closing per Cindy's decision: a row's selection is the product's call through ListItem's isSelected, so NavigationStack holds none of its own. An app that wants the row selected under a push sets isSelected on it and clears it on pop. The branch stays.

@vjeux vjeux closed this Oct 28, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…7a9c3f)

meta#8114 closes: a list row's selection is the product's call through
ListItem's isSelected, so NavigationStack holds none of its own, and meta#8082
closes with it. This PR now sits directly on meta#8108; its own change is
unchanged.

This branch had an error being deployed

1 failed deployment
Preview — 7fadaac4 Deployed Oct 27, 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.

2 participants