Skip to content

feat(lab): NavigationStack, push/pop navigation that moves like UINavigationController - #9697

Merged
cixzhang merged 5 commits into
meta:mainfrom
vjeux:lab-navigation-stack
Nov 5, 2026
Merged

cixzhang merged 5 commits into
meta:mainfrom
vjeux:lab-navigation-stack

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)

Stack: #9696 → #9697

Stacked on #9696 (Core's part); the diff shows its files until it merges.

User need

Phone layouts need iOS's drill-down navigation: screens push, a back swipe follows the finger, and large titles hand over to the bar.

Problem and solution fit

  • NavigationStack renders the app's path, one NavigationScreen per entry. Covered screens stay mounted but inert.
  • Pushes, pops and back swipes (from the leading edge or the content) move like UIKit's: a measured push spring, a bar per screen, one release decision at the lift. Focus moves to the new title, and back to the pushing control.
  • titleDisplayMode="large": the large title scrolls under the bar, which then shows its own title.
  • endContent puts content at the end of the screen's bar.

Current specification

Lab is not shipped; no spec needed. It follows spec:AST-094 FR3 (UIKit's rubber band), FR6 (a caught push continues from the screen), FR22 (a tab's edge swipe stays the browser's), FR36–FR37 (one decision; moving back cancels) and FR43 (every pointer); spec:AST-100 FR2–FR3 (the pan's release velocity). astryx discover lists both components, since official-packages.json is regenerated (spec:AST-051 FR11).

Owner rule, Nov 4 2026 (Cindy): anything Lab-only gets an OK. Open until promotion to Core:

  1. The component and its API: NavigationStack, NavigationScreen, useNavigationStack.
  2. Motion roles (spec:AST-094 FR4): the stack plays its springs directly, not through roles. Motion class per AST-094 FR45 (gesture class: the interactive edge-swipe pop is drag-capable, so pushes and pops spring, tap-started ones included); Lab only; confirmed with the owner at promotion.

Public delta and caller burden

Lab only: three exports and their prop types. The discover list and the docsite route lock gain two pages.

Design and alternatives

One progress value drives every layer on Core's compositor animator, so a push and a swipe share one model and survive a busy main thread. View Transitions can't track a finger.

Three layout Effects copy the view, path and swipe stack into refs the transition Effect and gestures read; React 19.0, Lab's floor, lacks useEffectEvent.

Size: about 4,940 added lines in one Lab component: the component (~2,020), real-touch browser proofs (~1,960), unit tests (~480), stories, docs, lists (~490).

Evidence

Recordings on the iOS 26.2 simulator, Chromium and the Android 15 emulator: the index.

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

Chrome's device toolbar, "Lab/NavigationStack": tap General; swipe back short of half (springs back), then past it (pops). Scroll "Large title".

Local gates at e3f2a34, which differs from this head (2317228) only in one comment (on #9696 66e6c3b over main 9e559e9), after a frozen install (exit 0): root build exit 0; pnpm typecheck exit 0; the Storybook build exit 0, stamped with that head as CI stamps it; the NavigationStack interaction proofs 43/43 in Chromium and 39 passed + 4 skipped in WebKit (the skips are the three main-thread-block proofs, a stated WebKit gap, and the Chromium-only rendering trace); Lab's UI units 967/967 (86 files), NavigationStack's own 37 among them; the touch-support bundle test 11/11; the PR accessibility contracts 1,182 passed, 1 skipped and 1 failed: Core's Link new-tab release contract, red once under the full three-worker run, 40/40 alone, and outside this diff's reach (reported to its owner); check:changesets, changeset coverage, check:knowledge and check:repo pass; strict lint and Prettier clean on the changed files; pnpm review:pre with this body reports only the three ref-copy Effects argued above. It merges clean onto #9696 and onto main, with no old motion-engine names.

Supersedes #8107, #8108, #8185 and #8699. #8204 (search in the bar) is withdrawn with SearchField.

@vercel

vercel Bot commented Nov 3, 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 Nov 5, 2026 10:57pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 11:05 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 30a67bb

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
lab/NavigationScreen 74.8 kB (new) · 1.1 kB CSS (new) — — · ✓ · ✗
lab/NavigationStack 74.8 kB (new) · 1.1 kB CSS (new) — — · ✓ · ✓

Package size: measured; main has no measurement of the merge base to compare yet.

API changes: none in this pull request

Committed API report behind this build: cli 19 rows, core 23 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner decision, and several changes are needed whatever that decision is.

The description asks for two decisions that no current spec settles: the NavigationStack, NavigationScreen and useNavigationStack component and API, and playing the stack's springs and delays as measured literals, which AST-094 FR4 forbids today ("MUST NOT play a literal"). An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by updating the governing spec on main, and the author may do so as an engineering owner.

  • Reduce the diff to the NavigationStack commit by landing #9399 and #9696 first and rebasing, because 13,882 authored lines exceed the 8,000-line limit for an owner-directed batch.
  • Stop the covered screen's bar dimming from showing through the 1 px gap each bar layer leaves (NavigationStack.tsx:268-272, :382-387), which darkens the bar's bottom rule under its leading 70 % on every pushed screen.
  • Replace the focusin listener Effect (NavigationStack.tsx:1765-1786) with an onFocusCapture handler on the container, beside its existing capture handlers.
  • Give the transition Effect (NavigationStack.tsx:1417-1517) every input it reads, not only view.transition.id.
  • Move the registry and chevron writes out of the CardLayer, BarLayer and BackControl layout Effects and into the callback refs those elements already have.
  • Reuse SearchField's activationTiming and easedCurve instead of re-deriving the activation clock and curve in navigationSearch.ts.
  • Drop the module-level isStackMotionRegistered guard, because Core's table registration is already idempotent.
  • In SearchFieldTouch.ts, use Core's shared observeResize and share the capture-phase blur reveal refusal with Core's useMobileKeyboard instead of copying it.
  • Type search as a SearchField element, so the stack never injects lift and onActiveChange into a DOM element.

[Automated review]

position: 'absolute',
insetInline: 0,
top: 0,
bottom: 1,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This 1 px gap lets the covered bar layer's dimming (the dim style, inset: 0) show through. On a pushed screen at rest, the bar's bottom rule reads darker under the leading 70 % and lighter under the trailing 30 %. End the dimming layers above the hairline, or hide the covered bar layer once a push settles.

* has no theme surface. Once per page, before the first animation; nothing
* registers on import.
*/
let isStackMotionRegistered = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Core's registerMotionTable already ignores a second registration of the same table, so this module-level flag adds state for a concern Core owns. Call registerNavigationTable() and registerSpringsTable() directly, as core/src/motion/animatedLayout.ts does.

}
});
// Only a new transition restarts this; the layers are driven imperatively.
}, [view.transition.id]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Effect also reads view.transition, view.models, settle, setProgress, animateProgress, fadeBack, focusAfter, showCoveredBar and the back-button animators. List them, or read the stable ones through refs, so the dependencies match what the setup reads.


// A field takes focus as it activates: measure its lift in that event, so
// the commit that activates it hands the field the right distance.
useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A field's focus is one event. The container already takes onClickCapture and onKeyDownCapture, so an onFocusCapture handler there can measure the lift, and this Effect and its listener can go.

* role (the UIViewPropertyAnimator curve). SearchField
* plays the same clock and curve, so the field and the bar move together.
*/
export function searchClock(element: Element | null | undefined): SearchClock {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

searchFieldModel.ts already exports this clock (activationTiming) and curve (easedCurve), read a different way (directly from the table, not through resolveMotion). Call those and delete this copy, so the field and the bar cannot drift apart.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner decision, and four changes are needed whatever that decision is.

The description asks for two decisions that no current spec settles: the NavigationStack, NavigationScreen and useNavigationStack component and API, and playing the stack's springs and delays as measured values, which AST-094 FR4 forbids today ("MUST NOT play a literal"). An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by updating the governing spec on main, and the author may do so as an engineering owner.

  • Reduce the diff to the NavigationStack commit by landing #9399 and #9696 first and rebasing, because 14,016 authored lines exceed the 8,000-line limit for an owner-directed batch.
  • Move the focus after a push or pop (NavigationStack.tsx:1361-1414) onto Core's focusForLastInput, isFocusLost and shared modality tracker, re-exported through ./internal/touch, instead of the stack's own isKeyboard and isFocusVisible record.
  • Move BarLayer's titleText registry write (NavigationStack.tsx:680-682) out of its layout Effect and into the titleRefs callback ref.
  • Remove the sentence in NavigationStack.doc.mjs that promises a pushing list row stays selected and deselects on pop, or implement and test that behaviour.

The earlier bar-rule, Effect, search-clock and search typing findings are fixed.

[Automated review]

Comment on lines +1384 to +1390
const isKeyboard =
lastActivationRef.current?.isKeyboard === true ||
isFocusVisible(active);
const focusOptions: FocusOptions & {focusVisible?: boolean} = {
preventScroll: true,
...(isKeyboard ? null : {focusVisible: false}),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The ring here follows the stack's own modality record (isKeyboard from rememberActivation, and isFocusVisible). Core's focusForLastInput in utils/focusReturn.ts, over useInteractionModalityTracking, already does this move (Dialog calls it), and architecture:interaction-modality INV2 says shared modality decides visibility. Re-export it, isFocusLost and the tracker through ./internal/touch and call focusForLastInput(target, {preventScroll: true}).

Comment on lines +680 to +682
const entry = registry.get(model.key) ?? {};
entry.titleText = titleText.animator;
registry.set(model.key, entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This registry write is attach work. titleRefs below already writes the title element into the same entry, so entry.titleText = titleText.animator can move there, and this Effect keeps only the title's motion.

name: 'NavigationStack',
displayName: 'Navigation Stack',
description:
"The navigator. You own the path: pass the entries pushed on the root screen and update them in onPathChange. The stack renders the root screen and one screen per entry, the top one last, keeps covered screens and their scroll positions alive, and makes them inert. The bar shows the top screen's title, its trailing items and a round Back button with a chevron, named by the screen it returns to. A list row that pushes a screen stays selected under it and deselects as the screen pops, following a back swipe. A push moves focus to the new screen's title; a pop returns it to the control that pushed the screen.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"A list row that pushes a screen stays selected under it and deselects as the screen pops, following a back swipe." Nothing in the stack or in Core's List selects or deselects a row, and no test covers it. Delete the sentence, or implement and test it.

Comment on lines +904 to +913
/**
* A push/pop navigator: UINavigationController's transitions, back button
* and back swipes, on the compositor.
*/
/**
* Registers the navigation and spring tables the stack animates with in
* Core's motion registry, where a role plays its table value: touch motion
* has no theme surface. Once per page, before the first animation; nothing
* registers on import.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: these two JSDoc blocks now sit above isTouchNow's. The first belongs on NavigationStack below, which has none; the second describes the deleted registerStackMotion. Move the first and delete the second.

Lab's NavigationStack, which follows in its own pull request, reads the
screen-edge pan recognizer, the spring solver, the motion resolver,
UIKit's navigation table and its four navigation springs. It also reads
Core's focus helpers that follow the last input. Core's internal
`@astryxdesign/core/internal/touch` entry now names them: named
re-exports only, outside the public API. The entry test checks each
runtime name is the value Core imports by path. The touch-support bundle
test checks each is in the internal entry and not in the root entry.

The stack's back button falls back to one catalog key,
`@astryx.navigationStack.back`, added to Core's English catalog as the
Comments family's Lab keys are.
@vjeux vjeux changed the title feat(lab): NavigationStack, iOS's drill-down navigation with back swipes, large titles and search in the bar feat(lab): NavigationStack, push/pop navigation that moves like UINavigationController Nov 5, 2026
@vjeux
vjeux force-pushed the lab-navigation-stack branch from e29cdfc to 13a2693 Compare November 5, 2026 15:51
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 13a2693

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval of this stack waits on an engineering owner's decision for #9697, which also needs two fixes whatever that decision is.

#9696

Approved.

#9697

On hold: no current spec covers the NavigationStack, NavigationScreen and useNavigationStack API (including whether isInteractivePopEnabled stays a boolean), and the stack plays measured springs that are not motion roles, which AST-094 FR4 and FR45 do not allow today, so an engineering owner needs to approve the API and decide whether NavigationStack is a gesture component whose springs join the motion role table or a tap component whose push, pop and Back play the theme's curve tokens, by commenting /approve-spec <full head SHA> on this pull request or updating the governing spec on main (the author may do so as an engineering owner).

  • Miss: each screen's scroller measures its own overflow and, on a screen of plain text, leaves keyboard users only a stop Chromium adds by itself, with no visible focus indicator and no name (NavigationStack.tsx:279-299, :409-429), so put Core's useScrollableArea on the scroller and its content root (named by the screen's title) and remove the stack's own detector and outline: 'none'.
  • Newly live check: the story's row chevrons (NavigationStack.stories.tsx:50) do not mirror under right-to-left and point the same way as Back, so add xstyle={rtlStyles.mirror} as List.stories.tsx does.

Whole stack

The two pull requests fit together with no leftovers, and a pull request lands only when it and every pull request below it are approved, so #9697 waits on the owner decision and both fixes.

[Automated review]

Comment on lines +413 to +429
useLayoutEffect(() => {
const element = scrollerRef.current;
if (element == null) {
return undefined;
}
// Through core's shared observer, which also measures once right away.
const measure = () =>
setIsOverflowing(element.scrollHeight > element.clientHeight + 1);
const unobserveScroller = observeResize(element, measure);
const inner = element.lastElementChild;
const unobserveContent =
inner == null ? undefined : observeResize(inner, measure);
return () => {
unobserveScroller();
unobserveContent?.();
};
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: this Effect is a second overflow detector beside Core's useScrollableArea, which already contains overscroll only on the overflowing axis and gives the viewport FR12's named keyboard stop (spec:AST-025 IR1, FR12). On a screen of plain text the scroller is reachable only because Chromium makes it focusable, with no focus indicator (outline: 'none' above) and no name. Put the viewport and content props of useScrollableArea({axis: 'block', overscroll: 'contain', keyboardAccess: {owner: 'contentOrViewport', label: title}}) on the scroller and its content root, then delete this Effect and scrollerOverflowing.

<ListItem
key={row}
label={row}
endContent={<Icon icon="chevronRight" color="secondary" />}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Newly live check: this drill-in chevron does not mirror under right-to-left, so it points the same way as Back's mirrored chevron (DR4). Add xstyle={rtlStyles.mirror} from @astryxdesign/core/utils, as List.stories.tsx:535 does.

* - top card: x = p · W;
* - card under it: x = parallax · W · (1 − p), so from −0.3 W to 0;
* - dimming over the card under it: opacity 1 − p (black at 10 %);
* - bar contents: the top screen's at 1 − p, the one under it at p.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): each bar moves with its card, and under a finger the revealed bar holds still (NavigationStack.tsx:900-903). Suggested: "- bars: each moves with its screen's card. Under a finger, the revealed bar holds still."

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 74d7daa

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval of this stack waits on an engineering owner recording the #9697 decision again at its current head; no code changes are needed.

#9696

Approved.

#9697

On hold: an engineering owner approved the NavigationStack, NavigationScreen and useNavigationStack API and its measured springs at the previous head, but this head amends that commit, so the approval no longer applies. No current spec covers that API (including whether isInteractivePopEnabled stays a boolean or becomes a <thing>Behavior prop), and the stack plays springs that are not motion roles, which AST-094 FR4 and FR45 do not allow today. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, or by updating the governing spec on main; the author may do so as an engineering owner.

The screens' keyboard path and the story's right-to-left chevrons are fixed. Three comment suggestions are left inline as non-blocking.

Whole stack

The two pull requests fit together with no leftovers. A pull request lands only when it and every pull request below it are approved, so #9697 waits on that decision.

[Automated review]

Comment on lines +5 to +27
/**
* @file NavigationStackTouch.ts
* @input Core's gesture recognizers and UIKit gesture table, from its
* internal touch entry, and navigationMotion.ts and navigationTransition.ts
* for the swipe's values and release math
* @output useBackSwipes: UIKit's two back swipes, from the leading screen
* edge and from anywhere in the content (iOS 26), driving a stack through
* BackSwipeStack
* @position NavigationStack's back swipes, imported in one place
* (NavigationStack.tsx).
*
* The content swipe answers every pointer, a finger, a pen and a mouse drag
* alike. The edge swipe takes a finger only, as the screen-edge pan does by
* default. The stack tracks a swipe 1:1 and releases it by UIKit's rule. The
* swipe finishes when progress plus 0.35 s of the pan's release velocity
* passes half. A release moving back cancels.
*
* In a browser tab the screen's leading edge is the browser's own back
* swipe (iOS Safari keeps it). So the edge swipe only runs in a home-screen
* web app (`navigator.standalone`). In a tab the content swipe ignores a
* finger that starts in that edge band. Server render and first paint
* assume a tab.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this header is 23 lines, and its two paragraphs repeat NavigationStack's JSDoc (NavigationStack.tsx:661-706). Keep @input, @output and @position.

Comment on lines +415 to +417
// The screen's scroller contains its overscroll only while it overflows.
// While it overflows it is a keyboard stop named by the title. A forward
// Tab into it moves on to its first plain link or button.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): the first two sentences restate the hook's options. Suggested: "A forward Tab into the scroller moves on to its first plain link or button."

Comment on lines +1305 to +1306
// A screen's scroller is focusable while it overflows, but it is not a
// control: a press on its plain content returns focus to the title.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): "a press on its plain content returns focus to the title" reads as if the press moves focus. Suggested: "The scroller is focusable while it overflows, but it is not a control. After a push from its plain content, the pop focuses the title."

@vjeux
vjeux force-pushed the lab-navigation-stack branch from 74d7daa to a3726e1 Compare November 5, 2026 17:49
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec a3726e1

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval of this stack waits on an engineering owner recording the #9697 decision at its current head, and #9697 needs one code change either way.

#9696

Approved.

#9697

On hold, with one required change:

  • Miss: the large-title scroll listener in NavigationStack.tsx (lines 382–403) is attached in a layout Effect, but the title's existing callback ref largeTitleCallback can attach it and return its cleanup, so move it there and delete the Effect and largeTitleRef.

An engineering owner approved the NavigationStack, NavigationScreen and useNavigationStack API and its measured springs at the previous head, but this head amends that commit with comment edits only, so the approval no longer applies; no current spec covers that API (including whether isInteractivePopEnabled stays a boolean or becomes a <thing>Behavior prop), and the springs are not the motion roles AST-094 FR4 and FR45 require, so a theme's tempo does not reach them. An engineering owner records the decision by commenting /approve-spec <full head SHA> on the head that carries the fix, or by updating the governing spec on main; the author may do so as an engineering owner.

Whole stack

The two pull requests fit together with no leftovers and merge cleanly onto main; a pull request lands only when it and every pull request below it are approved.

[Automated review]

Comment on lines +382 to +403
useLayoutEffect(() => {
const title = largeTitleRef.current;
const scroller = title?.parentElement ?? null;
if (!isLarge || title == null || scroller == null) {
return undefined;
}
// The screen scrolls under the bar: the title has gone once its bottom
// passes the bar's. A title with no layout has not scrolled away.
const read = () =>
onLargeTitleCollapse(
model.key,
title.offsetHeight > 0 &&
scroller.scrollTop >=
title.offsetTop + title.offsetHeight - BAR_HEIGHT,
);
read();
scroller.addEventListener('scroll', read, {passive: true});
return () => {
scroller.removeEventListener('scroll', read);
onLargeTitleCollapse(model.key, false);
};
}, [isLarge, model.key, onLargeTitleCollapse]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: this Effect attaches the scroller's scroll listener once the large title's <h1> exists (it reads largeTitleRef.current, which largeTitleCallback sets). That is work caused by the title attaching, so the title's callback ref can own it. Attach the listener and run the first read in largeTitleCallback, return a cleanup that removes it and calls onLargeTitleCollapse(model.key, false) (as containerCallback returns one), and delete this Effect and largeTitleRef.

…igationController

NavigationStack renders the app's path, one NavigationScreen per entry,
with UIKit's push spring, the covered screen's parallax and dimming, a
bar per screen that rides with it, and back swipes from the leading
edge and from anywhere in the content, tracked 1:1 and finished by
UIKit's release rule. Large titles hand over to the bar's title as the
list scrolls. The content swipe answers every pointer, and a moved focus
shows its ring by the last input, through Core's shared modality.

The values only this stack reads live beside it in navigationMotion.ts,
each with its measurement; Core's UIKit tables are read through its
internal touch entry. astryx discover lists the two components, and the
route lock gains their pages.
@vjeux
vjeux force-pushed the lab-navigation-stack branch from a3726e1 to 2317228 Compare November 5, 2026 18:51
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 2317228

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, because it is a draft from a fork; once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

#9696

Approved.

#9697

Approved: an engineering owner recorded the decision on the NavigationStack API, the isInteractivePopEnabled boolean and the measured springs at this head, and the large-title scroll listener now lives in the title's callback ref, which resolves the last review's required change (one optional comment-wording suggestion is inline).

Whole stack

The two pull requests fit together and merge cleanly onto main, and a pull request lands only when it and every pull request below it are approved.

[Automated review]

Comment on lines +375 to +379
// The large title has collapsed once it has scrolled entirely under the
// bar: the bar's own title takes over. The title reads the card's scroll
// position from the moment it attaches. A card hidden two levels down
// scrolls no more and keeps its last value. A title that detaches drops its
// collapse. One that attaches reads the scroll position again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: two sentences here restate what read() and addEventListener already show. A shorter comment keeps only the reasons:

// The large title has collapsed once it has scrolled entirely under the
// bar; the bar's title then takes over. A card hidden two levels down
// scrolls no more and keeps its last value. A title that detaches drops its
// collapse, so a screen pushed again starts with its large title.

@vjeux
vjeux marked this pull request as ready for review November 5, 2026 19:14
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 5, 2026
@cixzhang
cixzhang marked this pull request as draft November 5, 2026 19:22
@cixzhang
cixzhang marked this pull request as ready for review November 5, 2026 19:22
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 2c3f13d

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved, and CI passes on this head.

#9696

Approved. Merging main into it leaves its change as it was.

#9697

Approved: an engineering owner recorded the decision on the NavigationStack API, the isInteractivePopEnabled boolean and the measured springs at this head, and merging main and the updated #9696 leaves the change as it was. The earlier optional comment-wording suggestion on the large-title callback still applies.

Whole stack

The two pull requests fit together and are up to date with main. A pull request lands only when it and every pull request below it are approved.

[Automated review]

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 30a67bb

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI is still running on this head.

#9696

Approved, and already merged.

#9697

Approved: an engineering owner recorded the decision on the NavigationStack, NavigationScreen and useNavigationStack API, the isInteractivePopEnabled boolean and the measured springs at this head, and merging main leaves the change as it was. One optional suggestion remains: the comment above largeTitleCallback can keep only its reasons (see the inline comment).

Whole stack

The stack passes as a whole. A pull request in the stack lands only when it and every pull request below it are approved; #9696 is already in main.

[Automated review]

Comment on lines +375 to +379
// The large title has collapsed once it has scrolled entirely under the
// bar: the bar's own title takes over. The title reads the card's scroll
// position from the moment it attaches. A card hidden two levels down
// scrolls no more and keeps its last value. A title that detaches drops its
// collapse. One that attaches reads the scroll position again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional (non-blocking): two sentences here restate what read() and addEventListener already show. A shorter comment keeps only the reasons:

// The large title has collapsed once it has scrolled entirely under the
// bar; the bar's title then takes over. A card hidden two levels down
// scrolls no more and keeps its last value. A title that detaches drops
// its collapse, so a screen pushed again starts with its large title.

@cixzhang
cixzhang merged commit 4e4cf84 into meta:main Nov 5, 2026
62 checks passed
@vjeux
vjeux deleted the lab-navigation-stack branch November 5, 2026 23:52

This branch was successfully deployed

1 active deployment
Preview — 30a67bb0 Deployed Nov 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants