Navigation (nav, nav_stack)

Navigation state lives in signals. Selecting a tab or pressing Back updates the signal; changing the signal from app code updates the native container.

  • nav selects one destination, such as a tab or sidebar item. Its signal holds the active key.
  • nav_stack holds a path of destinations in a Signal<Vec<_>>. Pushing or removing a key opens or closes a level of the native stack.

Keys implement Route. Start with strings, or use an app-defined enum when you want the compiler to check destinations and their associated data. The string adapter (navigate, nav_back, current_route) lets deep links and dayscript address either form.

let section = Signal::new("home".to_string());
nav(section)                       // adaptive by default; .style() pins a shape
    .title(tr("app-title"))
    .header(sidebar_header)             // optional piece above the list
    .item("home",     tr("home"),     home_page)
    .item("settings", tr("settings"), settings_page)

Styles

NavStyleWhat it draws
Automatic (default)The platform’s answer at this width — see the ladder below.
TabsA tab bar at every size, however wide the window gets.
SidebarA NavigationSplitView: both panes where there is room, collapsing to a list that pushes the detail where there is not.

Automatic walks one ladder, and only its bottom rung is platform-specific:

window widthpresentation
Expanded and wider (≥ 840pt)Split — sidebar beside detail
Medium (600–839pt)Rail — a narrow strip beside the content
Compact (< 600pt)Tabs where a tab bar is the idiom, Stack where it is not

That last row is Cap::NavTabsAdaptive, and it describes the platform rather than its widget set. Every desktop can draw a tab bar (an app is free to pin NavStyle::Tabs), and none of them grows one when its window is dragged narrow: a narrow Mail.app hides its sidebar and pushes. The phones and the web are the opposite, and they are the surfaces whose window size ranges from a phone to a desktop. So Automatic and Sidebar behave identically on a desktop, and differ on iOS, Android, HarmonyOS and the web.

Rail is not gated by that capability: a narrow sidebar is an ordinary desktop shape, and on Windows it is what NavigationView does at that width. A backend with no rail control rounds it to a neighbor of its own choosing: macOS has nothing that is a vertical strip of icon-only destinations, so appkit draws an ordinary sidebar there.

Page residency

A presentation whose rows are the chrome (Tabs, Rail) keeps every visited page resident: switching is a NavPatch::Select, nothing is torn down, and each tab keeps its scroll offset and focused field, which is what every native tab container does. A Split or Stack presentation keeps only the shown page and switches by pop-then-push.

Residency follows the presentation rather than the host. Making every nav page resident would keep effects running for pages nobody is looking at; making none resident would rebuild a tab’s content on every tap. Splitting it this way means a morph only ever disposes pages that are not on screen, or lazily builds ones that were not built yet; the visible page is never rebuilt, which is the invariant a morph has to keep. Pages build on first visit.

A chrome presentation is the exception, because its bar needs an item per destination: every page is built up front, in row order, before the initial selection is applied. NavPatch::Select names a page by attach order while the chrome draws the rows, so page i has to be row i; that pairing is all a suite has. Building the selected destination first (the selection a .restore(key) came back with, say) would attach it at index 0 and leave the bar highlighting the first row while another destination’s page is the one on screen.

The active key is a Signal<String>, two-way exactly like Picker/Toggle: set it and the UI switches; the user picking natively writes it back (origin-tagged, no echo).

PresentationNative container
Splita NavigationSplitView: macOS NSSplitViewController with a sidebar NSSplitViewItem (system material, source-list NSOutlineView, full-height under the titlebar) + detail; GTK two nested GtkPaneds (sidebar
Stackone page at a time, back-navigable: UINavigationController, AdwNavigationView, the Android back stack, or a desktop back-header above the pages.
Tabsthe rows drawn as a tab bar: UITabBarController / Material NavigationBarView / NavigationView.PaneDisplayMode = Top / an NSTabView on macOS / an AdwViewSwitcher on GTK / a QTabWidget on Qt / a composed bar on web-dom.
Railthe rows as a narrow strip: Material NavigationRailView, PaneDisplayMode = LeftCompact, an ArkUI vertical Tabs; roundable where a toolkit has none.

nav is one primitive, a selection-bound switcher, and a presentation is chrome plus page lifetime rather than a different host. That is why a window crossing a breakpoint re-presents (NavPatch::Presentation) rather than rebuilding: the pages it already has are re-homed, so nothing loses a scroll offset, a focused field, or an animation in flight.

What each backend draws. The rows become the platform’s destination chrome, and where a platform has no such widget the presentation rounds to the neighbor it does have (Rail lands on an ordinary sidebar on macOS and Qt). A backend that answers Cap::NavTabs = Unsupported sends Automatic through the sidebar ladder instead, which is what every backend did before adaptive navigation existed.

BackendTabs presentationAdaptive
macos-appkitNSTabView (top tabs on a bezel), the detail pages as its items; labels from the host’s rowsno; a Mac narrows to a stack
ios-uikitUITabBarController in .tabSidebar mode, driven by UITab on iOS 18+ and by viewControllers below it (2026-09) — see belowyes
android-mdcnavigation suite: BottomNavigationViewNavigationRailView → permanent NavigationView drawer, by width. A NavStyle::Sidebar pane is a NavigationView too (2026-09) — see belowyes
linux-gtkAdwViewSwitcher over an AdwViewStack of resident pages, each in a filling DayCellno
linux-qtQTabWidget — Qt’s own one-of-N containerno
web-doma composed tab bar (.day-nav.tabs)yes
harmony-arkuia composed bottom bar over resident pages; ArkUI’s native node set has no tab container, so it is built from Day’s primitivesyes
windows-xamlthe same NavigationView with PaneDisplayMode = Top; Rail is LeftCompact, a real railno

Only the phones and the web grow a tab bar as the window narrows (Cap::NavTabsAdaptive); a desktop may pin one with NavStyle::Tabs, but narrowing hides its sidebar and pushes.

Keyboard

A sidebar walks with the arrow keys: ↑/↓ move to the next destination, Home and End to the first and last, and the selection reports exactly what a click on that row reports. The desktops get this from the widget the style maps to (an NSOutlineView source list, a NavigationView) with nothing of Day’s in front of it: keys go to whatever has focus and no route sits above them (docs/menus.md).

web-dom has no such widget, so the backend builds the behavior: .day-navmenu carries the tab stop and role="listbox", its rows carry role="option" and aria-selected, and the shim moves the selection by reporting the new index the way the click handler does. It also holds the focus across the page swap its own key press caused: a detail pane can contain an element the browser focuses as soon as it appears (a <video>, a web view’s iframe), and without that the sidebar would answer one arrow and then go quiet.

Immersive items (.immersive())

.item(…).immersive() marks the last-added destination as an immersive-chrome page: on backends with an immersive nav mode (day-android’s edge-to-edge opt-in today) its pushed page keeps the floating transparent bar over full-bleed content, while unmarked pages and the root get the standard opaque bar. Every other backend ignores the flag. Pair it with day::safe_area() (docs/layout): the immersive page paints its background unpadded and pads its content by the reported insets.

let path = Signal::new(Vec::<String>::new());
nav_stack(path, home_view)
    .destination(|key| detail_view(key))
// push:  path.update(|p| p.push("item-42".into()));
// pop:   path.update(|p| { p.pop(); });
// the native back button writes the pop back into `path` (origin-tagged).

Day reconciles the native stack to path (keep the common prefix, pop the rest, push the new suffix; the same diff NavigationStack/React-Navigation do). The native containers: UINavigationController (iOS), AdwNavigationView (GTK), Android back-stack, and a top-page-only presentation on macOS NSSplitView / Qt QSplitter in stack mode. The path is data, so deep-linking is “parse the URL into a path and set it,” and the stack is unit-testable without the framework.

Commands on the chrome

There is no bar_action. A command that belongs on the chrome rather than in the page is a TOOLBAR ITEM declared on the piece it acts on (docs/toolbars.md), and which bar it rides follows from that:

nav(section)
    .toolbar(toolbar_button("add", tr("add")).icon(Symbol::Add).action(add_item))  // the list's
    .destination(|k| page(k).toolbar(share_button()))                              // the page's

Nav::toolbar and NavStack::toolbar put items on that host’s own chrome — the sidebar column where the presentation has one, the root list when it has collapsed. Items declared on a destination page ride the detail. Both leave the bar when what they act on leaves the screen, so neither needs a scope to be spelled out.

The affordance that shows and hides the sidebar is the toolkit’s, not the app’s: a nav(Sidebar) supplies it, and .sidebar_toggle(false) suppresses it.

Data-driven items (nav().items)

nav items can come from a signal, so a sidebar or tab set grows and shrinks with your data (a rooms list, open documents). Static .items and dynamic .items blocks mix; pair .items with .destination to build the page for a data-driven key (like nav_stack).

let tabs = Signal::new(vec!["general".to_string(), "random".to_string()]);
nav(current)
    .style(NavStyle::Tabs)
    .items(move || tabs.get(), |k: &String| item(k.clone(), k.clone()))
    .destination(|k: &String| room_page(k))

The row set re-derives whenever a block’s signal changes: rows are added/removed on the native widget, and if the selected key disappears the selection resets (to None for an Option key). The same effect resolves every row title tracked, so a runtime set_locale retitles the native rows in place, static .items included. item(key, title).icon(name) is the row spec; .icon_tint(color) recolors the row’s glyph (docs/vectors.md), .context_menu(vec![…]) attaches a per-row context menu (docs/menus.md), and .immersive() on it marks that row’s pushed page immersive-chrome, same as the static form above.

.badge_icon(name) puts a glyph at the row’s trailing edge, with .badge_tint(color) for a color that carries meaning (a starred page’s yellow star). It shares the trailing slot with the text .badge(…) and is drawn after it, so a row can show a count and a status at once:

item(section, title)
    .icon(res::vectors::nav_grid)
    .badge_icon(res::vectors::star)
    .badge_tint(AMBER)

Both are template glyphs, so an untinted one takes the backend’s neutral row color and follows the theme; a tinted one keeps its color because the color carries meaning. Every backend draws it: the trailing slot of the AppKit cell, the GTK row box, a QStyledItemDelegate on Qt (a QListWidgetItem has only the leading icon slot), the end compound drawable on Android, the composed NavigationViewItem.Content on XAML, between label and chevron on ArkUI, a masked element on web-dom, and a trailing UIImageView on UIKit, which is also where the nav badge slot first appeared on that backend. A nav host used as a self-contained widget inside a page that already routes should call .local() so it does not add a segment to current_route or intercept navigate.

Section headers. nav(…).section(title) opens a header before the NEXT item added — a static .item or the first row of the next .items block — and item(key, title).section(title) does the same from inside a data-driven mapper, which is how a derived list keeps its groups under a search filter: attach the header to the first surviving row of each group, and a group whose rows are all filtered out disappears with them. Headers are a grouping hint: a backend without grouped rows (a tab strip, a plain phone list) shows one flat list, so a header is never the only way a row is reachable, and a row’s icon tint is the grouping signal that survives there. AppKit draws them as source-list group rows (small, bold, secondary, pinned as their group scrolls under them); the outline keys its rows by index, not by title, so a header may share its text with an item (“Controls” over Controls) without the two collapsing into one row. UIKit and Android draw them from their list’s own header slot, described below. GTK draws them in its list box’s header slot. Qt, whose QListWidget has no header slot, draws each as a small bold dimmed item of its own that takes no click, hover or selection, and web-dom as a heading element between the rows. Windows adds a NavigationViewItemHeader to the pane’s items (a disabled item in the plain ListView sidebar), collapsing them while the pane is a tab strip or an icon rail, and HarmonyOS a secondary-colored title above the group’s first row. These four translate indices at the list, so a row keeps the index Day gave it however many headings sit above it.

nav(section)
    .section(res::str::smart_feeds())
    .item_icon("today", res::str::today(), res::images::today, today_page)
    .section(res::str::feeds())
    .items(move || st.feeds.get(), |f| item(f.id, f.name))

A data-driven item is a label + optional icon: the native sidebar/tab row. It is not an arbitrary rich row (an avatar + preview + badge); a master list that needs those is a list, and combining a rich master list with native master-detail push is a separate, not-yet-built feature.

Backend support (2026-07): dynamic add/remove/reselect renders on macos-appkit, linux-gtk, linux-qt, and web-dom (and their host variants), verified in the showcase walkthrough. The ios-uikit sidebar and tab selection are wired; dynamic rendering on the UIKit/Android/ArkUI/XAML tab widgets is in progress (those backends ignore the item-set patch until then, so the initial set still shows). The item logic is backend-independent and covered by mock_e2e::nav_data_driven_items_reconcile.

The iOS tabs host is driven by UITab

A Tabs presentation on ios-uikit is a UITabBarController in .tabSidebar mode, and since 2026-09 its destinations are UITab objects rather than a viewControllers array of UITabBarItems. Apple’s guidance since iOS 18 is that adopting UITab is what gives a tab bar its automatic adaptivity — the tab bar and the sidebar become two renderings of ONE list of tabs, which is Day’s model exactly, and it is what .tabSidebar is built to consume. The array still works, but the controller has to infer everything from view controllers, and the sidebar-side affordances have no tab to hang on.

Five things this backend learned the hard way:

  • A UITab is a model object with an identity, not a per-render descriptor. Its provider hands UIKit a view controller and UIKit then owns that controller as the tab’s. This host re-syncs on every page insert and every rows change; minting fresh tabs each time left two tabs claiming one controller, and UIKit asserts in -[UITab viewController] as soon as it resolves the second. A tab is created once per page and only its title and glyph are re-applied.
  • The identifier is the PAGE, not the position. insert can put a page in the middle, and an identifier is fixed at construction. Day’s index is the tab’s position in the host’s tabs.
  • A tab’s glyph needs a SIZE, and the ASSET is where it comes from. A tab bar draws UITab.image at the image’s own size: UIKit scales an SF Symbol to the bar’s metrics, but a catalog image has no metrics to scale by. A Material Symbols export carries width="48" height="48" and drew at 48pt, twice the height of an iOS tab icon and overlapping its own label. The backend hands the image over untouched and the glyph is authored at icon size instead (docs/vectors.md): thumbnailing here downsamples the catalog’s bitmap rendition and throws away the vector representation that put it there. Day-Showcase’s Grids tabs were the last 48pt holdouts (2026-09-05).
  • A tab switch waits for the visible stack’s transition. Switching tabs hides the outgoing tab’s navigation controller, and a pop still animating there never finishes once its view has left the window: the transition coordinator stays alive, the popped page stays on the stack, and every later screenshot reports the UI still settling (Day-Trader’s back-then-switch on iOS 27, one walkthrough variant in eight). The selection moves in Day’s tree at once; the native switch is deferred a few runloop turns until no on-screen stack is transitioning.
  • didSelectTab:previousTab: fires for programmatic selection too, where the old didSelectViewController: fired only for user taps. It therefore needs the same origin guard as every other two-way control here — without it, installing the tabs reported a selection the user never made and the app’s bound signal followed it.

Below iOS 18 the same host runs on viewControllers. UITab and setTabs: are iOS 18’s, so the sync asks the CONTROLLER whether it answers setTabs: — the same shape the .tabSidebar setMode: probe takes — and where it does not, each page carries its title and glyph on the UITabBarItem UIKit makes for it, the roster goes in through setViewControllers:, Select addresses a page by INDEX, and the delegate’s didSelectViewController: reports a tap. An iOS 17 device therefore gets the plain tab bar an iOS 17 app always had rather than a crash. The didSelectTab:previousTab: method is declared OUTSIDE the delegate’s protocol block for the same reason: a protocol block asks the runtime for the nav host’s type encoding, and asking for an iOS 18 nav host on an older runtime fails the class registration itself, which took down the whole scene as soon as a tabs host was realized.

UITabGroup is deliberately NOT used. Day’s NavMenuProps::sections are headings — “a section header introducing the row at the same index” — whereas a UITabGroup is a destination that CONTAINS others: in a sidebar it draws as a heading, but in the compact tab bar the whole group collapses to a single tab. Mapping flat sections onto it would turn N tab-bar destinations into G. It becomes the right realization if Day’s nav host ever grows real hierarchy.

The iOS sidebar is a collection-view list

nav(NavStyle::Sidebar) realizes on ios-uikit as a UICollectionView laid out by UICollectionLayoutListConfiguration with the .sidebar appearance, in the primary column of a UISplitViewController. That appearance publishes the listEnvironment trait the cells’ adaptive UIListContentConfiguration::cellConfiguration reads, and it is what gives the Settings sidebar its look: an inset rounded selection pill with a tinted label, and rows with no separators. A UITableView draws its selection edge to edge whatever background configuration its cells carry, so the rounded shape is the list appearance’s to give.

The list runs under the bars. A page whose content is one scroll view — this list, a scroll-rooted detail, a tree — fills the page’s full bounds, and UIKit’s inset adjustment starts the content below the navigation bar and lets it pass under the translucent bar and the bottom search field as it scrolls, as Settings and Mail do. A page whose content is a navigation host fills its bounds too, because the host passes the bars on to its own pages — which is what lets a tab’s list reach under the tab bar. The window root follows the same rule: a window whose root is a nav, split or tab host is not padded by the status bar and home indicator, so the bars reach the screen’s edges and the list runs behind them, the way every UIKit app’s does. A page holding anything else (a heading over a list, a form, a canvas) is laid out inside the safe area instead, since it has no scroll insets to absorb a bar with.

Section headings ride the same list configuration. setHeaderMode(.supplementary) turns them on, and a heading is a supplementary view carrying the adaptive UIListContentConfiguration::headerConfiguration, so its type, color and insets come from the same sidebar environment the rows do. A list that declares no heading sets .none instead, so a flat sidebar reserves no band where a heading would go, and flipping heading-ness re-installs the layout, header mode being a property of the layout rather than of the data.

The grouping stays private to the list. NavMenuProps::sections is parallel to the rows (a heading introduces the row at its own index), the backend folds that into runs of (heading, first, len), and row_of/path_of translate between a flat row index and an NSIndexPath. Everything above the backend — SelectionChanged, NavMenuProps::selected, the per-row tint and badge arrays — keeps speaking in flat row indices.

The Android sidebar is a NavigationView

nav(NavStyle::Sidebar) realizes on android-mdc as a Material NavigationView, the class Android means for a standing navigation column. It was a LinearLayout of TextView rows in a ScrollView until 2026-09, with the 48dp height, the padding, the ripple and the 24dp leading glyph measured out by hand — a comment called that “the Material nav-drawer idiom”, which it was imitating. The real one brings the M3 row metrics, the ripple, subheaders between NavMenuProps::sections (the flat list dropped them, so eight groups arrived as twenty bare rows), a RecyclerView so a long sidebar recycles, and the fully-rounded ACTIVE INDICATOR behind the checked row — which is why NavMenuPatch::Selected is no longer a no-op there.

Two consequences worth knowing before touching it:

  • A row’s menu id is not its index. NavigationMenuItemView copies its item’s id onto ITSELF, which puts menu ids and view ids in the one namespace findViewById searches — and Day’s own fragment containers take View.generateViewId(), which counts up from 1. Rows keyed by bare index collided immediately: containerId was 1, the second row became a view with id 1, it sits earlier in the traversal than the detail container, and FragmentTransaction.replace built every detail page INSIDE that sidebar row. Ids start at ROW_ID_BASE (0x01000000) for rows and SECTION_ID_BASE for headings, above generateViewId’s ceiling and below aapt’s floor.
  • Rows recycle, so a per-row context menu (docs/menus.md) cannot be attached once. It goes on from OnChildAttachStateChangeListener, reading the row a cell is CURRENTLY bound to from its own getItemData() rather than from its adapter position, which headings and dividers shift.

How iOS tells a user’s back from its own

There is no mirror of the stack on iOS (2026-09-10; before this, day-uikit kept a copy of the intended stack, re-applied it wholesale on every change, inferred the user’s pops from didShow counts through a settle loop, and absorbed Day’s answering pops with a counter — and both back bugs of that month lived in the bookkeeping). UIKit’s viewControllers is the stack. Two rules:

  • Day’s changes are one delta, computed from what UIKit reports. The insert duty that hands a page’s controller to the host pushes it (push_page: the active controller’s current pages plus this one, in one setViewControllers:animated:), and the remove duty that takes it back pops it (pop_page: the current pages minus this one). A collapsed triple column is driven through UIKit’s column APIs instead (showColumn, popToViewController:), since a wholesale set destroys the bookkeeping its merge keeps. A merge on iOS 26 nests the secondary controller onto the primary as one entry, so day_pages flattens a nested controller into its pages wherever a stack is read. A transition UIKit cancels (a window capture mid-flight does that on iOS 26+) is applied once more from its completion.

  • The user’s back is observed where it starts. DayNavController overrides popViewControllerAnimated:, popToViewController:animated: and popToRootViewControllerAnimated:: UIKit routes the back button there once shouldPopItem: agrees, the swipe there under an interactive transition, and the history menu to the popTo… pair. Day never calls those for its own changes, and its two pops on a collapsed triple column announce themselves (with_day_pop), so a call that reaches super is the user’s. observe_user_pop waits for that pop’s own transition — a swipe let go early is cancelled, and Day hears nothing — and confirm_user_pop emits one NavBack { already_popped: true } per Day page that left. Day answers each by popping its model and removing the page, and pop_page finds the page already gone: that no-op is the whole protocol between the two, and nothing is counted.

  • A pop during a size-class transition is UIKit’s own. A split view merges its columns when it collapses and takes them apart again when it expands, and the un-merge is a real popViewControllerAnimated: on the primary’s controller — indistinguishable at the call site from the user’s back. DayRootVC’s willTransitionToTraitCollection:withTransitionCoordinator: raises a flag for the length of the transition (released by the coordinator’s completion, with a two-second floor under a completion that never arrives), and note_pop reports nothing while it is up. Read as a back instead, the pop deselects the row the user is on, and the widened split then obeys its own “never show an empty detail” rule and opens the FIRST row: resizing an iPad window across the breakpoint threw away the page you were reading and replaced it with the top of the list (2026-09-19). The window root is early enough to see it, because UIKit restructures the columns before it reports the expansion. The cost is that a back tapped during the resize animation itself is ignored, which is the trade this is worth.

NavPatch::ListInStack is not consulted here: UIKit’s collapse folds the content list onto the merged stack by itself (the list is the supplementary column’s root), and NavPatch::ListVisible shows or pops it there. NavPatch::Presentation never reaches a toolkit whose container re-presents (the pieces layer gates it on Cap::NavRepresent).

The guard has the same two doors: shouldPopItem: vetoes the button, and the controller is the swipe recognizer’s delegate, whose gestureRecognizerShouldBegin: vetoes the swipe; both emit NavBack { already_popped: false } and let Day decide. The gesture is never disabled, so a guard that proceeds pops through Day’s rail and the next swipe works with nothing to re-enable.

nav_back: in a walkthrough drives Day’s rail and reaches none of this; nav_back: { native: true } presses the bar’s button (Toolkit::native_backDayNavController::press_back), which asks shouldPopItem: through its selector and pops through the override, so a script covers the code a tap runs. Trace it with --env DAY_DIAG_NAV=1: user pop levels=1, exec SET native=N -> target=M.

Back interception (on_back)

NavStack::on_back intercepts the user’s back affordance (a native gesture/button, or nav_back()) to run a policy before the pop. It does not run for a programmatic path.set (a write is not a back), matching Jetpack Compose’s BackHandler.

let dirty = Signal::new(false);
nav_stack(path, home_view)
    .destination(|k| detail_view(k))
    .on_back(move |req| {
        if dirty.get() {
            // confirm asynchronously, then perform the deferred pop on "yes"
            day::task(async move {
                if confirm("Discard changes?").await {
                    dirty.set(false);
                    req.proceed();          // performs the pop the guard consumed
                }
            });
            BackResponse::Handled           // consume this back
        } else {
            BackResponse::Proceed           // normal pop
        }
    });

The guard returns Proceed (pop now) or Handled (consume; the pop does not happen). A Handled guard may hold the BackRequest and call proceed() later: the unsaved-changes → confirm → leave flow. proceed() performs exactly the pop Proceed would have.

While a guard is armed above the root, Day tells the toolkit to stop auto-popping on a native gesture and route the back through Day instead (NavPatch::GuardTop). What that means per backend:

backendnative-gesture arming while guarded
iOS (UIKit)swipe disabled; the back button is vetoed via a UINavigationController subclass’s navigationBar:shouldPopItem:, which emits the back to Day
Androida higher-priority OnBackPressedCallback routes the system/gesture back and the toolbar up-arrow to Day (the predictive-back preview is unavailable while armed)
HarmonyOS (ArkUI)the top NavDestination’s onBackPressed consumes the native back and defers to Day
GTKthe top AdwNavigationPage sets can-pop = false (swipe/Escape disabled; the app drives back through its own control, which is guarded)
macOS / Qt / XAML / webno-op — the back button already routes through Day, so the guard runs with no native arming needed

The guard’s logic (intercept, defer, proceed, never-on-programmatic-write) is identical everywhere and covered by mock_e2e::nav_stack_on_back_guard_intercepts_and_defers.

A nav host arms the same patch for a reason of its own: while a detail is open beside a composed content list, so the platform’s back closes the detail instead of popping the page that holds both (see detail_visible below). The host counts the requests to arm it, so a guarded stack merged into the host and an open detail never disarm each other.

Each mounted surface registers a small adapter over its own signal, so a string route can address the whole tree. The grammar:

route    = segment *( "/" segment ) [ "?" query ]     e.g.  mail/inbox/msg-42?hint=shared
segment  = a nav host/tabs item key, or a stack destination key
query    = name "=" value *( "&" name "=" value )     (params for the destination builders)

Reserved characters inside a segment or param value (/ ? & = %) are percent-encoded; day_core::nav::{parse_route, encode_route} do this for you. Two addressing modes:

  • A single key is relative: navigate("inbox") reaches the innermost surface first and falls through outward. For a nav/tabs it sets the active key; a nav_stack claims only "" (pop to root), so sibling keys fall through to the enclosing surface. This is what a button deep inside a page wants: address the nearest thing that knows the key.
  • A /-separated path is absolute: navigate("mail/inbox/msg-42") anchors at the outermost surface that knows the first segment, resets every surface inside the anchor to its root, then feeds the remaining segments inward. Segments for surfaces that only mount as the outer switch takes effect are queued and consumed as those surfaces register. One string reaches a stack three levels deep on a cold start. A stack consumes absolute segments unconditionally (its destinations are open-ended); the explicit path is the stack’s state (set-semantics: navigating mail/inbox while mail/inbox/msg-42 shows pops the detail).

Params ride the query string: route_param("hint") / route_params() inside a destination builder return the values of the navigation being applied. They describe the navigation in flight; a push you perform by writing the path signal directly carries its data in your own state instead.

  • nav_back(): pops the innermost surface, falling through when it is already at its root. On a sidebar with a content list, an open detail is the innermost layer, whether it is pushed over the list in a collapsed stack or shown beside it in a split: the call closes it (detail_visible := false), the same place the native back lands, and only a second call leaves the section.
  • current_route(): the full path, every mounted surface’s contribution from outermost to innermost ("mail/inbox/msg-42"). It round-trips through navigate, so persisting the whole route by hand is two lines: save current_route() on the way out (day-part-prefs works), navigate(&saved) after the first mount on the way back. For a single surface, .restore (below) does the same without the plumbing. dayscript’s assert_route compares against the same full path.
  • Startup deep links (DAY_DEEPLINK) and warm links (RouteRequested) route the same way. On hosts with no process environment the platform entry records the launch route with day_core::set_launch_deeplink instead; web-dom seeds it from the page’s URL hash (docs/web.md), so …/#controls opens on that section. The OS side (scheme registration, per-platform intake, and testing) is docs/deep-links.md.
  • The URL stays live both ways on web-dom: day-core reports every route change to the backend (Toolkit::set_route: the hash updates as you navigate, one history entry per step), and a hash change the app didn’t write (browser back/forward, a hand-edited URL) arrives as Event::RouteRequested and navigates. Other backends inherit the no-op default.

Because each surface owns its own signal, a nav(Tabs) or a nav_stack nests inside a nav(Sidebar) section with no extra wiring. There is no global navigation controller to arbitrate, only this string adapter for addressing.

Sibling one-of-N surfaces need .local(). Every routed surface contributes to the full route, so two nav/tabs at the same level (a filter tab strip beside a main tab bar) both feed current_route(): you get section/mainKey/filterKey, and navigate("filterKey") is ambiguous. Mark all but the primary one .local(); it then drives its own signal without touching the route. A nav host nested one level deeper (a Tabs inside a Sidebar section) is the opposite case and should stay routed; that cascade is what nesting is for. In debug builds, two routed one-of-N surfaces at the same level log a warning naming this fix.

Ordering caveat: relative dispatch and the full route walk the registry in mount order, which equals nesting depth for a single active chain. Two sibling surfaces mounted at once (two independent stacks visible in one window) are ordered by mount time, not focus. Prefer absolute routes (or drive the signals directly) in such layouts.

day lint cross-checks literal navigate("…") calls and dayscript navigate:/assert_route: routes against the declared keys in your sources (.item("key", …) call sites and routes! { … => "key" } blocks): a route whose first segment nothing declares is reported (day::lint::unknown-route) rather than failing silently at runtime.

Restoring state across launches (.restore)

When you want a surface to reopen where the user left it, mark it with .restore(key) instead of wiring current_route() by hand:

nav(section).restore("nav.section")   // reopens on the last-viewed section
nav_stack(path, home).restore("mail.path")     // rebuilds the pushed path

The selected key (or the stack’s /-joined path) is saved under key on every change and read back at build. A pending launch deep link wins: a DAY_DEEPLINK (or a set_launch_deeplink hint) routes one turn after mount, so .restore steps aside and the link decides where the app opens. A saved value that no longer fits (a nav host key whose item is gone, a stack segment that no longer parses) is ignored rather than restoring a broken state.

.restore reads and writes through a store the app installs once at startup; nothing persists until you install one:

fn main() {
    day::prefs::install_nav_store();   // before the UI mounts
    // …
}

The prefs store is disk-backed, so restore also survives an Android process death: the OS reclaims a backgrounded app and rebuilds it on return, and the value is still on disk. With no store installed, .restore is a silent no-op, so the same code compiles and runs on a target where you don’t want persistence: the Showcase installs the store on web only, where a reload is routine, and starts fresh on native. To back .restore with your own storage, implement day_core::NavStore and hand it to day_core::set_nav_store.

Typed routes

Route keys are data, and strings are their wire format. The Route trait carries the two-way mapping:

pub trait Route: Clone + PartialEq + 'static {
    fn key(&self) -> String;                  // typed value → path segment
    fn from_key(key: &str) -> Option<Self>;   // path segment → typed value
    fn title(&self) -> String { self.key() }  // native nav-bar title (defaults to the key)
}

title() is the label a stack shows in the native navigation bar for a pushed page. It defaults to the wire key, so override it to display a name when the key is not presentable (e.g. a route that carries only an id can look the name up from your data).

String implements it (the untyped baseline: every segment parses), and for plain enums the routes! macro writes both sides:

day::routes! {
    pub enum Section { Home => "home", Stack => "stack" }
}

let section = Signal::new(None::<Section>);        // None = the collapsed mobile list
nav(section)
    .item(Section::Home,  tr("home"),  home_page)  // compile-checked, no raw keys
    .item(Section::Stack, tr("stack"), stack_page)

A sidebar nav keys on Option<Section> (None"", the no-selection list state); tabs always have a selection, so they key on the bare enum (Signal::new(Tab::One)). Blanket impls cover both: Option<R> is a Route whenever R is, and .item takes the bare variant either way.

Variants carry data: this is where typed routes improve on string encoding. Implement Route by hand and put the payload in the variant:

enum Drill { Depth(u32), Item { id: u32 } }        // "3" ↔ Depth(3), "item-42" ↔ Item{id:42}

let path = Signal::new(Vec::<Drill>::new());
nav_stack(path, root).destination(|d: &Drill| match d {
    Drill::Depth(n)    => level_page(*n),          // parsed, not string-split
    Drill::Item { id } => item_page(*id),
})
// push: path.update(|p| p.push(Drill::Item { id: 42 }));

The destination builder receives the parsed value; encode/decode lives in exactly one place (the Route impl). A typed stack also validates absolute routes: a segment from_key rejects is refused (the navigation stops there) instead of pushing an unparsed key; a String stack keeps its open-ended accept-anything behavior.

Typed absolute paths compose with route(…), and navigate_to is the typed relative form:

navigate_to(&Section::Home);                       // ≙ navigate("home")
route(&Section::Stack).then(&Drill::Item { id: 42 })
    .param("hint", "linked")
    .navigate();                                   // ≙ navigate("stack/item-42?hint=linked")
nav_link_to(tr("open-42"), route(&Section::Stack).then(&Drill::Item { id: 42 }))

Everything downstream is unchanged: current_route() still returns the encoded string (which is what you persist), deep links and dayscript still speak segments, and the two layers meet only at key/from_key. Mixed trees are fine: a typed nav host over a String stack, or vice versa.

Composition

To reproduce the Mail.app or Files.app navigation pattern, nest the containers:

nav(section).style(NavStyle::Sidebar)
    .item("library", tr("library"), || nav_stack(lib_path, library_root).destination(detail))

The sidebar selection drives which section shows; the selected section is itself a nav_stack that drills down. Each surface owns its signal.

Nested stacks share one native container on mobile. When the enclosing host presents as a push stack (a phone, or any window too narrow for two panes; see size classes), a nav_stack built inside one of its pages does not mint a second native navigation controller; it pushes its own pages onto the enclosing host, so the whole chain (list → section → drill-down) is one native stack with a single back button. The inner nav_stack keeps its own path signal and route registration (so current_route(), deep links, and nav_back() fall-through are unchanged); only the native container is shared. Where the enclosing host presents as split panes a nested nav_stack is not merged; it renders in the detail pane with its own back-header, which matches the desktop idiom. A resident container (nav(Tabs)) is a merge barrier: a nav_stack inside a tab keeps its own host.

Split or stacked

A nav(Sidebar) shows its list beside the selected page in a wide window and pushes the page over the list in a narrow one. That follows the window, not the platform: it is resolved from the window’s size class and re-resolved whenever the window crosses a breakpoint, so one nav is right on a desktop, a tablet, and a phone. .presentation(…) pins it where the content only works one way. docs/size-classes.md is normative; it covers the breakpoints, what survives a re-presentation, and which backends morph today.

The content list (three panes)

.content_list(build) gives a nav host the Mail shape: sidebar, content list, detail (mailboxes, message list, message). The list is built once and stays resident for the host’s life; its content follows the app’s own signals (the sidebar selection scoping it, the row chosen from it), so switching sections re-scopes it without a rebuild.

nav(section).style(NavStyle::Sidebar)
    .content_list(timeline_pane)               // the middle column, built once
    .content_list_width(400.0)                 // preferred; drag limits are the backend's
    .content_list_for(|k| k != "settings")     // full-page sections collapse the pane
    .detail_visible(reader_open)               // the compact push gate, two-way
    .detail_title(move || open_title.get())    // the detail layer's bar, live
    .item(…)…
    .destination(…)                            // the DETAIL only — the list is not in here
  • Where the pane lands is Cap::NavContentList’s answer. Native (macos-appkit, Qt, GTK): a real pane at every presentation, with a draggable divider on each side; a narrow window collapses the sidebar and keeps the list, as a narrow Mail.app does. On Qt the pane is the middle of the same three-pane QSplitter the sidebar lives in, hidden on a host that declared no list, so pane indices and the back header never move; on GTK it is the start child of the inner of the host’s two nested GtkPaneds, hidden the same way (since 2026-09; the backend notes below have the shape). Emulated (ios-uikit): a real column while expanded that merges into the navigation stack when the host collapses. A UISplitViewController fixes its column count at creation, never shows the primary without the supplementary, and drops a controller re-mounted in another of its columns, so a destination without a list gets a double-column host and one with a list a triple-column host: a change between the two on a wide window rebuilds the host with fresh column controllers and moves the pages across, which is what SwiftUI does when a NavigationSplitView changes column count. Day’s handle for the host is a container the split’s view fills, so the rebuild never touches the tree. Unsupported (everything else): the nav host composes the list beside each list-backed destination while split, and as the root layer of the gated push flow while compact.
  • detail_visible is the compact flow’s gate, two-way like every binding. Wide layouts never gate on it (the detail pane is always on screen, showing the app’s empty state until a row is chosen), but a back still treats it as the open layer: while it is true beside the list, the back closes the detail (writes false) and only the next back leaves the section, so clear what the pane shows when the signal goes false. Where the list is composed into the destination’s page, a platform back (Android’s, HarmonyOS’s) would pop that page, list and detail together, so the host arms NavPatch::GuardTop while a detail is open side by side and closes it through Day instead. Stacked, the content list is the top of the stack until the app writes true (a row was opened); the detail pushes then, and the platform’s back writes false on the way out. In a chrome presentation (a tab bar, a rail) the same flow runs inside the tab: the destination’s page is a nested navigation host (a UINavigationController in the tab, a Material toolbar over the fragment back stack), so the tab gets a navigation bar, a title, and a native back, and the nav host’s bar actions ride that bar (a tabs chrome otherwise draws none). Without detail_visible a stacked host behaves classically (the detail pushes on selection).
  • detail_title names the detail layer’s navigation bar: the pushed editor on a phone, the detail page’s bar wherever the toolkit titles one. Reactive like every title: a closure reading your own state retitles the live bar (NavPatch::Title) as that state changes, so the bar can carry the open item’s name. Unset, the detail layer keeps its destination’s title.
  • content_list_for collapses the pane per destination (NavPatch::ListVisible): a settings page takes the whole detail area, and selecting a list-backed section brings the pane back.
  • A host with a content list joins the split’s default-selection rule at every presentation: the pane needs a selection to scope itself to, so a collapsed host opens on the list rather than on bare sidebar rows.
  • The native resident pane (Native, and Emulated while expanded) is a merge barrier like a chrome page: a nav_stack inside it keeps its own container, with the desktop back header above its pages. The composed compact flow is the opposite: there the list is the root page of a push stack (the tab’s own navigation controller, or the enclosing host’s), so a nav_stack inside it merges, and a drill-down from the list (a category, then its stations) is real pushes with the native back, with the gated detail pushed on top of whatever the stack pushed.

Keyboard: pair the list’s content with .focusable() + .focused(sig) + .on_key(…) (docs/focus.md) so the arrows walk the selection, and a scroll_target signal (ScrollTarget::Id) to keep the selected row in view.

Backend notes

  • GTK adopts libadwaita throughout (adw::Application loads the Adwaita stylesheet). The window is an AdwApplicationWindow whose content is an AdwToolbarView (an AdwHeaderBar supplies the title, window controls, and drag; Day’s content sits below it). Navigation: Sidebar → two nested GtkPaneds — sidebar | (list | detail) — with a user-draggable divider on each side, the AppKit NSSplitView shape; nav_stackAdwNavigationView (push/pop + back gesture; its popped signal writes native back into the path). A split pane’s page (a GtkFixed) sits in a filling DayCell — day-gtk’s custom container, which asks GTK for no size and hands its child the whole allocation — so a page’s Day-laid frame never becomes a pane’s GTK minimum; each cell carries its pane’s honest minimum as its size request instead (NAV_SIDEBAR_MIN_W, NAV_LIST_MIN_W, and 200pt for the detail), no pane may shrink below it, and the divider stops there (the maximums are clamped on the position notify). Each pane cell reports its page’s size from its own size_allocate, before it allocates the page, and day-core dispatches the report at once when its tree is free — so the page is re-laid inside the same GTK layout pass and follows a divider drag live, as AppKit’s setFrameSize: report does. (Reporting from an idle after the position notify, as the first paned version did, trailed the divider by a frame or more.) The sidebar folds the way an AppKit split item does: when the window has no room for it beside the other panes’ minimums it collapses, and it comes back at its last width once there is room again (24pt of slack keeps a resize on the threshold from flapping); the toolbar’s sidebar toggle hides it independently, and a toggled-off sidebar stays off. The inner paned exists on every host, list or not — with no list page, or one collapsed by content_list_for, GTK gives the detail the whole inner allocation and draws no handle — so the widget shape never changes under live pages. A stack page’s GtkFixed is wrapped in an AdwNavigationPage. Tabs use an AdwViewStack with a .linked toggle switcher; dialogs use AdwAlertDialog (docs/dialogs.md). Decision (2026-09): AdwOverlaySplitView is gone from this backend, with no flag to bring it back. libadwaita pins sidebar widths by design (the GNOME HIG has no draggable sidebars), so the Adw split could never give Day’s desktop apps the adjustable columns AppKit and Qt have, and its pane sizing — a min == max sidebar, a content pane whose minimum was its own Day frame — fought every relayout (the issue #19 reveal jump, the External-policy scroll windows it took to break minimum propagation). What the Adw split supplied that the paned does not — a slide on the sidebar toggle, and the adaptive collapsed breakpoint — is either not wanted (dayscript screenshots the instant a toggle returns) or done by the fold above. DAY_GTK_SPLIT, which used to select the paned, is no longer read.
  • macOS NSSplitView / Qt QSplitter honor the resolved presentation: Split shows both panes; a nav_stack collapses the empty sidebar and stacks every page (top visible) in the detail pane, with a back header (chevron + centered title, hidden at the root) above the pages; desktop has no system back affordance, so a pushed page carries its own way out. The button emits the same NavBack event mobile back does, writing the pop into the path signal.
  • Android hosts each page in an androidx Fragment that retains its Day-owned view (the react-native-screens pattern: the FragmentManager owns when a page shows, Day owns what it shows). A push is a replace() back-stack transaction carrying MaterialSharedAxis transitions, which gets the whole back behavior from the platform: OnBackPressedDispatcher dispatches hardware/gesture back on every API level, the FragmentManager seeks the pop transition live under the predictive back gesture on API 34+ (progress, cancel, commit), and its back callback is enabled only while the back stack is non-empty, so the system’s predictive back-to-home animation stays available at the root (apps opt in with android:enableOnBackInvokedCallback="true"; the scaffold does). Native pops are reported to Rust as NavBack { already_popped: true }; Rust-initiated pops run popBackStack. When testing on Android 13/14 (API 33/34), the system gates predictive-back animation behind Developer options → “Predictive back animations” (adb shell settings put global enable_back_animation 1), and gesture navigation must be active; Android 15+ enables it by default.
  • Mobile presents the host as a native stack for both Sidebar (collapsed) and nav_stack. A nav_stack nested inside such a host’s page merges into it (one UINavigationController / DayNavHost, one back button) rather than nesting a second controller; see Composition. No backend change is involved: the shared host receives NAV_PAGE pushes/pops from both the outer surface and the inner stack identically to a single-surface stack.

Testing

crates/day-pieces/tests/mock_e2e.rs: nav host tabs/sidebar two-way binding, stack push/pop/reconcile, native-back-into-path, deep-link, nested fall-through, and typed routes (a Signal<Option<Area>> sidebar over a data-carrying Leg(u32) stack, including segment validation). The showcase’s top-level nav is a typed nav(Sidebar) over a Section enum, its Tabs page a typed nav(Tabs), and its Stack page a nav_stack over a data-carrying Drill enum, all driven through the walkthrough on all five local targets.