Skip to main content
Amends 0303 (decision 7, the props vocabulary), as 0304, 0311, 0312 and 0313 did for waves 2 to 5. 0303 carries a note; its body is unchanged.

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

  1. The wave 6 vocabulary. Decision 7 of 0303 stands; wave 6 adds these rows:
  2. @stackra/navigation owns router UI and @stackra/ui stays router-free. @stackra/navigation (packages/experience/navigation, tier:lib) depends on @stackra/ui and takes React Router 7 (web) and React Navigation 7 (native) as optional peers, at the versions pnpm-workspace.yaml already catalogues for the console and the mobile app. RouteOverlay and useRouteOverlay live there, in its ./react and ./native entries, with a contract in its own ./contracts. @stackra/ui never imports it and never imports a router.
  3. A component never decides which place is current. isCurrent comes from the app or from @stackra/navigation. One pure function, markCurrentItems(items, pathname), lives in @stackra/ui core and matches on a whole path segment (/orders is current on /orders/ord_1 and 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.
  4. A shell piece never imports another. AppShell takes sidebar, header and children as nodes, a sidebar’s trigger is a node in AppHeader’s startContent, and PageHeader’s status and actions are nodes or actions, so the pieces meet in the app’s code, as the wave 5 emptyState slot 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.
  5. 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 a nav, AppHeader a header, PageHeader a header inside main, and Section a section that is named by its title. A page that holds two of a kind gives each a label. Titles are real headings: the page’s single h1 is PageHeader’s, a Section is h2 by default.
  6. Widths are named, not measured. collapseBelow takes 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.
  7. Where a runtime has no counterpart the page says so. TabBar has no web form (the web shell uses the Sidebar), and the web Screen is a plain main with the desktop title-bar insets, NavigationHeader a plain header with a back link, a title and endContent (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.
  8. 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.
  9. RouteOverlay’s presentation is one enum. OverlayPresentation is modal, sheet, drawer or full-screen, named like the other wave values. Web renders them with Modal and Drawer, native with the React Navigation stack’s presentation. It takes isDismissable as 0312 names it, and false means 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/ui can be installed with no router, and an app picks React Router or React Navigation through @stackra/navigation.
  • A page keeps one h1, one main and 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.ts proves the shared types.
  • turbo boundaries keeps @stackra/ui from importing @stackra/navigation or a router, because it is an undeclared dependency of the package; packages/tooling/oxlint/src/boundaries.jsonc holds no rule for it yet. tools/scripts/validate-repository.mjs checks 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 by pnpm 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 startContent and endContent are the top and bottom of a vertical bar.
    • AppHeader’s search and userMenu are two more bar slots, after title and endContent respectively.
    • NavigationHeader’s hasLargeTitle, onBack, backHref and backLabel name the back control and the large title; backHref follows the app-path rule of decision 3 and never opens an absolute URL. Its native-only hasTopInset turns the safe-area top padding off for a header inside a modal.
    • Screen’s isScrollable, isKeyboardAvoiding and edges are native, and its web-only isEmbedded drops the desktop title-bar insets for a screen inside a window that already clears them.
    • TwoColumnLayout’s required aside is the second column, and moreActionsLabel names the overflow button of PageHeader and Section.
    • The web-only props isCompact (the icon rail of a NavigationList), isCollapsible, isEmbedded, skipLabel and mainId have no native counterpart.
    • AppShell’s sidebar may be a function of AppShellSidebarState, and markCurrentItems lives in @stackra/ui core as decision 3 says.
    • PageActions is 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 carries data-slot="page-actions".
    • The native components share two helpers: isAbsoluteUrl in core and followHref in the native router-provider folder, so every press of a link follows one rule.

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.
  • isActive on the item. Not chosen: isCurrent says what aria-current says, 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.
  • primaryAction and secondaryActions. Not chosen: one ordered list overflows by itself, and a page with no primary action is just a list with no first entry marked.

Supersedes and sources

Supersedes: none. Amends 0303 decision 7. The catalogue rows, section 3 gaps and section 4 of the component catalogue (wave 6 of its build order), the HeroUI 3.2.6 and HeroUI Native 1.0.10 component sources, and the React Router 7 and React Navigation 7 documentation.