Context
0303 decision 1 keeps@stackra/ui router-free and puts the
router bindings in @stackra/navigation, and decision 7 gave every component one props vocabulary. Wave 6 adds the
frame of an app: AppShell, Sidebar with NavigationList, AppHeader, PageHeader, Section, SingleColumnLayout and
TwoColumnLayout, the native Screen, TabBar and NavigationHeader, and RouteOverlay, which turns a route into a Modal,
Sheet, Drawer or full-screen page. These pieces list the same places (a sidebar on the console, a tab bar on the phone),
name the same landmarks, and offer the same slots at the ends of a bar, but React Navigation, React Router and HeroUI
each name those things differently, and the shell is the one part of a screen that every other screen sits in. An
Accepted ADR’s body changes only for typos and Enforcement, so the additions are recorded here.
Decision
-
The wave 6 vocabulary. Decision 7 of 0303 stands; wave 6 adds these rows:
-
@stackra/navigationowns router UI and@stackra/uistays router-free.@stackra/navigation(packages/experience/navigation,tier:lib) depends on@stackra/uiand takes React Router 7 (web) and React Navigation 7 (native) as optional peers, at the versionspnpm-workspace.yamlalready catalogues for the console and the mobile app.RouteOverlayanduseRouteOverlaylive there, in its./reactand./nativeentries, with a contract in its own./contracts.@stackra/uinever imports it and never imports a router. -
A component never decides which place is current.
isCurrentcomes from the app or from@stackra/navigation. One pure function,markCurrentItems(items, pathname), lives in@stackra/uicore and matches on a whole path segment (/ordersis current on/orders/ord_1and not on/orders-archive), so the web and the native shell agree and neither imports a router. A phone tab bar reads the first level only. -
A shell piece never imports another. AppShell takes
sidebar,headerandchildrenas nodes, a sidebar’s trigger is a node in AppHeader’sstartContent, and PageHeader’s status and actions are nodes oractions, so the pieces meet in the app’s code, as the wave 5emptyStateslot does (0313 decision 5). A piece may build on a component of an earlier wave (PageHeader on Breadcrumbs, StatusBadge and ActionMenu), never on a sibling of its own wave. -
Landmarks. AppShell draws the one
main(with the id a skip link jumps to, and a skip link as its first focusable element). Sidebar and NavigationList are anav, AppHeader aheader, PageHeader aheaderinsidemain, and Section asectionthat is named by itstitle. A page that holds two of a kind gives each alabel. Titles are real headings: the page’s singleh1is PageHeader’s, a Section ish2by default. -
Widths are named, not measured.
collapseBelowtakes a Tailwind breakpoint name, so the web components map it to a static class (md:and the like), never a computed string. Native has no width to switch on, so an adapted native form accepts the prop and ignores it, and its page says so. -
Where a runtime has no counterpart the page says so.
TabBarhas no web form (the web shell uses the Sidebar), and the webScreenis a plainmainwith the desktop title-bar insets,NavigationHeadera plain header with a back link, a title andendContent(it does not import PageHeader, by decision 4). The native Sidebar, AppHeader, PageHeader, Section and the two layouts are adapted forms: an inset list, the navigation header, an in-content header, a card and a single column. -
Native sheets use the inset style. Every native bottom sheet the shell opens uses HeroUI Native’s detached
style, as the owner decided on 2026-10-24: its content takes
detached, a bottom inset of the safe area plus 12, a horizontal margin of 16 and a 32 px corner radius. -
RouteOverlay’s presentation is one enum.
OverlayPresentationismodal,sheet,drawerorfull-screen, named like the other wave values. Web renders them with Modal and Drawer, native with the React Navigation stack’spresentation. It takesisDismissableas 0312 names it, andfalsemeans no Escape, no backdrop press and no swipe.
Consequences
- An app declares its navigation once as
NavigationItem[]and gets a sidebar on the console, a tab bar on the phone and a drawer on a narrow window from the same list. @stackra/uican be installed with no router, and an app picks React Router or React Navigation through@stackra/navigation.- A page keeps one
h1, onemainand a landmark list a screen reader can tell apart. - Each wave 6 group can be built alone, because the only contracts they share are the types of this ADR.
Enforcement
packages/design-system/ui/src/contracts/types/__tests__/shell-contract-props.test.tsproves the shared types.turbo boundarieskeeps@stackra/uifrom importing@stackra/navigationor a router, because it is an undeclared dependency of the package;packages/tooling/oxlint/src/boundaries.jsoncholds no rule for it yet.tools/scripts/validate-repository.mjschecks the new member’s tier tag.- Each component’s type test asserts that the runtime props extend the contract and refuse the raw names this
vocabulary replaces (
aria-label,primaryAction,headerRight). tools/scripts/spec-trace.mjs(run bypnpm validate) refuses a spec id that no test covers.- Until a lint rule exists, review (
architecture-guardian) checks this page. - As built, wave 6 extends the vocabulary in the places below, recorded here so review can hold them:
- Sidebar’s
startContentandendContentare the top and bottom of a vertical bar. - AppHeader’s
searchanduserMenuare two more bar slots, aftertitleandendContentrespectively. - NavigationHeader’s
hasLargeTitle,onBack,backHrefandbackLabelname the back control and the large title;backHreffollows the app-path rule of decision 3 and never opens an absolute URL. Its native-onlyhasTopInsetturns the safe-area top padding off for a header inside a modal. - Screen’s
isScrollable,isKeyboardAvoidingandedgesare native, and its web-onlyisEmbeddeddrops the desktop title-bar insets for a screen inside a window that already clears them. - TwoColumnLayout’s required
asideis the second column, andmoreActionsLabelnames the overflow button of PageHeader and Section. - The web-only props
isCompact(the icon rail of a NavigationList),isCollapsible,isEmbedded,skipLabelandmainIdhave no native counterpart. - AppShell’s
sidebarmay be a function ofAppShellSidebarState, andmarkCurrentItemslives in@stackra/uicore as decision 3 says. PageActionsis an internal row on each runtime, not exported and not a shell piece: PageHeader and Section both draw it, so decision 4 holds and the two share one implementation. Its web wrapper carriesdata-slot="page-actions".- The native components share two helpers:
isAbsoluteUrlin core andfollowHrefin the nativerouter-providerfolder, so every press of a link follows one rule.
- Sidebar’s
Alternatives
- Router-aware shell components in
@stackra/ui. Not chosen: a router peer on the design system would make every consumer of a Button depend on one router, and the native app has a different one. isActiveon the item. Not chosen:isCurrentsays whataria-currentsays, and an ancestor of the current place is current too, which “active” does not.- Fixed pixel widths for the collapse point. Not chosen: the theme owns the breakpoints, so a number in a component drifts from it.
primaryActionandsecondaryActions. Not chosen: one ordered list overflows by itself, and a page with no primary action is just a list with no first entry marked.