Size classes and re-presenting navigation

A window’s width decides how much can be on screen at once. A nav in a wide window shows its list beside the selected page; the same nav host in a narrow one shows the list, then pushes the page over it. Day resolves that from the window’s size class and re-resolves it whenever the class changes, so one nav is right on a desktop, a tablet, a phone, and a browser window someone is dragging narrower as they read.

The breakpoints

SizeClass buckets a window’s size in points. The numbers are Android’s window size classes, used verbatim on every backend:

WidthClasspointstypically
Compact< 600phone portrait
Medium600–839tablet portrait, a narrow desktop window
Expanded840–1199tablet landscape, a typical desktop window
Large1200–1599a large desktop window
ExtraLarge≥ 1600maximized on a big display
HeightClasspointstypically
Compact< 480phone landscape
Medium480–899phone portrait, tablet landscape
Expanded≥ 900tablet portrait, most desktop windows

One table across every backend means one answer: a 700pt window is Medium on a Mac, in a browser, and on a tablet, so an app that lays out from the class gets the same layout at the same size everywhere. Apple publishes two buckets rather than five; those map onto this table instead of replacing it, with Compact the compact one and everything wider regular.

Apps read the class with day::size_class(). The read is tracked, so a piece that lays out from it rebuilds when the window crosses a breakpoint. It answers None on a backend that reports no window geometry.

let two_up = day::size_class().is_some_and(|c| c.width >= WidthClass::Expanded);

Per-window, not per-app

The class belongs to a window. One process can show two windows in different classes at the same moment (a narrow window beside a wide one, iPadOS Stage Manager, Android split-screen), and an app keyed off a single global would lay the second window out for the first one’s size. The signal is keyed by window root, alongside the safe-area insets, and both scope the way toolbars do (§day_core::toolbar): a read during a window’s content build means that window.

The value is derived in day-core from Event::WindowResized, which every backend already emits. A toolkit reports geometry and never a class, which keeps the breakpoint table in day-core alone.

What a nav host does with it

A nav presents as Split (list beside detail), Stack (one page at a time, back-navigable), Tabs (the rows as a tab bar) or Rail (the rows as a narrow strip). Left alone it resolves that automatically:

nav(section)                       // automatic: follows the window
nav(section).presentation(NavPresentation::Split)   // pinned

Resolution answers four questions in order:

  1. Did the app pin one? If so, that one, clamped to something the toolkit can draw.
  2. Can this toolkit draw split panes at all (Cap::NavSplit)? If not, it stays single-pane.
  3. Which style is it? Tabs is a tab bar at every size; Sidebar is the SplitStack ladder this document has always described.
  4. Automatic walks the full ladder: Split when expanded, Rail at medium, and when compact either Tabs or Stack; Cap::NavTabsAdaptive decides which, because growing a tab bar from a narrowed window is idiomatic on the phones and the web and is not on any desktop (docs/navigation.md).

Pin one when the content only works one way, such as a settings sidebar whose detail is meaningless on its own or a wizard that has to stay a stack. A pin is still a preference: a toolkit with no split container stacks whatever it is asked for.

Re-presenting a live host

When the class crosses a breakpoint the host is re-presented: NavPatch::Presentation tells the toolkit to rebuild its own chrome and re-home the pages it already has. No page is torn down and rebuilt, because a rebuild would drop every scroll offset, text selection, and focused field, and would restart any animation in flight.

That works because a page’s Pane is a fact about the model rather than about the current drawing. A nav host’s list page is Pane::Sidebar whether the host is split or stacked; the presentation decides only where the pane lands:

paneSplitStackTabsRail
Sidebarits own splitter panethe root of the stacknot drawn; the rows are the tab barnot drawn; the rows are the rail
Detailthe detail panepushed above the rootthe tab’s content areathe content beside the rail

Tabs and Rail differ only in where the rows are drawn, which is why they share a code path in the pieces layer and in every backend: both hide the sidebar page and render its rows as chrome.

Selection is carried across, with one asymmetry:

  • Narrowing keeps the selection. The detail becomes the top of the stack, which is where the user already was.
  • Widening with nothing selected picks the first item, because a split presentation has no way to draw an empty detail pane. This is the same rule the initial build uses.

Per-backend support

Cap::NavRepresent says whether a toolkit re-presents a live host. It gates more than the patch: on Unsupported, presentation resolves from Cap::NavSplit alone and the window’s size never enters into it. A toolkit that cannot change its presentation must not have it decided by something that changes underneath, or a window launched narrow would be stuck stacked with no way back.

backendNavSplitNavRepresentnotes
web-domthe shim rebuilds chrome; page elements move between containers intact
macos-appkitone NSSplitViewController either way — a stack is that split with its sidebar item collapsed
linux-qt / windows-qt / macos-qtone QSplitter either way, back header installed in both
linux-gtk / windows-gtksee below
windows-xamla NavigationView owns its own PaneDisplayMode; re-presenting means driving that rather than re-homing pages
ios-uikitobservedUISplitViewController, both columns navigation controllers
android-mdcobservedSlidingPaneLayout, list pane beside a detail pane
harmony-arkuiNavigationMode.Auto pending; see below

GTK is the odd one out among the desktops. Everywhere else both presentations are the same container with different chrome, so a morph re-homes pages inside a host Day already holds. On GTK they are different widgets (nested GtkPaneds for the split, AdwNavigationView for the stack), and Day holds the host handle, so it cannot be swapped underneath. The route there is a stack presentation re-homing the split’s pages under the same host, which is a restructure of a working backend rather than an addition to it. What a narrowing GTK window does get is the AppKit-style fold (docs/navigation.md, backend notes): the sidebar collapses when the window has no room for it beside the other panes’ minimums, and returns when there is room again — the presentation stays Split throughout.

This policy determines one lowering rule: an Emulated toolkit’s adaptive host is lowered with presentation: Split (meaning “build the adaptive container”) even when the window is compact at build time, because the container collapses itself. Stack in NavProps is thereby literal: it marks a host that is a stack at every size (a pinned request, or the nested nav_stack() piece under a split host), and the backend realizes it as a plain navigation controller.

The following traps fail silently:

  • iOS. The split’s primary column must be a UINavigationController, not a bare view controller. UIKit merges the secondary column into the primary’s stack when it collapses; with nothing to merge into, it drops the navigation bar entirely on phones and breaks first-responder handling, so becomeFirstResponder fails quietly. Which controller owns the stack therefore depends on the presentation.
  • iOS, again. Day-initiated stack changes are one setViewControllers:animated: each, computed at execution from the pages UIKit reports plus or minus the one page that joined or left (push_page, pop_page). Nothing is kept on Day’s side to fall out of step: a page that the user already popped is absent from UIKit’s reported state, and removing it is a no-op. One thing IS kept, only while a set animates: its target, because UIKit defers a setViewControllers:animated: issued mid-transition and keeps reporting the old stack until it lands (and warns about the call). So changes issued in one turn coalesce into one set, and a change that arrives while a set is in flight waits for that set’s completion — never a second set during an animation. A scripted nav_back: { native: true } waits for ui_idle for the same reason: pressed during a push still animating, the bar had nothing to pop, and a wide window then passed the step as “nothing was pushed” (the iPad CI leg, 2026-09-10).
  • iOS, a third time. Never animate to an empty stack (deselecting in the expanded split empties the detail column): with no destination controller the transition sets up but never completes, the stack keeps its old contents, and the orphaned transition coordinator reports busy forever. Pass animated: false when the target is empty.
  • iOS, a fourth time. The collapse is not on Day’s schedule. iOS 18 asks topColumnForCollapsingToProposedTopColumn: and merges BEFORE Day’s launch sync has put the first destination in the detail column; iOS 26 asks after. Left to UIKit, an iPhone on iOS 18 opened on the sidebar with the model saying the page was open and nothing to pop. So the delegate answers from the mirror for every host shape, and split_presentation_changed finishes the merge UIKit started: a page still sitting in the secondary column after a double-column collapse goes on top of the primary’s stack, which is the shape the mirror has.
  • iOS, a fifth time. A host view Day places carries no autoresizing mask. UIKit gives every controller’s view W+H, and a masked child of a superview that grows from zero gets its own size plus the delta: a nav host Day had sized to 420×810 under a tab page came out 840×1620 the moment the page took its bounds after Day’s pass, with the list’s trailing accessories and the bar title off the right edge. nav.view() and the split’s view are mounted with an empty mask, and the split’s container (DayNavContainer) sizes it in layoutSubviews.
  • Every toolkit with an adaptive container. Never nest one inside a pane. A UISplitViewController assumes it owns the window; embedded in a detail column its column layout collapses into garbage. The Stack-is-literal lowering rule exists for this case, so the nested host realizes as a plain navigation controller instead.

Resizable windows on the phones

Both mobile platforms now resize app windows freely, and both vendors have stopped treating it as optional. Android 16 (API 36) ignores screenOrientation, resizeableActivity, minAspectRatio and maxAspectRatio on any display 600dp or wider (tablets, open foldables, and desktop windowing on every form factor), and the temporary opt-out property (PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY) is removed in API 37. iPadOS 26 deprecated UIRequiresFullScreen and made every iPad window resizable; iOS 27 extends that to iPhone apps on iPad and to iPhone Mirroring on the Mac.

Day needed little for this, because the class is derived in day-core from Event::WindowResized and every backend already emitted one. It needed that geometry to be the window’s own, and the app to still be there afterwards.

Android: survive the resize

Entering split-screen, or dragging a desktop-windowing edge across a size bucket, changes screenLayout and smallestScreenSize. An activity that has not claimed those in android:configChanges is destroyed and recreated, and day-android does not survive a second nativeStart in one process, so the app comes back missing whatever it installed once at startup. The scaffold’s manifest claims them, day lint fails a manifest that does not (day::lint::android-not-resizable), and day::android::start refuses a second launch with a message naming the fix rather than half-relaunching into an undiagnosable state.

The <layout> element carries the window’s minimum and its desktop-windowing default, and PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI (API 35+) lets the system UI offer a second window for an app that supports one. resizeableActivity is not declared, because it already defaults to true at targetSdk 24+ and API 36 ignores it.

iOS: measure the scene, not the screen

UIScreen’s bounds are the display. A scene has not filled the display since iPad multitasking, and on iPadOS 26 it usually does not even at launch. Measured on an iPad Pro 13-inch, a Day app opens into a 635×1376pt window on a 1032×1376pt screen. Sizing the window from the screen made the first size class wrong by 397 points, so a nav host resolved a three-column split for a window that had room for two and then corrected itself once the first layout pass ran.

day-uikit takes its launch geometry from the scene’s own coordinate space (effectiveGeometry.coordinateSpace on iOS 26+, the direct property below it; the same value either way). DayHolderView reports every later change against its own scene, which matters as soon as there are two windows: reporting a secondary’s geometry against the primary re-framed the wrong window’s root view and re-bucketed the wrong window’s class.

Declaring a minimum size

One declaration serves both layers. Android reads it from the manifest at build time and iOS at run time:

[window]
width = 960          # also the desktop-windowing default size
height = 640
min_width = 320
min_height = 400

day build writes the <layout> element’s attributes and the iOS Info.plist keys day-uikit reads back for UIWindowScene.sizeRestrictions. An app that sets WindowOptions.min_size in Rust still wins over both. On iOS the minimum is a preference the system satisfies on a best-effort basis; laying out sensibly at whatever size arrives is still the app’s job.

What still works on an older OS

Nothing here raises a floor. Android’s minSdk stays 24 and everything used is API 24+ except the multi-instance <property> (API 35+, and older platforms skip unknown tags). iOS deploys to 15.0 and needs no gated API on the critical path, because the class comes from geometry rather than from UIKit traits: the scene’s coordinate space reads the same from iOS 15 to iOS 27. The one API newer than the floor (UITabBarController.mode, annotated ios(18.0)) is guarded on respondsToSelector: rather than on a version number, which is the fact the call depends on. It responds on iOS 15.5 and 17.5 as well; gating it on the version instead made those releases worse, because the resolver’s fallback lowers a different host shape.

Row fit policies

A row keeps its children on one line no matter what. That is the right contract for a label beside a value, and the wrong one for five buttons on a phone: the line overflows the window and the tail lands offscreen, still green under every dayscript assertion because the synthetic rail does not hit-test. .fit(RowFit::…) names what should happen instead, with the same children and the same call shape:

row((a, b, c)).spacing(8.0)                                  // RowFit::Clip, the default
row((chips,)).spacing(8.0).fit(RowFit::Wrap { run_spacing: 8.0 })
row((keys,)).spacing(8.0).fit(RowFit::WrapColumns { run_spacing: 8.0 })
row((label, control)).fit(RowFit::ColumnAt(WidthClass::Compact))
row((chips,)).spacing(8.0).fit(RowFit::Scroll)

Clip is the default: one line at natural sizes, and whatever does not fit lands offscreen. In debug builds the engine logs the overflow once per container, naming the dayscript ids in reach (day layout: children overflow their container …), so the silent version of this failure no longer exists. Release builds skip the check entirely.

Wrap breaks onto additional lines where the next child would overflow, like wrapped text, the shape a chip row, a button strip, or a tag cloud wants. Lines are run_spacing apart, and children align within their line via .align(VAlign::…). Wrapping replaces main-axis negotiation, so .grow() and spacer() are inert, and a single child wider than the window still overflows.

WrapColumns wraps the same way but into aligned columns: every cell takes the widest child’s width, and each line holds as many as the window fits. Wrap keeps each child at its natural width, so the lines come out ragged, which is right for chips of unequal weight and wrong for a set of peers that should read as a grid (a keypad, a palette, a row of equal choices):

Wrap          [Item 1][ Item 2 ][Item 3][ Item 4 ]     WrapColumns   [ Item 1 ][ Item 2 ][ Item 3 ]
              [ Item 5 ][Item 6][ Item 7 ]                           [ Item 4 ][ Item 5 ][ Item 6 ]

The column count follows the available width, so it re-flows as the window changes. Left at that, the columns keep the widest child’s width and whatever width is left over trails the last column. When any child grows (.grow_w()), the widest child’s width becomes the narrowest a column gets instead, and the columns stretch to share the whole width. That is the adaptive grid a gallery of tiles wants, SwiftUI’s GridItem(.adaptive(minimum:)): tiles that can grow fill each line edge to edge, and a line with fewer tiles than columns leaves the rest of its columns empty. No column is wider than the line, though: on a window narrower than the widest child, the cells take the window’s width and their text wraps.

row((tiles,)).spacing(16.0).fit(RowFit::WrapColumns { run_spacing: 16.0 })
// each tile: …min_width(120.0).grow_w(), with anything inside that should scale sized from it

An authored, fixed column count with per-cell spans is a different job; that is grid, whose children are rows rather than items.

ColumnAt(class) re-arranges the row into a leading-aligned column while the window’s width class is at or below class, the shape a label-plus-control-plus-result line wants, where wrapping members independently would tear apart what reads as one sentence. The size_class() read is tracked, so crossing the breakpoint re-arranges it live; app state lives in signals and survives the rebuild.

Scroll keeps the single line and makes it a horizontal scroll strip: one row tall, filling the width it is given, with the tail a swipe away instead of gone. It is the policy for rows whose order matters more than their visibility, such as a timeline, a filmstrip, or a rail of shortcuts.

The showcase’s Layout page renders one row under each policy with a live component count, which is the quickest way to see the difference.

Testing it

dayscript’s size_class: step reports a class the way a backend would, without resizing anything:

- size_class: { width: compact }              # height defaults to `expanded`
- assert_visible: { id: nav-list }
- size_class: { width: expanded, height: medium }
- size_class: { width: auto }                 # back to what the window reports

Everything downstream runs its real path: the host re-presents, a piece reading day::size_class() rebuilds. What it does not change is the window’s pixels, so a screenshot after this step shows the new layout at the old size.

Where the geometry itself is under test, resize: moves it:

- resize: { width: 1100, height: 900 }        # real points
- assert_visible: { id: content-list }
- resize: auto                                # back to the device's own geometry

The runner performs the resize and the engine half waits until the app has reported the new class, so the next step cannot race the platform’s resize animation. It is asserted as a width class: what reaches day-core is the safe-area-inset content size, so a window resized to 900pt tall reports about 830 once the status bar, the navigation bar and the app bar come out (a page that is one scroll view reports its full height on iOS and absorbs the bars as content insets instead; the sides always come out, and on iPadOS 26 the floating sidebar is a 330pt left inset of the secondary column, so a detail beside it reports about 700 wide on an iPad Pro), and width is what every re-presentation decision reads anyway. Aim for mid-bucket sizes; a width within a few points of a breakpoint can fall the other side of it once insets are taken out.

Only android-mdc has a host-side lever today (adb shell wm size, which is also what delivers the configuration change the manifest has to survive). Everywhere else the step fails rather than passing one that moved nothing. iOS coverage for a width crossing therefore comes from running the same walkthrough on an iPhone and an iPad, which is worth doing regardless: on iPadOS 26 an iPad app opens windowed, so its scene is materially narrower than its screen.

Release the override with width: auto once the sweep is over. A forced class that outlives the steps it was written for follows the script into everything after it: a phone left on expanded lays out a two-pane split at 390pt, and the detail pane (the thing the next step is about to look for) sits off the right edge of the screen. The layout is correct; the window is not that wide.

Write the two-part assertion: that the presentation changed and that the state survived. A morph that silently drops the selected section passes a naive screenshot check and fails the only thing this feature promises.