Skip to content

test(lab): NavigationStack interaction proofs on real touch input - #8108

Draft
vjeux wants to merge 1768 commits into
meta:mainfrom
vjeux:uikit/c14-navigation-stack-proofs
Draft

vjeux wants to merge 1768 commits into
meta:mainfrom
vjeux:uikit/c14-navigation-stack-proofs

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8107. Review only this PR's change.

Maintainer impact

What led here: we are bringing Astryx's touch components to iOS quality on the mobile web, matching UIKit's motion. NavigationStack (#8107) reproduces UIKit's navigation, but its unit tests use fake timers and never see what a browser draws: a push off UIKit's curve, a swipe that stops following the finger, or a push that freezes on a busy page would pass them. These tests drive the stack in real Chromium and WebKit with real touch, and fail CI on any of that.

Intended invariant

  • A push follows UIKit's spring within half a pixel per frame.
  • A back swipe follows the finger; a slow release cancels, a flick finishes. A browser tab keeps its edge swipe (spec:AST-094 FR22).
  • Each bar moves with its screen.
  • A push keeps moving through a 600 ms main-thread block, without per-frame layout or paint.
  • Focus returns to the pushing control after a pop; Reduced Motion cross-fades.

Change

The options were more unit tests (fast, blind to rendering), screenshot comparisons (single frames, brittle) or the repository's interaction harness (real input, every frame, slower). I chose the harness because it checks what a person sees, in both engines. Moving layers are compared by their animations, not computed positions, which WebKit can report late on a busy machine. Added: one test file, two stories (a sideways scrolling strip; an app that refuses a pop) and the harness as a lab dev dependency.

Evidence

A push on the iOS 26.2 simulator keeps moving while the main thread is blocked

Scope

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

Testing

What I ran and what it showed:

  1. Built Storybook (pnpm storybook:build), which the tests load.
  2. Chromium (node node_modules/@playwright/test/cli.js test --config playwright.interaction.config.ts --project=chromium packages/lab/src/NavigationStack) and WebKit (node internal/interaction-harness/scripts/webkit-container.mjs packages/lab/src/NavigationStack): every test passed, twice in each engine; WebKit skips three that need Chromium-only traces.
  3. To confirm a test catches a defect, I gave the top bar 0.9 of its screen's speed (velocity.topX * 0.9 in NavigationStack.tsx's animateProgress): "A back swipe holds the revealed bar still…" failed every run.
  4. By hand, "Lab/NavigationStack › Pushed two levels" in Chrome's phone mode: dragging from the left third past the middle moves the screen and its bar together, and the screen pops.

Recorded on the iOS 26.2 simulator and the Android 15 emulator. Includes #8562 (Core's internal entry points); if those land differently, this follows.

Local gates are the evidence here; no CI run is expected.

@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 29, 2026 11:00pm 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 29, 2026, 11:18 PM UTC

@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-stack-proofs branch from 20142ee to 3efa2a4 Compare October 24, 2026 06:16

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision: against main this diff carries the whole unmerged stack, so it needs a ruling on whether the draft AST-094 record (FR1–FR23) governs the touch foundations, lab NavigationStack and the package-level @astryxdesign/core/motion/experimental and /gestures/experimental subpaths (AST-017 FR21 provides /experimental subpaths only for an owning component), and whether component:Selector DEC-1 ("adaptive keeps its width-and-pointer query") is updated for the new presentation="adaptive" boundary or that change leaves the stack. 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. These need fixing whatever that decision:

  • Merge the predecessors (or rebase onto them) so this pull request's diff is its own 570-line commit rather than about 27,600 authored lines.
  • NavigationStack's content swipe pops the screen when someone swipes a horizontal scroller inside it that can still scroll (NavigationStack.tsx:1116–1131, against the promise at :280–283).
  • A push that starts while another push is still sliding shows the new screen with no slide (NavigationStack.tsx:842–845).
  • motion/uikit/navigation.ts:83 needs an M-… measurement id for the UIKit table test, routes.lock.json and packages/cli/assets/discover/official-packages.json need regenerating for NavigationStack, and AST-094 FR7 cites an AST-090 that is not in the repository.

[Automated review]

cardsRef,
() =>
new PanGestureRecognizer({
direction: 'right',

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 pan claims every rightward drag in the cards, including one that starts on a horizontal scroller that can still scroll: with a scroller at scrollLeft 300 inside the Pushed story's top screen, a 160px rightward touch swipe popped to General instead of scrolling. The card style at lines 280–283 promises the scroller keeps its native sideways scrolling. Failing the pan when touch.scroll?.canScroll(...) says the content can move the finger's way (or using scrollHandoff) would keep that promise.

releaseRef.current = null;
let promise: Promise<boolean>;
if (transition.kind === 'push') {
if (!wasRunning) {

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 a push lands while another transition is running, this skips placing the new top card at progress 1, so its animator springs from its resting x = 0 to 0: tapping About while General was still at 131px showed About at 0px on every frame, with no slide. Placing a card that has no presented position off-screen first would keep the slide.

* @source measured: UIKit lab (injected host app), iOS 26.2 simulator,
* iPhone 17 Pro, UINavigationController.contentSwipe began after 27–30 pt
* of travel for drags at or under 600 pt/s, Oct 23 2026
* @measure the exact threshold and the faster start: at 1000 pt/s the swipe

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:151–152 needs @measure M-… with a row in motion/MEASUREMENTS.md; this prose @measure fails UIKit constants table: every value is sourced (law P2) > navigation.ts › contentPopHysteresis (also red in CI test-ui (1/4)). Moving the text into a new M- row and referencing its id fixes it.

// and large touch displays stay anchored, phones in either orientation get
// the sheet. The query is the shared touch presentation's
// (utils/touchPresentation.ts).
export const COMPACT_TOUCH_PRESENTATION_QUERY = COMPACT_TOUCH_QUERY;

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 moves presentation="adaptive" for DropdownMenu, ContextMenu, MoreMenu, Selector and MultiSelector from (max-width: 768px) and (pointer: coarse) to narrower than 744px or shorter than 500px with a coarse pointer. The current component:Selector DEC-1 says "adaptive keeps its width-and-pointer query", so this needs that record amended by an owner, or it should leave this stack.

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 (the same citation recurs at line 235), so a reader cannot see what FR7 departs from. Cite the record where it lives, or drop the note.

@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

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 57.2KB +303B 12.4KB 12.4KB 0B
core/AppShell 57.9KB 58.1KB +250B 11.6KB 11.6KB 0B
core/AvatarGroup 39.8KB 39.8KB +10B 6.4KB 6.4KB 0B
core/AvatarGroupOverflow 39.9KB 40.2KB +314B 6.4KB 6.4KB 0B
core/Banner 47.8KB 48.1KB +299B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.3KB 54.6KB +252B 9.9KB 9.9KB 0B
core/BottomSheet 34.0KB 34.0KB 0B 7.9KB 7.9KB 0B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/BreadcrumbItem 66.4KB 66.7KB +312B 11.0KB 11.0KB 0B
core/Button 43.3KB 43.6KB +283B 9.2KB 9.2KB 0B
core/ButtonGroup 8.4KB 8.4KB 0B 3.3KB 3.3KB 0B
core/Calendar 54.9KB 55.2KB +298B 10.0KB 10.0KB 0B
core/Carousel 50.2KB 50.5KB +327B 10.2KB 10.2KB 0B
core/ChatComposer 60.6KB 60.9KB +280B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.7KB 56.0KB +275B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.7KB 56.0KB +275B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.5KB 46.8KB +274B 9.5KB 9.5KB 0B
core/ChatLayout 48.8KB 49.1KB +284B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.4KB 46.6KB +264B 9.6KB 9.6KB 0B
core/ChatSendButton 45.8KB 46.1KB +274B 9.2KB 9.2KB 0B
core/CheckboxInput 49.9KB 50.2KB +281B 9.3KB 9.3KB 0B
core/CheckboxList 46.0KB 46.0KB +9B 8.1KB 8.1KB 0B
core/CheckboxListItem 54.0KB 54.3KB +295B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.1KB +289B 6.9KB 6.9KB 0B
core/CodeBlock 60.5KB 60.8KB +285B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 12.0KB +291B 4.2KB 4.2KB 0B
core/CommandPalette 50.9KB 51.2KB +292B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.7KB +280B 3.5KB 3.5KB 0B
core/ComplexSelector 55.8KB 56.1KB +328B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 78.0KB +283B 14.6KB 14.6KB 0B
core/ContextMenuItem 16.6KB 16.8KB +281B 5.2KB 5.2KB 0B
core/DateInput 99.9KB 100.1KB +252B 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 69.6KB +276B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.3KB +258B 15.2KB 15.2KB 0B
core/DialogHeader 48.8KB 49.1KB +289B 9.8KB 9.8KB 0B
core/Drawer 31.6KB 31.6KB -2B 8.0KB 8.0KB 0B
core/DrawerHeader 48.9KB 49.2KB +259B 11.2KB 11.2KB 0B
core/DropdownMenu 72.7KB 73.0KB +288B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.4KB +284B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 15.7KB +287B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.5KB +275B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.1KB 58.4KB +278B 9.5KB 9.5KB 0B
core/Field 44.2KB 44.2KB +11B 8.0KB 8.0KB 0B
core/FieldLabel 39.8KB 39.8KB +10B 6.5KB 6.5KB 0B
core/FileInput 54.1KB 54.4KB +272B 10.3KB 10.3KB 0B
core/IconButton 43.3KB 43.6KB +282B 9.2KB 9.2KB 0B
core/InputGroup 44.8KB 44.8KB +9B 8.0KB 8.0KB 0B
core/Item 11.4KB 11.6KB +273B 4.7KB 4.7KB 0B
core/LayerProvider 63.3KB 63.6KB +295B 10.1KB 10.1KB 0B
core/Lightbox 51.2KB 51.4KB +258B 9.8KB 9.8KB 0B
core/Link 43.5KB 43.8KB +297B 7.6KB 7.6KB 0B
core/ListItem 53.5KB 53.8KB +322B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 144.7KB +257B 17.4KB 17.4KB 0B
core/MobileNav 51.0KB 51.3KB +272B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.2KB 46.5KB +269B 9.4KB 9.4KB 0B
core/MoreMenu 72.9KB 73.2KB +289B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 76.4KB +265B 14.6KB 14.6KB 0B
core/NavHeadingMenu 8.9KB 8.9KB 0B 3.2KB 3.2KB 0B
core/NumberInput 55.7KB 56.0KB +288B 10.7KB 10.7KB 0B
core/Outline 28.6KB 28.9KB +300B 4.0KB 4.0KB 0B
core/Pagination 87.7KB 88.0KB +272B 15.3KB 15.3KB 0B
core/Popover 49.8KB 50.1KB +246B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 155.3KB +275B 18.6KB 18.6KB 0B
core/RadioList 45.6KB 45.6KB +9B 8.0KB 8.0KB 0B
core/RadioListItem 52.1KB 52.5KB +336B 9.7KB 9.7KB 0B
core/SegmentedControl 38.8KB 38.8KB +11B 6.3KB 6.3KB 0B
core/SegmentedControlItem 8.6KB 8.9KB +285B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.1KB +294B 7.1KB 7.1KB 0B
core/Selector 79.1KB 79.4KB +294B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.8KB +251B 5.1KB 5.1KB 0B
core/SideNav 58.0KB 58.3KB +275B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.6KB 46.9KB +251B 9.5KB 9.5KB 0B
core/SideNavHeading 54.7KB 55.0KB +258B 10.1KB 10.1KB 0B
core/SideNavItem 54.5KB 54.8KB +276B 10.1KB 10.1KB 0B
core/Slider 48.5KB 48.5KB +10B 8.5KB 8.5KB 0B
core/Step 32.4KB 32.7KB +289B 5.3KB 5.3KB 0B
core/Stepper 47.4KB 47.7KB +274B 9.6KB 9.6KB 0B
core/Switch 48.4KB 48.7KB +294B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.0KB +294B 4.1KB 4.1KB 0B
core/TabList 40.2KB 40.2KB +12B 7.2KB 7.2KB 0B
core/TabMenu 51.6KB 51.9KB +284B 10.0KB 10.0KB 0B
core/Table 84.0KB 84.2KB +261B 15.1KB 15.1KB 0B
core/TableCell 78.8KB 79.1KB +277B 14.8KB 14.8KB 0B
core/TableHeaderCell 78.6KB 78.9KB +282B 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.4KB 52.6KB +286B 11.2KB 11.2KB 0B
core/TextArea 49.4KB 49.4KB +13B 9.4KB 9.4KB 0B
core/TextInput 53.5KB 53.8KB +291B 10.5KB 10.5KB 0B
core/Thumbnail 50.7KB 50.9KB +283B 11.0KB 11.0KB 0B
core/TimeInput 86.8KB 87.1KB +280B 13.8KB 13.8KB 0B
core/Timestamp 27.5KB 27.5KB +11B 9.5KB 9.5KB 0B
core/Toast 59.8KB 60.1KB +267B 9.7KB 9.7KB 0B
core/ToggleButton 44.1KB 44.4KB +252B 9.3KB 9.3KB 0B
core/Token 29.6KB 29.9KB +298B 4.5KB 4.5KB 0B
core/Tokenizer 64.8KB 65.1KB +316B 11.7KB 11.7KB 0B
core/Toolbar 39.8KB 39.8KB +12B 7.7KB 7.7KB 0B
core/TopNav 51.9KB 52.1KB +287B 10.4KB 10.4KB 0B
core/TopNavHeading 54.2KB 54.4KB +241B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.3KB +283B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.2KB 54.5KB +278B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.4KB +281B 4.1KB 4.1KB 0B
core/TopNavMenu 53.6KB 53.9KB +268B 10.1KB 10.1KB 0B
core/TreeList 32.5KB 32.8KB +284B 4.7KB 4.7KB 0B
core/Typeahead 61.5KB 61.7KB +277B 11.3KB 11.3KB 0B
lab/NavigationScreen N/A 57.7KB N/A N/A 994B N/A
lab/NavigationStack N/A 57.7KB 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 34.6KB 0B
@astryxdesign/lab 11.1KB 11.3KB +178B

Accessibility Audit

Status: No accessibility violations detected.

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 is on hold for an engineering owner's decision: against main this diff carries the whole unmerged stack, so it needs a ruling on whether the draft AST-094 record (FR1–FR23, DEC-1, DEC-2) governs the touch foundations, lab NavigationStack and the package-level @astryxdesign/core/motion/experimental and /gestures/experimental subpaths, and whether component:Selector DEC-1 (“adaptive keeps its width-and-pointer query”) is updated for the new presentation="adaptive" boundary or that change leaves the stack. 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. These need fixing whatever that decision:

  • NavigationStack's content swipe pops the screen instead of scrolling a horizontal scroller inside it, since the pan now claims the touch from 5 px (NavigationStack.tsx:1116–1131, against the promise at :280–283).
  • A push that starts while another push is still sliding shows the new screen with no slide (NavigationStack.tsx:842–845).
  • The lint check fails because packages/cli/assets/discover/official-packages.json changes CLI output without a Changeset naming @astryxdesign/cli.
  • Land the predecessors (or retarget) so the diff is this pull request's own commit, and point AST-094 FR7 at a record that exists instead of AST-090.

[Automated review]

Comment on lines +1116 to +1131
const content = useGestureRecognizer(
cardsRef,
() =>
new PanGestureRecognizer({
direction: 'right',
hysteresis: CONTENT_CLAIM_PX,
maximumNumberOfTouches: 1,
shouldReceiveTouch: (_recognizer, touch) =>
!(
browserOwnsEdgeRef.current &&
startsInEdgeBand(
touch.startX,
writingDirection(containerRef.current),
)
),
}).requireToFail(edge),

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 pan claims a rightward drag from 5 px (the host's early claim for a one-way pan), before any native scroll, so a horizontal scroller in a pushed screen can no longer scroll back toward its start: in Chromium a 160 px rightward swipe inside one left it at scrollLeft 300 and popped the screen, against the promise at lines 280–283. Let the pan fail when the touch's horizontal scroll chain can still scroll its way.

Comment on lines +842 to +845
if (transition.kind === 'push') {
if (!wasRunning) {
setProgress(1, top, under);
}

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 a push lands while another transition runs, the new top card is never placed at progress 1, so animateProgress(0, …) starts from its resting x = 0 and nothing slides: pushing General then About 80 ms later leaves About at 0px from its first frame. Place the incoming card at progress 1 unless it is the card the running transition already moves.

Comment on lines +155 to +162
{
"kind": "component",
"name": "NavigationScreen"
},
{
"kind": "component",
"name": "NavigationStack"
},

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 @astryxdesign/cli's shipped discovery data, and the lint check's Changeset coverage step fails for it; add a Changeset naming @astryxdesign/cli.

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 in this pull request (the citation recurs at line 235), so an approver cannot see which exit rules FR7 departs from; cite the record that states them, or drop the departure.

@vjeux
vjeux force-pushed the uikit/c14-navigation-stack-proofs branch from 4e1e859 to b9088e1 Compare October 24, 2026 10:01
@vjeux vjeux mentioned this pull request Oct 24, 2026
4 tasks done

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision: against main this diff carries the whole unmerged stack, so it needs a ruling on whether the draft AST-094 record (FR1–FR23, DEC-1, DEC-2) governs the touch foundations, lab NavigationStack and the package-level @astryxdesign/core/motion/experimental and /gestures/experimental subpaths, and whether component:Selector DEC-1 (“adaptive keeps its width-and-pointer query”) is updated for the new compact-touch boundary or that change leaves the stack. 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. These need fixing whatever that decision:

  • The content swipe pops a pushed screen when someone drags a horizontal scroller inside it back toward its start (NavigationStack.tsx:1116–1134, against the promise at :280–283).
  • A push that starts while another transition runs shows the new screen with no slide (NavigationStack.tsx:842–845).
  • .changeset/touch-presentation.md does not say that every DropdownMenuSubMenu left at its default now drills in or flies out at the new boundary.
  • Land the predecessors (or retarget) so the diff is this pull request's own 570-line commit, and point AST-094 FR7 at a record that exists instead of AST-090.

[Automated review]

Comment on lines +1119 to +1131
new PanGestureRecognizer({
direction: 'right',
hysteresis: CONTENT_CLAIM_PX,
maximumNumberOfTouches: 1,
shouldReceiveTouch: (_recognizer, touch) =>
!(
browserOwnsEdgeRef.current &&
startsInEdgeBand(
touch.startX,
writingDirection(containerRef.current),
)
),
}).requireToFail(edge),

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 pan claims a rightward drag from 5 px through the host's early claim, before any native scroll, so a horizontal scroller in a pushed screen can no longer scroll back toward its start: in Chromium with touch input, a 160 px rightward swipe inside a scroller at scrollLeft 300 popped the screen and never scrolled it, while a leftward swipe scrolled it to 433. spec:AST-025 FR18 gives that drag to the nearest owner on the axis, and the card style at lines 280–283 promises it. Letting the pan begin only when the scrollers under the touch cannot scroll its way (the recognizer's scrollEdge option) would fix it, and a scroller case in the interaction proof would keep it fixed.

Comment on lines +843 to +845
if (!wasRunning) {
setProgress(1, top, under);
}

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 a push lands while another transition runs, the new top card is never placed off screen, so animateProgress(0, …) starts from its resting x = 0: pushing About 80 ms into General's push shows About at 0 px from its first rendered frame, where a lone push still has it 218 px out after 57 ms. Placing the incoming card at layersAt(1, …).topX whenever it is new to the transition, plus a quick double-push case in the proof, would fix it.

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.

The same constant drives useMenuDrillIn, and DropdownMenuSubMenu defaults to presentation = 'adaptive', so every sub-menu left at its default now opens as a flyout on an iPad mini in portrait and drills in on a phone in landscape, with no opt-in. spec:AST-017 FR7 makes the Changeset body the whole declaration, so this one should name that change too.

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 in this pull request (the citation recurs at line 235), so an approver cannot see which exit rules FR7 departs from; cite the record that states them, or drop the departure.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision: against main this diff carries the whole unmerged stack, so it needs a ruling on whether the draft AST-094 record (FR1–FR23, DEC-1, DEC-2) governs the touch foundations, lab NavigationStack and the package-level @astryxdesign/core/motion/experimental and /gestures/experimental subpaths, and whether component:Selector DEC-1 (“adaptive keeps its width-and-pointer query”) is updated for the new compact-touch boundary or that change leaves the stack. 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. These need fixing whatever that decision:

  • NavigationStack's content swipe pops a pushed screen when someone drags a horizontal scroller inside it back toward its start (NavigationStack.tsx:1157–1176, against the promise at :283–286).
  • Focus drops to the page after every pop in a real browser, because the revealed screen is still inert when focusAfter runs (NavigationStack.tsx:926–933); the jsdom focus test cannot see this.
  • A push that starts while another transition runs shows the new screen with no slide (NavigationStack.tsx:878–881).
  • .changeset/touch-presentation.md does not say that every DropdownMenuSubMenu left at its default now drills in or flies out at the new boundary.
  • Land the predecessors (or retarget) so the diff is this pull request's own 632-line change, and point AST-094 FR7 at a record that exists instead of AST-090.

[Automated review]

() =>
new PanGestureRecognizer({
name: 'NavigationStack.contentBackSwipe',
direction: 'right',

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 one-way pan has no scroll check, so the host claims the touch from 5 px its way and a horizontal scroller in a pushed screen can't be dragged back toward its start: in Chromium with touch input, a 160 px rightward swipe inside an overflow-x: auto strip at scrollLeft 300 popped About and never scrolled it (a leftward swipe scrolled it to 459). The card comment at lines 283–285 promises the opposite, and spec:AST-025 FR18 gives that drag to the nearest owner on the axis. Letting the pan begin only when the scrollers under the touch can't move its way (the recognizer's scrollEdge option), plus a scroller case in the proof, would fix it.

);
if (under != null) {
settle(under, 'top');
focusAfter('pop', 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.

When this runs, the setView above has not committed yet, so view.leaving is still set and every card and bar is inert (lines 1216 and 1228): source.focus() does nothing, and the fallback at line 855 targets the leaving screen's own title. In Chromium, Back after a keyboard push, Back on the deep-linked story and Done all leave focus on body; the jsdom test at NavigationStack.test.tsx:133–149 can't see it because jsdom ignores inert. Focusing after the commit that clears leaving, with the revealed screen's title as the fallback, would fix it.

releaseRef.current = null;
let promise: Promise<boolean>;
if (transition.kind === 'push') {
if (!wasRunning) {

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 push that lands while another transition runs skips this, so the new top card starts from its resting x = 0 and never slides: pushing About 81 ms into General's push kept About at 0 px on every frame for 270 ms, where a lone push starts it at 370 px. Placing the incoming card at layersAt(1, …).topX whenever it is new to the transition would keep the slide.

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.

The same constant drives useMenuDrillIn, and DropdownMenuSubMenu defaults to presentation = 'adaptive', so every sub-menu left at its default now drills in on a phone in landscape and flies out on an iPad mini in portrait, with no opt-in. spec:AST-017 FR7 makes the Changeset body the whole declaration, so this one should name that change too.

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 in this pull request (the citation recurs at line 235), so an approver cannot see which exit rules FR7 departs from; cite the record that states them, or drop the departure.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision on the draft AST-094 record (FR1–FR23, DEC-1, DEC-2), the only spec behind the touch foundations, the lab NavigationStack, the package-level motion/experimental and gestures/experimental subpaths and the new presentation="adaptive" boundary, and on whether its FR4 ('physics are not themable') is amended for the new theme motion roles and platform condition or those come out. 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 need fixing:

  • The content swipe pops a pushed screen when someone drags a horizontal scroller inside it back toward its start (NavigationStack.tsx:1171–1190).
  • Focus lands on the page body after every pop, because the revealed screen is still inert when focusAfter runs (NavigationStack.tsx:935–942).
  • A push that starts during a running transition shows the new screen without sliding (NavigationStack.tsx:887–890).
  • test-node (1/4) fails because the docs-topics.json golden no longer matches the motion docs topic.
  • .changeset/touch-presentation.md does not say that a default DropdownMenuSubMenu now drills in or flies out at the new boundary, and AST-094 FR7 cites AST-090, which is not in the repository.
  • Land the predecessors first so this diff carries only its own commit, and record iOS Safari runs of the back swipes.

[Automated review]

() =>
new PanGestureRecognizer({
name: 'NavigationStack.contentBackSwipe',
direction: 'right',

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 one-way pan has no scrollEdge, so it claims at 5 px and cancels a horizontal scroller's native scroll: a rightward drag on a carousel inside a pushed screen pops the screen. scrollEdge: true lets it begin only when no scroller under the touch can move that way.

);
if (under != null) {
settle(under, 'top');
focusAfter('pop', 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.

setView above is only queued, so every card is still inert here and this focus call does nothing; in Chromium focus ends on the body after the back button. Move focus after the commit that clears leaving.

releaseRef.current = null;
let promise: Promise<boolean>;
if (transition.kind === 'push') {
if (!wasRunning) {

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 a transition is running this skips placing the new top card off-screen, and a newly mounted card starts at x = 0, so a second quick push shows the new screen without sliding.

// in that edge band. Server render and first paint assume a tab.
const [browserOwnsEdge, setBrowserOwnsEdge] = useState(true);
const browserOwnsEdgeRef = useRef(true);
useEffect(() => {

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 effect repairs browserOwnsEdge after the first render; useSyncExternalStore with a server snapshot, as useTouchIdiom does, derives it during render (react-component-runtime INV1/INV3).

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.

DropdownMenuSubMenu defaults to adaptive and reads the same query, so every default sub-menu now drills in or flies out at the new boundary too; please name it here.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-090 is not in the repository, so an approver cannot see what FR7 departs from; cite where it lives or drop the clause until it lands.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold for an engineering owner's decision on the draft AST-094 record (FR1–FR23, DEC-1, DEC-2), the only spec behind the touch foundations, the lab NavigationStack, the package-level motion/experimental and gestures/experimental subpaths and the new presentation="adaptive" boundary (which also needs Selector's DEC-1 updated), and on whether FR4 ("physics are not themable") is amended for the theme motion roles and platform condition or those come out. 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 need fixing:

  • The content back swipe pops a pushed screen when someone drags a horizontal scroller inside it back toward its start (NavigationStack.tsx:1171–1190).
  • Focus lands on the page body after every pop, because every screen is still inert when focusAfter runs (NavigationStack.tsx:935–942).
  • A push that starts during a running transition shows the new screen without sliding (NavigationStack.tsx:887–890).
  • The motion docs, theme template and [feat] changeset say themes retune sheets, alerts, menus and scrolling, but only the lab NavigationStack reads the motion roles today.
  • .changeset/touch-presentation.md omits the default DropdownMenuSubMenu drill-in, AST-094 FR7 cites AST-090, which is not in the repository, and the back swipes and tab edge still need iOS Safari runs.
  • Land the predecessors first so this diff carries only its own commits.

[Automated review]

() =>
new PanGestureRecognizer({
name: 'NavigationStack.contentBackSwipe',
direction: 'right',

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 one-way pan has no scrollEdge, so it claims at 5 px (PanGestureRecognizer.ts:112) and the host cancels the finger's touchmove: a rightward drag on a horizontal scroller inside a pushed screen pops the screen instead of scrolling it back, despite the promise at lines 285–287. scrollEdge: true begins it only when no scroller under the touch can move that way.

);
if (under != null) {
settle(under, 'top');
focusAfter('pop', 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.

The setView above is only queued, so every card and bar is still inert here and this focus call does nothing; focus ends on the page body after Back. Move focus after the commit that clears leaving, and fall back to the revealed screen's title rather than the leaving one (line 864).

releaseRef.current = null;
let promise: Promise<boolean>;
if (transition.kind === 'push') {
if (!wasRunning) {

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 a transition is running this skips placing the new top card off-screen, and a newly mounted card rests at x = 0, so a second quick push shows the new screen without sliding. Place a newly mounted card at progress 1 even when retargeting.

// in that edge band. Server render and first paint assume a tab.
const [browserOwnsEdge, setBrowserOwnsEdge] = useState(true);
const browserOwnsEdgeRef = useRef(true);
useEffect(() => {

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 effect repairs browserOwnsEdge after the first render and names no external system (react-component-runtime INV1/INV3); useSyncExternalStore with a server snapshot, as useTouchIdiom does, derives it during render.

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
content: [
{
type: 'prose',
text: 'On a touch screen, sheets, alerts, menus, navigation and scrolling play the springs, durations and curves measured from iOS. Each value is a motion role named by its table path, such as `springs.sheetPresent` or `timing.curves.easeInOut`. A theme retunes a role through `motion.springs`, `motion.durations` or `motion.curves`, at its root or in an adaptation rule; a role it leaves alone keeps its measured default.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the lab NavigationStack reads motion roles or the animator at this head; no released sheet, alert, menu or scroller plays these springs, so a theme that sets springs.sheetPresent (the example here, in the template and in defineTheme's JSDoc) changes nothing a product ships. Describe what reads the roles today.

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.

DropdownMenuSubMenu defaults to presentation = 'adaptive' and its drill-in reads the same query (useMenuDrillIn.tsx:39), so every default sub-menu now drills in or flies out at the new boundary too; please name it here (AST-017 FR7).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-090 is not in the repository (the citation recurs at line 235), so an approver cannot see what FR7 departs from; cite where it lives or drop the clause until it lands.

@vjeux
vjeux force-pushed the uikit/c14-navigation-stack-proofs branch from 580bd5a to bf91c97 Compare October 24, 2026 23:31
vjeux added 26 commits October 28, 2026 11:33
…s, and the action-group tracker writes its own

The header and writePress called it the one writer of the press attribute,
but main's action-group tracker writes its groups' presses itself. They now
say what holds, as meta#8076's gestures README does.
…he press attribute

The release note said every press painter writes data-astryx-press
through one internal writer, but the action-group tracker
(hooks/actionGroupTracker.ts) writes its groups' presses itself, as the
gestures README and utils/pressFeedback.ts already say. The note now says
paintPress writes every press it tracks and the action-group tracker
writes its own.
…llowance under every primary pointer

The useMenuPress record's FR9 puts the trigger's held finger under
spec:AST-094 FR29 under every primary pointer (FR43), so a finger on a
touchscreen laptop may drift 13 pt before the open, as on a phone. The
trigger read (pointer: coarse) as its press started and took the long
press's 10 px where the pointer was fine. Three cases fail until it does
not: a 12 pt drift opens under a fine pointer too, a (12, 12) settle
opens there, and nothing reads a pointer query, the trigger's press
included.
…lowance under every primary pointer

The useMenuPress record's FR9 governs the trigger's held finger by
spec:AST-094 FR29 under every primary pointer (FR43). The trigger now
hands the long press FR29's 13 px drift allowance whatever the primary
pointer, so it no longer reads (pointer: coarse) as its press starts, and
the menus drop their coarsePointer import. ContextMenu keeps the long
press's own allowable movement. The changeset says the same.
Strict ESLint reported six unused bindings in files this pull request
carries: the host's HAPTIC_SWITCH_ATTRIBUTE import, two unused imports
in the pan's tests (scrollProbe, ScreenEdgePanGestureRecognizer), the
swipe test's drift, the presented-hit-testing test's gestures, and a
GestureHost test's before, which now asserts what it was read for: a
second finger's moves leave the pan where it was.
…eal angular speed

The release velocity of a rotation whose listener sets rotation = 0 on
each change, at 2 degrees every 16 ms, read 4.363 rad/s against the
2.182 rad/s of the same stream without a re-base: the test asserted only
more than 1.5. It now asserts the 2.18 rad/s (spec:AST-100 FR2), and
fails.
The rotation's velocity tracker samples the raw unwrapped angle, which
setting rotation does not move, so the setter's shift of the samples read
each re-base as extra turn and doubled the release velocity of a
listener that re-bases on every change. The setter no longer touches the
tracker.
…mates while it runs

The proof read the element's animations 230 ms in, after the dip had
ended, and asserted only that every animated key is opacity, which an
empty list passes: with the cross-fade removed from the plan (the move
still landing), the proof still passed. It now reads the keyframe keys
right after the call starts and asserts they include opacity and nothing
else; the same mutant fails it. Chromium and WebKit pass. (F1's in-flight
read from c648034.)
…roller, and its claimed moves are cancelled under pointer events

Two comparisons stop at the shadow boundary, each failing a case:
- once the host's listener on a recognizer's inner node has seen the
  finger, isFingerDownIn reads it as outside a light-DOM scroller around
  that root, so a scroll under it is not the finger's (FR20);
- under inputMode 'pointer-events', a claimed pan on a shadow host
  cancels none of its finger's moves over the inner content, since the
  finger's target is the inner node and its touches reach the host
  retargeted (FR19).
…the flat tree

isFingerDownIn and the pointer-events finger match compare with
containsAlongFlatTree, as the scroll watch already does: a finger on
content inside an open shadow root is down in the scroller around that
root, and its touches, retargeted to the shadow host, match its pointer
finger, so a claimed pan cancels its moves there (spec:AST-094 FR19,
FR20).
…achments asks, and only then

The host let the last attachment decide whether an element is an action
group (useGestureRecognizer always passes the option), and never
recomputed it at a detach: a plain attachment after the group's turned
the group off, and a group's attachment released left a recognizer that
never asked taking presses under a fine pointer. Fails until the host
counts the attachments that ask.
…ents asks

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

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

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

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

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

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

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

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

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

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

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
meta#8360 now carries meta#8076 and meta#8320, which this branch took before.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:31
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner providing or identifying the governing spec for the new public isRtlElement export from @astryxdesign/core/hooks, or deciding that it stays internal; 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 that decision, the back swipe, the gesture host and the scope need these changes:

  • A release moving back toward the start still pops (navigationTransition.ts:121–130), where spec:AST-094 FR37 requires a cancel.
  • NavigationStackTouch.ts averages its own three velocities, so a resting finger still flings; spec:AST-100 FR2 and FR3 require the pan's own release velocity, and the case at NavigationStack.interaction.browser.spec.ts:669–687 should expect a cancel.
  • spec:AST-094 FR19 and FR43 attach gestures for every pointer and keep only the edge swipe off under a mouse, a trackpad or a pen, so the coarse-only gesture host, both swipes and the mouse case at :839–869 need to conform, unless an engineering owner first records an exception.
  • A swipe that catches the return from past the start should resume from the signed position (NavigationStack.tsx:1166, FR6), and the rubber band (FR3), the swipe direction (spec:AST-100 FR5), the iOS-only wording at NavigationStack.doc.mjs:67, real-device evidence for the iOS Safari and Android Chrome claims, and the caller-less internal options need the fixes noted in the review.
  • At 31,476 authored lines the pull request carries its whole stack, so landing the layers below first, or re-targeting at #8107's branch, keeps this review to its own change.

[Automated review]

Comment on lines +121 to +130
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. From 0.85 of the width, any backward release slower than one width per second still passes this projection and pops. Check the velocity's sign before projecting.

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.

This mean of the last three per-move velocities never decays during a rest, so a finger that stops before lifting still flings (the case at NavigationStack.interaction.browser.spec.ts:669–687 asserts it). spec:AST-100 FR2 makes that release 0 and FR3 rules out a second estimate; read the pan's own release velocity at the lift.

layers?.barDim?.stop();
}
runningRef.current = null;
return width > 0 ? Math.abs(x) / width : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Past the start the card sits at a negative x (layersAt), so Math.abs turns a catch during the return into a jump to the mirrored offset (spec:AST-094 FR6). Return the signed progress: (direction === 'rtl' ? -x : x) / width.

Comment on lines +911 to +913
private isActive(recognizer: GestureRecognizer): boolean {
return this.isCoarse || this.perEventRecognizers.has(recognizer);
}

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 FR19 and FR43 attach recognizers for every pointer, and architecture:react-component-runtime INV13 rules out a touch-only query; this gate keeps them inactive under a mouse, a trackpad, or a finger or pen on a device whose primary pointer is fine.

export {useTreeFocus} from './useTreeFocus';
export type {UseTreeFocusOptions, UseTreeFocusReturn} from './useTreeFocus';

export {isRtlElement} from './isRtlElement';

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 isRtlElement stable public API; no current record approves it, and its changeset says it is pending owner review. Lab could reach it through an internal entry until an engineering owner decides.

This branch was successfully deployed

1 active deployment
Preview — 531614ac Deployed Oct 29, 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