Skip to content

feat(lab): NavigationStack hands its bar to a search field - #8204

Draft
vjeux wants to merge 1689 commits into
meta:mainfrom
vjeux:uikit/c14-navigation-search
Draft

vjeux wants to merge 1689 commits into
meta:mainfrom
vjeux:uikit/c14-navigation-search

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8185 and #8181. 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, tapping a list's search field lifts it into the navigation bar while the bar steps aside. SearchField (#8181) moves itself; it needs a host that hands over the bar.

Problem and solution fit

NavigationScreen gains search (a SearchField element) and hidesSearchWhenScrolling (default true). The field sits under the large title and scrolls with the content. The stack follows the field's own isActive: it measures how far the field must rise (lift), and on the field's clock the back button and bar items fade, the large title rides up fading, and the content rises with it. A change of mind turns from where things are; Cancel brings everything back. While searching, bar controls are inert and back swipes are off. Under a mouse, every part moves at once.

Current specification

Proposed, for the owner's review; the seam follows AST-056 DEC-2 (the stack reads the field element's own state).

Public delta and caller burden

search and hidesSearchWhenScrolling on NavigationScreen, replacing the earlier search: {field, isActive, hidesWhenScrolling} object and its NavigationSearch type. The bar now overlays the screens, which scroll under it; nothing changes at rest.

Design and alternatives

  • The stack owns the bar's layout; the field owns its state and motion.
  • iOS 26's bar has no background; ours fades out before the field arrives.
  • Gap: no scroll-away snap (no script-driven scrolling, as in feat(lab): NavigationStack large titles #8185).

Evidence

iOS 26.2 simulator: activate, then Cancel, on a pushed screen, a field that scrolls away, a 600 ms block inside the rise. Chromium: cancel mid-activation, Reduce Motion. Android 15 emulator: activate, then Cancel.

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 › Search":

  1. Tap the search field: it rises into the bar while the title and bar items fade.
  2. Tap Cancel: everything returns.
  3. Tap the field and Cancel at once: the bar turns back from where it is, without a jump.

What I ran: the unit tests, the browser proofs (Chromium and WebKit), the registry contract and the sandbox build passed. Four review findings have tests that failed before their fixes (for example, a screen left mid-search and pushed again starts at rest). The cancel proof times its cancel from the rise's own start and retries only when too few frames were captured before it.

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 Ready Ready Preview Oct 27, 2026 1:43pm 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, 12:44 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
@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

New Components

SearchField (@astryxdesign/lab) · View in Storybook
Metric Value
Entry File (ESM) 253B
Lines of Code 745
Source Files 4
Complexity Very High (31)
Exports SearchField, type, type
Props Count N/A
Has Tests Yes
Has Stories Yes

Modified Components

Item (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 284B 284B 0B
Lines of Code 1372 1377 -
Complexity Very High Very High (122) -
TreeList (@astryxdesign/core) · View in Storybook
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 +496B 12.4KB 12.5KB +24B
core/AppShell 57.9KB 58.3KB +416B 11.6KB 11.7KB +22B
core/AspectRatio 3.7KB 3.8KB +66B 3.5KB 3.5KB +40B
core/Avatar 37.5KB 37.5KB +79B 6.3KB 6.3KB +23B
core/AvatarGroup 39.8KB 39.9KB +77B 6.4KB 6.5KB +22B
core/AvatarGroupOverflow 39.9KB 40.4KB +491B 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 +477B 9.9KB 10.0KB +22B
core/BaseTypeahead 54.3KB 54.7KB +421B 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 +958B 11.0KB 11.4KB +412B
core/Breadcrumbs 22.4KB 22.5KB +96B 3.3KB 3.3KB +40B
core/Button 43.3KB 43.7KB +476B 9.2KB 9.2KB +21B
core/ButtonGroup 8.4KB 8.5KB +67B 3.3KB 3.3KB +40B
core/Calendar 54.9KB 55.4KB +489B 10.0KB 10.0KB +22B
core/Card 6.4KB 6.5KB +66B 6.3KB 6.4KB +42B
core/Carousel 50.2KB 50.7KB +509B 10.2KB 10.2KB +21B
core/Center 4.2KB 4.2KB +63B 4.6KB 4.6KB +42B
core/ChatComposer 60.6KB 61.1KB +481B 10.8KB 10.8KB +19B
core/ChatComposerDrawer 23.9KB 24.0KB +93B 4.5KB 4.6KB +39B
core/ChatComposerInput 55.7KB 56.2KB +464B 10.1KB 10.1KB +21B
core/ChatComposerTokenElement 55.7KB 56.2KB +463B 10.1KB 10.1KB +21B
core/ChatDictationButton 46.5KB 46.9KB +464B 9.5KB 9.5KB +20B
core/ChatLayout 48.8KB 49.3KB +469B 9.9KB 10.0KB +20B
core/ChatLayoutScrollButton 46.4KB 46.8KB +447B 9.6KB 9.6KB +20B
core/ChatMessage 23.1KB 23.1KB +85B 3.5KB 3.6KB +39B
core/ChatMessageBubble 7.0KB 7.1KB +67B 3.6KB 3.6KB +42B
core/ChatMessageList 28.9KB 29.0KB +99B 7.8KB 7.8KB +21B
core/ChatMessageMetadata 25.8KB 25.9KB +85B 3.9KB 3.9KB +23B
core/ChatSendButton 45.8KB 46.3KB +464B 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 +89B 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 +468B 9.3KB 9.3KB +22B
core/CheckboxList 46.0KB 46.1KB +90B 8.1KB 8.2KB +22B
core/CheckboxListItem 54.0KB 55.0KB +951B 10.5KB 11.0KB +541B
core/Citation 25.9KB 26.0KB +93B 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 +477B 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 +470B 11.8KB 11.8KB +23B
core/CommandPaletteEmpty 6.1KB 6.2KB +64B 3.3KB 3.4KB +42B
core/CommandPaletteFooter 23.3KB 23.4KB +95B 3.5KB 3.6KB +39B
core/CommandPaletteGroup 3.5KB 3.5KB +64B 3.3KB 3.4KB +39B
core/CommandPaletteInput 32.3KB 32.4KB +86B 8.1KB 8.1KB +22B
core/CommandPaletteItem 8.4KB 8.9KB +449B 3.5KB 3.5KB +40B
core/CommandPaletteList 22.4KB 22.5KB +89B 3.3KB 3.3KB +41B
core/ComplexSelector 55.8KB 56.3KB +509B 10.6KB 10.7KB +20B
core/ContextMenu 77.7KB 78.7KB +945B 14.6KB 15.2KB +550B
core/ContextMenuItem 16.6KB 17.4KB +831B 5.2KB 5.6KB +420B
core/DateInput 99.9KB 100.3KB +398B 14.8KB 14.8KB +20B
core/DateRangeInput 69.4KB 69.8KB +457B 11.2KB 11.2KB +21B
core/DateTimeInput 110.1KB 110.5KB +419B 15.2KB 15.2KB +20B
core/Dialog 16.2KB 16.3KB +64B 7.1KB 7.1KB +23B
core/DialogHeader 48.8KB 49.3KB +482B 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.4KB +449B 11.2KB 11.2KB +22B
core/DropdownMenu 72.7KB 73.6KB +938B 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 +923B 9.5KB 9.9KB +408B
core/EmptyState 7.7KB 7.8KB +64B 3.5KB 3.6KB +42B
core/Field 44.2KB 44.3KB +96B 8.0KB 8.0KB +22B
core/FieldLabel 39.8KB 39.9KB +81B 6.5KB 6.5KB +23B
core/FieldStatus 10.4KB 10.5KB +74B 4.2KB 4.3KB +23B
core/FileInput 54.1KB 54.5KB +435B 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 +479B 9.2KB 9.2KB +21B
core/InputGroup 44.8KB 44.9KB +88B 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 +498B 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 +450B 9.8KB 9.8KB +21B
core/Link 43.5KB 44.0KB +477B 7.6KB 7.6KB +25B
core/List 6.7KB 6.8KB +59B 3.4KB 3.5KB +40B
core/ListItem 53.5KB 54.4KB +923B 10.5KB 11.0KB +541B
core/Markdown 144.5KB 145.4KB +937B 17.4KB 17.9KB +545B
core/MetadataList 23.1KB 23.2KB +91B 3.6KB 3.6KB +42B
core/MetadataListItem 6.5KB 6.5KB +64B 3.4KB 3.4KB +43B
core/MobileNav 51.0KB 51.4KB +429B 10.1KB 10.1KB +22B
core/MobileNavToggle 46.2KB 46.7KB +454B 9.4KB 9.4KB +20B
core/MoreMenu 72.9KB 73.8KB +924B 14.7KB 15.3KB +545B
core/MultiSelector 76.2KB 76.6KB +458B 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.2KB +476B 10.7KB 10.7KB +31B
core/Outline 28.6KB 29.1KB +478B 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 +874B 15.3KB 15.7KB +395B
core/Popover 49.8KB 50.3KB +423B 12.1KB 12.1KB +20B
core/PowerSearch 155.1KB 155.9KB +895B 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.7KB +87B 8.0KB 8.0KB +22B
core/RadioListItem 52.1KB 53.0KB +906B 9.7KB 10.1KB +401B
core/ResizeHandle 25.1KB 25.2KB +99B 3.9KB 3.9KB +41B
core/Section 7.5KB 7.6KB +67B 6.2KB 6.2KB +42B
core/SegmentedControl 38.8KB 38.9KB +99B 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 +980B 15.0KB 15.4KB +403B
core/SelectorOption 14.5KB 15.5KB +1.0KB 5.1KB 5.5KB +421B
core/SideNav 58.0KB 58.4KB +444B 10.6KB 10.6KB +21B
core/SideNavCollapseButton 46.6KB 47.1KB +451B 9.5KB 9.5KB +20B
core/SideNavHeading 54.7KB 55.2KB +442B 10.1KB 10.2KB +22B
core/SideNavItem 54.5KB 54.9KB +452B 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 +84B 8.5KB 8.5KB +21B
core/Spinner 26.3KB 26.4KB +83B 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 +479B 5.3KB 5.3KB +22B
core/Stepper 47.4KB 47.8KB +443B 9.6KB 9.6KB +20B
core/Switch 48.4KB 48.9KB +485B 9.3KB 9.4KB +21B
core/Tab 9.7KB 10.2KB +472B 4.1KB 4.1KB +41B
core/TabList 40.2KB 40.3KB +90B 7.2KB 7.2KB +23B
core/TabMenu 51.6KB 52.1KB +463B 10.0KB 10.0KB +21B
core/Table 84.0KB 84.9KB +933B 15.1KB 15.7KB +537B
core/TableBody 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableCell 78.8KB 79.7KB +965B 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 +947B 14.8KB 15.3KB +547B
core/TableRow 6.2KB 6.3KB +66B 3.5KB 3.6KB +40B
core/TableSelectionToolbar 52.4KB 52.8KB +485B 11.2KB 11.2KB +21B
core/Text 8.6KB 8.6KB +66B 6.8KB 6.8KB +23B
core/TextArea 49.4KB 49.5KB +96B 9.4KB 9.4KB +21B
core/TextInput 53.5KB 54.0KB +480B 10.5KB 10.5KB +22B
core/Thumbnail 50.7KB 51.1KB +486B 11.0KB 11.0KB +25B
core/TimeInput 86.8KB 87.3KB +467B 13.8KB 13.8KB +21B
core/Timer 9.2KB 9.3KB +70B 6.8KB 6.8KB +23B
core/Timestamp 27.5KB 27.6KB +102B 9.5KB 9.6KB +21B
core/Toast 59.8KB 60.3KB +443B 9.7KB 9.7KB +22B
core/ToggleButton 44.1KB 44.6KB +439B 9.3KB 9.3KB +21B
core/ToggleButtonGroup 6.3KB 6.4KB +68B 3.2KB 3.3KB +42B
core/Token 29.6KB 30.1KB +487B 4.5KB 4.5KB +42B
core/Tokenizer 64.8KB 65.3KB +515B 11.7KB 11.7KB +21B
core/Toolbar 39.8KB 39.9KB +91B 7.7KB 7.8KB +46B
core/TopNav 51.9KB 52.3KB +449B 10.4KB 10.4KB +23B
core/TopNavHeading 54.2KB 54.6KB +421B 9.9KB 10.0KB +20B
core/TopNavItem 9.0KB 9.5KB +455B 3.8KB 3.9KB +39B
core/TopNavMegaMenu 54.2KB 54.6KB +470B 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 +449B 10.1KB 10.1KB +23B
core/TreeList 32.5KB 33.5KB +1011B 4.7KB 5.1KB +423B
core/Typeahead 61.5KB 61.9KB +475B 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 62.4KB N/A N/A 1.1KB N/A
lab/NavigationStack N/A 62.4KB N/A N/A 1.1KB N/A
lab/SearchField N/A 40.6KB N/A N/A 1.5KB 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 5.1KB 1.9KB 2.1KB +185B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 35.1KB +555B
@astryxdesign/lab 11.1KB 12.1KB +987B

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 | 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 has to wait. The new search prop on NavigationScreen and the draft spec:AST-094 requirements it builds on (FR1–FR23, DEC-1, DEC-2) have no current spec authority, so an engineering owner needs to approve or reject them by commenting /approve-spec <full head SHA> on this pull request; the author can record that decision if they are an engineering owner. Whatever that decision is, these changes are needed:

  • Land or retarget the predecessor pull requests so this diff is only its own ~1,050-line commit: against main it is 32,244 authored lines, including an unannounced change to presentation="adaptive" on five released core components.
  • Popping a screen while its search is active (the content back swipe stays enabled) leaves its key in searchingRef, so pushing it again hides its back button and bar background; disable the back swipes during search, and clear or replay the search state when screens leave or reach the top.
  • Read the active state from the SearchField instead of a second isActive, keep setLifts out of the search layout effect, run the interaction proofs inside an existing CI owner rather than a new pr-interaction lane (AST-030 FR11), and fix AST-094's citation of the nonexistent spec:AST-090.

[Automated review]

),
}).requireToFail(edge),
recognizer => onSwipe(recognizer, CONTENT_DEAD_ZONE_PX),
{isEnabled: canPop && isInteractivePopEnabled},

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 content back swipe stays enabled while the top screen's search.isActive is true, and a pop never runs runSearch(key, false). The popped key stays in searchingRef, heldCollapsesRef and searchRestRef, so pushing that screen again leaves its back button and bar items at opacity 0 (still focusable), fades the bar background to 0 and swallows its large-title crossings. Consider disabling both back swipes during search and clearing a key's search records when it leaves view.models.

}
return;
}
if (previous.key !== topKeyNow) {

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 screen that becomes top with search.isActive already true is never handed the bar: this branch only fades the background. Its large title and content stay put while the SearchField places itself lifted on its first layout. Running runSearch(topKeyNow, true, true) here when the new top is searching would line them up.

Comment on lines +1536 to +1539
if (lift == null) {
lift = measureLift(key) ?? 0;
storeLift(key, lift);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When isActive flips without a focus inside the field (as the Toggle search test does), the lift is measured and stored here, inside the search layout effect. The activating commit renders the field with lift 0, and a second render repairs it, which architecture:react-component-runtime INV1/INV3 rule out unless the effect names what it synchronizes. Could the field measure its own lift as it activates, or the measurement stay in the event path?

*/
field: ReactNode;
/** Whether search is active: the field's controlled `isActive`. */
isActive: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isActive repeats the SearchField's own controlled isActive, and nothing checks that the two agree; a mismatch renders a half hand-over (AST-002 FR15/FR16). The stack already clones field to inject lift; could it read or inject the active state instead?

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.

There is no spec:AST-090 on main or at this head, so this amendment has nothing to amend. Cite the record that owns exit motion today, or drop the amendment.

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

[feat] `presentation="adaptive"` on DropdownMenu, ContextMenu, MoreMenu, Selector and MultiSelector chooses the bottom sheet on a compact touch screen: a coarse primary pointer and a viewport narrower than 744px or shorter than 500px (it was 768px wide and below). An iPad mini in portrait keeps the anchored popover; a phone in landscape now gets the sheet.

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 changes the released result of presentation="adaptive" on five components (landscape phones get the sheet; 744–768px coarse screens get the popover). AST-017 FR3 asks for the change to be judged under FR1 and for the changeset to name the option that restores the old default. It also looks like it belongs in its own pull request rather than this one.

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.

This adds the interaction proofs as their own self-routed lane, with its own scope pattern. AST-030 FR11 asks new coverage to join an existing surface owner rather than create a separately routed lane, and FR10 admits a new surface only through an owner-approved amendment to AST-030. Could the proofs run inside an existing test owner, or come with that amendment in the harness's own pull request?

onSubmit,
handleRef,
lift = 0,
cancelLabel = 'Cancel',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These defaults become the close and clear buttons' aria-label, so every locale hears English. The internationalization architecture (INV1a) asks for Astryx-owned assistive strings to be en.json keys resolved by the translator, as NavigationStack does for its Back label. The props can stay as overrides.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c14-navigation-search branch from 0597b32 to db79dd5 Compare October 24, 2026 17:21

@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 still has to wait on an engineering owner. No current spec governs the new search prop on NavigationScreen (or NavigationStack and SearchField), so an owner needs to provide or identify that spec, and to approve or reject the draft spec:AST-094 requirements it builds on (FR1–FR23, DEC-1, DEC-2), recording the decision by commenting /approve-spec <full head SHA> on this pull request; the author may record it if they are an engineering owner. Whatever that decision is, these changes are still needed:

  • Land or retarget the predecessor pull requests so this diff is only its own commit of about 1,260 lines; against main it is about 32,800 authored lines, including an unannounced change to presentation="adaptive" on five released core components.
  • A screen popped while its search is active keeps its search state, so pushing it again leaves the field in the bar with the large title and rows behind it and no back button (the Search story's Done row reproduces it); clear or replay that state when screens leave or reach the top.
  • Read the active state from the SearchField instead of a second isActive, keep setLifts out of the search layout effect, run the interaction proofs inside an existing CI owner (AST-030 FR11), back SearchField's button labels with catalog strings, and fix AST-094's citation of the nonexistent spec:AST-090.

[Automated review]

<ListItem label={`${title} has nothing deeper.`} />
) : null}
{rows.length === 0 ? (
<ListItem label="Done" onClick={() => nav.popToRoot()} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In the Search story this row pops to the root while the screen's search can still be active, and nothing clears that screen's search records on a pop (NavigationStack.tsx:1634-1639). Pushing the screen again brings its field back lifted into the bar while the large title and rows stay below, with no back button or bar background until Cancel. Clearing a key's records when it leaves the stack, or replaying the hand-over for a screen that becomes top already searching, would fix it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval has to wait on an engineering owner: no current spec governs the NavigationStack search hand-over or themable touch motion, and the draft spec:AST-094 they build on (FR1–FR23, DEC-1, DEC-2) needs approval or rejection, including whether touch motion stays out of themes (its FR4 and DEC-1) or follows the new defineTheme motion roles. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these changes are still needed:

  • Land or retarget the predecessor pull requests so this diff is only its own commit of about 1,300 lines; against main it is about 35,500 authored lines, including an unannounced presentation="adaptive" change on five released core components and a self-routed CI lane.
  • A screen popped while its search is active keeps its search state, so pushing it again leaves the field in the bar with the large title and rows behind it and no back button (the Search story's Done row reproduces it).
  • Replace the search configuration object and its second isActive, which spec:AST-056 DEC-2 and FR6 rule out, and bring the motion roles in line with the theme-token, adaptation-condition and experimental-surface rules, or have those records' owners amend them.
  • Keep setLifts out of the search layout effect, back SearchField's button labels with catalog strings, limit the scroller's overscroll containment to overflowing axes, regenerate the stale CLI golden that fails test-node, and fix AST-094's citation of the nonexistent spec:AST-090.

[Automated review]

}
return;
}
if (previous.key !== topKeyNow) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the top screen changes, this branch only fades the bar background. A screen popped mid-search (the Search story's Done row, or a back swipe on iOS) keeps its key in searchingRef, heldCollapsesRef, searchRestRef and lifts, and pushing it again leaves its field lifted into the bar while the large title and rows stay below, with no back button until Cancel. Clearing a key's records when it leaves view.models, and running runSearch(key, true, true) for a screen that becomes top already searching, would cover both directions.

* the titles and the bar items fade on the field's activation clock, and
* the content rides up with the field into the bar.
*/
search?: NavigationSearch;

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-056 DEC-2 ("No search configuration object") and FR6 rule out an object-valued search prop, and isActive here restates the SearchField's own controlled isActive with nothing reconciling the two (AST-002 FR15/FR16). Could the stack take the field as a slot and read its active state from it?

: null;
if (lift == null) {
lift = measureLift(key) ?? 0;
storeLift(key, lift);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When isActive flips without a focus inside the field, the lift is measured and stored here, inside the search layout effect, so the activating commit renders the field with lift 0 and a second render repairs it (architecture:react-component-runtime INV1/INV3). Measuring where isActive flips, or keeping the lift out of React state, would avoid the repair.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR4 says touch presentations read their motion from the table on every theme, and DEC-1 rejects tuning touch motion per theme, but this pull request adds defineTheme({motion: {springs, durations, curves}}) motion roles that retune exactly that, including the search clock. One of the two needs to change before the record can be approved.

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.

There is no spec:AST-090 on main or at this head, so this amendment has nothing to amend. Cite the record that owns exit motion today, or drop the amendment (the Current-state impact list repeats it).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A platform condition widens the adaptation-condition vocabulary that architecture:theme-authoring-contract keeps fixed, and the motion roles beside it write --motion-* names that do not originate in tokens.stylex.ts (architecture:theme-tokens INV1). Both need those records' owners, or a shape that fits them.


/** Both: presentations such as a sheet in place of a popover. */
export const COMPACT_TOUCH_QUERY =
'(width < 744px) and (pointer: coarse), (height < 500px) and (pointer: coarse)';

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 changes the released result of presentation="adaptive" on five components (landscape phones get the sheet; 744–768px coarse screens get the popover), while component:Selector DEC-1 records that adaptive keeps its width-and-pointer query and the changeset names no AST-017 FR3 restoring option. It also looks separable from the search work.

Comment thread .github/workflows/ci.yml Outdated
if git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \
&& git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then
scope=0
bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|apps/storybook/)' || scope=$?

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 makes the interaction proofs their own self-routed lane over existing surfaces. AST-030 FR11 asks new coverage to join an existing surface owner, and FR10 admits a new surface only through an owner-approved amendment. Could the proofs run inside an existing test owner?

onSubmit,
handleRef,
lift = 0,
cancelLabel = 'Cancel',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These defaults become the close and clear buttons' aria-label, so every locale hears English. architecture:internationalization INV1a asks for Astryx-owned assistive strings to be en.json keys resolved by the translator, as NavigationStack does for its Back label; the props can stay as overrides.

paddingTop: BAR_HEIGHT,
scrollPaddingTop: BAR_HEIGHT,
overflowY: 'auto',
overscrollBehavior: 'contain',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

overscrollBehavior: 'contain' is active even when the screen does not overflow, which spec:AST-025 FR14 rules out (containment only on axes that currently overflow); a short screen inside a scrolling page becomes a dead scroll zone.

@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 has to wait on an engineering owner: no current spec governs the NavigationStack search hand-over or themable touch motion, and draft spec:AST-094 (FR1–FR23, DEC-1, DEC-2) needs approval or rejection, including whether touch motion stays out of themes (its FR4 and DEC-1) or follows the new defineTheme motion roles. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; whatever the decision, these changes are still needed:

  • Land or retarget the predecessor pull requests so this diff is only its own commit of about 1,360 lines; against main it is about 39,500 authored lines, including unannounced changes to presentation="adaptive" on five released components and a new keyboard-riding default for ChatLayout.
  • A screen left while its search is active keeps its search state, so pushing it again shows the field in the bar with the large title and rows behind it and no back button (the Search story's Done row reaches it).
  • On Chrome for Android the field's keyboard overlay leaves the end of a long screen under the keyboard with no room to scroll it into view, and search.isActive restates the field's own state with no check.
  • Bring the motion roles in line with the theme-token, adaptation-condition and experimental-surface rules, keep state out of the search layout effect, move the interaction proofs into an existing CI owner, back SearchField's button labels with catalog strings, and fix AST-094's citation of a nonexistent spec:AST-090.

[Automated review]

Comment on lines +1744 to +1749
if (previous.key !== topKeyNow) {
barBackground.animator.animate(
{opacity: searchingRef.current.has(topKeyNow) ? 0 : 1},
{spring: springs.navigationBarElements},
);
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.

When the top screen changes, this branch only fades the bar background. A screen left while its search is active (the Search story's Done row, or a back swipe on iOS, both still enabled during search) keeps its key in searchingRef, so pushing it again sends its back button and bar items to opacity 0 while runSearch never lifts its large title and rows; with the story's active entry still true, the field places itself in the bar's row above its own large title until Cancel. Run runSearch(key, true, true) for a screen that becomes top already searching, and clear the records of a key that leaves view.models.

Comment on lines +1671 to +1676
const finish = () => {
searchingRef.current.delete(key);
freshLiftsRef.current.delete(key);
searchRestRef.current.delete(key);
releaseCollapses(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.

This is the only place the per-screen search records are cleared, and it runs only at the end of a cancel on the top screen. A pop never reaches it, so searchingRef, heldCollapsesRef, searchRestRef, freshLiftsRef and lifts keep a popped key: if the app ends search on pop, the re-pushed screen's back button stays invisible but focusable and its large-title crossings are swallowed.

Comment on lines +505 to +508
releaseOverlayRef.current =
editable != null && win != null
? getKeyboardLayoutGuide(win).overlayWhileFocused(editable)
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With the overlay set, Chrome on Android stops resizing and panning the page for the keyboard, but nothing in the stack pads the screen's scroller by the keyboard's inset (ChatLayout pairs the same overlay with a spacer of the keyboard's height). The end of a long screen, about the keyboard's 312 px, stays under the keyboard and cannot be scrolled above it until typing ends. Pad the scroller by the guide's inset while it holds the overlay, or leave the overlay out.

*/
field: ReactNode;
/** Whether search is active: the field's controlled `isActive`. */
isActive: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isActive restates the SearchField's own controlled isActive, and nothing checks that the two agree: with this false while the field is active, the field rises into a bar that keeps its background and controls and paints over it; with this true while the field is inactive, the bar's controls fade and turn inert while the field stays put. AST-002 FR15 asks for invalid combinations to be unrepresentable or warned about; letting the stack learn the active state from the field would leave field and hidesWhenScrolling here.

Comment on lines +1609 to +1612
if (lift == null) {
lift = measureLift(key) ?? 0;
storeLift(key, lift);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When isActive flips without a focus inside the field (a first layout that starts active, or a programmatic toggle), the lift is measured and stored here, inside the search layout effect, so the commit that activates hands the field the wrong lift and a follow-up render repairs it. react-component-runtime INV1 and INV3 ask an effect that sets state to name the external system or DOM measurement it derives from; measuring in the activating event, or naming the measurement, would satisfy it.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A platform condition widens the adaptation-condition vocabulary that architecture:theme-authoring-contract keeps fixed, and the motion roles it is meant for write --motion-<role> variables whose names do not originate in tokens.stylex.ts (theme-tokens INV1). Draft AST-094 FR4 and DEC-1 in this same pull request also rule out tuning touch motion per theme, so an owner has to choose one.

Comment on lines +141 to +146
if (registered !== undefined && registered.kind !== kind) {
throw new Error(
`${location}: "${id}" is a ${registered.kind}; set it under the matching group.`,
);
}
const variable = motionRoleVariable(id);

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 well-formed role id that no motion table registers passes this check and is written as a variable nothing reads, so a typo or a role a table later renames silently does nothing. Reject unregistered ids with the same named error the wrong-kind case gets.

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.

This adds the interaction proofs as their own job with its own scope check. 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; running these proofs inside the existing UI test owner would conform.

Comment on lines +360 to +361
cancelLabel = 'Cancel',
clearLabel = 'Clear text',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These defaults become the close and clear buttons' accessible names, so every locale hears English. internationalization INV1a asks for Astryx-owned visible and assistive strings to be catalog keys resolved by the translator, as @astryx.navigationStack.back already is for the stack's back button.

Comment on lines +97 to +101
- **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
(reduced motion closes at once) for touch presentations (owners: ernestt,
cixzhang).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is no spec:AST-090 on main or at this head, so this proposed amendment has nothing to amend. Cite the record that owns exit motion today, or drop the amendment.

@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. lab/src/SearchField/SearchField.tsx:291 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. lab/src/NavigationStack/NavigationStack.tsx:1604 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.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…corded on the restacked heads (iOS 26.2 simulator, Chromium, Android emulator with Chromium 158)
@vjeux
vjeux force-pushed the uikit/c14-navigation-search branch from 733dc0a to 33c0fde Compare October 25, 2026 04:40
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
vjeux added 18 commits October 27, 2026 03:29
…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.
…st's cadence

Under load the screencast delivers frames at its own rate: 13 in the judged
window where the 60 Hz count wanted at least 15, while the title changed on
every frame delivered. The proof now counts against the cast's measured
cadence, the median interval between the frames it delivered in the window
(never faster than 60 Hz); a frozen title delivers none there and still fails.
The bases merged with none of this PR's own change, so a compare from here
shows only that change. The bases both add to the shared motion notes, the
UIKit table's entry header and the en catalog; each keeps both sides' entries.
Both re-export useAnimator from Core's internal motion entry; it keeps one,
meta#8181's, with its type.
…wn start

Under load the trace's round trip could begin well after the rise had
started (38 % up at its first frame), so "150 ms into the rise" landed about
190 ms in, at 84 % of the rise, and the bar's carried-on turn passed the
85 % bound (92 %; once in two WebKit runs). The sampler now times the cancel
from the rise animation's start; when a busy runner begins the trace too late
for three frames before the cancel point, the bar is let back down and the
activation tried again, up to three times.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…slates, the Layout/Paint proof waits for demotion, the handover counts at the cast's cadence) into the bar look's bases
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…) into the NavigationStack bar look

# Conflicts:
#	packages/core/src/AppShell/AppShell.tsx
#	packages/core/src/AppShell/AppShellScrollEdge.test.tsx
#	packages/core/src/hooks/useScrollEdgeEffect.ts
#	packages/core/src/motion/useScrollEdgeEffect.test.tsx

@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 has to wait on an engineering owner: no current spec governs the NavigationStack search hand-over, the Lab NavigationStack and SearchField touch presentations it builds on (including the clocks and springs they play as internal constants), or the new isRtlElement export from @astryxdesign/core/hooks. An engineering owner can approve or reject those additions by commenting /approve-spec <full head SHA> on this pull request, and the author may record it as an engineering owner. Whatever that decision, these still need changes:

  • The diff carries the whole unmerged stack, about 41,300 authored lines; landing or retargeting the predecessors would leave the hand-over's own commits, the List divider and build-plugin changes belong in their own pull requests, and the selection hold and isRtlElement each need one copy instead of two.
  • A SearchField passed without a controlled isActive rises into the bar's row under a bar that never steps aside, leaving Cancel out of a finger's reach (NavigationStack.tsx:831-839).
  • Returning to a screen whose search is still active, after pushing from its results, leaves the covered screen's opaque bar over the field, because nothing hides it again after the transition (NavigationStack.tsx:2092-2103).
  • The back swipe averages its own last three velocities, so a finger that rests before lifting can still finish the pop, which AST-100 FR2 rules out (NavigationStackTouch.ts:171-183); the fine-pointer press-tracker listeners in GestureHost.ts:199-212 go beyond what AST-094 FR19 allows, and the layout effect at NavigationStack.tsx:2040-2064 should not set state.

[Automated review]

Comment on lines +831 to +839
/** Whether a screen's search field is active: the field's own `isActive`. */
function isSearchFieldActive(
props: NavigationScreenProps | undefined,
): boolean {
const field = props?.search;
return (
isValidElement<{isActive?: boolean}>(field) && field.props.isActive === 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.

The stack follows only the element's isActive prop, but SearchField activates itself on focus when it is uncontrolled (defaultIsActive), and the focusin handler still stores a lift for it. A <SearchField label="Search" /> therefore rises into the bar's row under a bar that keeps its background, back button and items, so Cancel is out of a finger's reach (AST-002 FR15: do not silently render a broken state). Follow the field's own state (for example, wrap its onActiveChange the way lift is injected), or make isActive required in the search type and inject lift only into a controlled field.

Comment on lines +2092 to +2103
if (previous.key !== topKeyNow) {
if (isTopSearching && !searchingRef.current.has(topKeyNow)) {
// A screen that becomes top already searching (pushed again with
// its field active) gets the bar at once.
runSearch(topKeyNow, true, true);
return;
}
barBackground.animator.animate(
{opacity: searchingRef.current.has(topKeyNow) ? 0 : 1},
{spring: springs.navigationBarElements},
);
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 screen that becomes the top while still searching only fades the shared bar background here. showCoveredBar() already ran when the transition started and cleared hiddenBarRef, so the covered screen's freshly mounted bar layer (opaque, at rest) stays over the field. In the Search story: General, focus its field, About, then back. Call hideCoveredBar() in this branch when searchingRef holds the new top.

Comment on lines +2040 to +2064
useLayoutEffect(() => {
const present = new Set(view.models.map(model => model.key));
const forget = (records: Map<string, unknown> | Set<string>) => {
for (const key of [...records.keys()]) {
if (!present.has(key)) {
records.delete(key);
}
}
};
forget(searchingRef.current);
forget(heldCollapsesRef.current);
forget(searchRestRef.current);
forget(freshLiftsRef.current);
setLifts(current => {
const gone = [...current.keys()].filter(key => !present.has(key));
if (gone.length === 0) {
return current;
}
const next = new Map(current);
for (const key of gone) {
next.delete(key);
}
return next;
});
}, [view.models]);

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 layout effect sets state (setLifts) to drop the entries of screens gone from view.models, and its comment names no external system or DOM measurement (react-component-runtime INV1, INV3). Read lifts through the present keys during render, or prune where the path change is derived.

Comment on lines +171 to +183
const p = stack.progress();
// 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.

The swipe keeps its own sample buffer and releases on the mean of the last three pan velocities, so a finger that rests before lifting still finishes the pop on earlier motion. AST-100 FR2 and FR3 and interaction-modality INV10 give one release velocity from core's tracker, 0 after a still pause, with no Lab buffer or filter. Pass the pan recognizer's release velocity at ended to shouldFinishPop and drop interactivePopVelocitySamples.

Comment on lines +199 to +212
/**
* The recognizer, or the press tracker, reads each event's pointer itself,
* so it attaches even where the primary pointer is fine, with no warning.
* A recognizer gets the host's listeners and its element's style there, as
* `spec:AST-057`'s row swipe does. A press tracker there gets only passive
* `touchstart` and `pointerdown` listeners on its own element, and no
* style; the host's document and window listeners come for a finger's
* press on it and go when the press ends. It paints that press's feedback
* only (an FR19 exception for press feedback only, pending in
* `spec:AST-094`'s amendment): no control event, nothing kept from the
* browser, which still pans, and no mouse or pen press. Everything else
* stays inactive there. @default false
*/
perEventPointer?: boolean;

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 perEventPointer press tracker gets passive touchstart and pointerdown listeners where the primary pointer is fine, which AST-094 FR19 ("no touch listener is registered") and FR24 (only module:DropdownMenu/useMenuPress is excepted) rule out, and no component passes it. Remove the press-tracker carve-out until AST-094 is amended; the AST-057 recognizer carve-out FR19 names can stay.

Comment on lines +8 to +10
* @position Lab-internal, not exported from `@astryxdesign/lab`: lab's own
* direction check, used by SearchField's touch layer. Core's helper of the
* same name is not among core's published exports, so lab keeps its own

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core exports isRtlElement from @astryxdesign/core/hooks in this same diff, and NavigationStack already imports it from there, so this header is stale and Lab carries a second copy; keep one.

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

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:33

This branch was successfully deployed

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