Agent docs

AppShell

Whole-app chrome: nav sidebar, topbar slots, and the padded page frame.

The whole-app chrome for a product built on the kit: a collapsible nav sidebar, a topbar with title/search/actions/user slots, and a padded content area that renders children.

The canon behind it. method/design-language/08-navigation-and-shell.md §A1 (08-navigation-and-shell.md:14, chrome is infrastructure a route declares into, never markup a page repeats), §A2 (08-navigation-and-shell.md:17, one source of truth for a page's icon and label), and §A3 (08-navigation-and-shell.md:20, active state must be explicit, never inferred by prefix).

Topbar title. A string title renders as a div, not a heading, so the page's own heading stays the only h1. Pass topbar.titleAs ("h1", "h2", ...) when the topbar title is the page heading.

Rail without icons. A nav item with no icon and no color shows its initials (two letters) in the collapsed rail, never a blank pill; the full-width column shows only the label.

Topbar centring. With a search slot the topbar is a three-column grid (minmax(max-content,1fr) auto minmax(0,1fr)): the search sits on the true centre regardless of the title's length, and only a title longer than its column share nudges it right (doc 59 S6). Without a search slot the row is the flex it always was.

When to reach for it#

Reach for AppShell once, at the app root, for a product surface that wants sidebar-plus-topbar navigation and does not need more than that. It is the shell for walmart-mvp (App.tsx:436, 501) and for the forge console (versable-forge-v6, two files import it on its main as of 2026-09-04); the citations below are walmart's, the first consumer.

Do not assume it is the shell for every app on the kit. Speedway built its own two-layer chrome (root.tsx for the topbar and global providers, routes/shell.tsx for sidebar, breadcrumbs, an activity rail, and a peek outlet) before or instead of adopting this component, and imports zero symbols from app-shell (rg AppShell speedway/app returns nothing). AppShell has no activity-rail slot, no peek outlet, and no breadcrumb primitive; a page that needs those reaches for the pattern in method/design-language/08-navigation-and-shell.md §A and §C, not for this component. That doc's own "Portable checklist" section is written for exactly this gap, what a second consumer app should carry over from speedway's shell even though it cannot import speedway's code.

Contract#

Chrome:

  • logo renders in the sidebar header at full width; railLogo replaces it below lg or while manually collapsed, where only about 40px of width is available, so give it a mark, not a wordmark. Both are free JSX (walmart's Logo()/RailLogo(), App.tsx:166-189).

  • nav: NavGroup[]. The sidebar's whole content. Keys on a group:

    • label: the kicker text. An empty label renders no kicker and no caret, just the rows, which is the shape for a section's own index row above the groups (the playground's indexRow() helper, apps/playground/src/app/showcase-registry.ts:624).
    • items: NavItem[]: the rows. Each takes label, href, an optional icon, an optional active override, an optional color (an accent dot on the row, sidebar.tsx:140), and a free badge slot.
    • count?: a number beside the kicker.
    • collapsible?: groups collapse by default and start expanded (sidebar.tsx:155-157). Pass false to pin one open.
    • defaultCollapsed?: true: start closed. The shape a long sidebar takes so only the group the reader is in opens.
    • icon?: a mark left of the kicker at kicker size, 13px, or 11px on a sub-group. Use the same icon the group's index card wears (Card.Icon, card.md) so column and cards read as one set.
    • groups?: NavGroup[]: sub-groups under the rows, each with its own smaller kicker and caret, one step in.

    Three behaviours you cannot see from the keys. A group holding the active row opens regardless of defaultCollapsed, so arriving by link always shows where you are. Nesting stops at one level: a sub-group's own groups is not rendered. Open state is keyed by path, so two sub-groups both named "Forms" under different sections keep separate carets.

    navGrouping sits on the shell and the sidebar rather than on a group: flat (default) or railed, the quiet kicker with rows one step in behind a hairline rail, for a long column (canon 08 §A10).

    Ruled 2026-08-16 (doc 55 S2, S3): collapsible flipped from off to on by default, so an app upgrading past 0.2.0 sees carets on every labelled group unless it opts out. The group icon, defaultCollapsed and groups keys landed on main after 0.2.0. The playground's "Nested groups" variant (apps/playground/src/app/components/app-shell/examples/sidebar-nested-groups.tsx) shows them and test/sidebar-nested-groups.test.tsx pins them.

  • activeHref matches items by exact href when an item does not set its own active. The canon rule is to never rely on this: "active state is explicit, never inferred by prefix" (08-navigation-and-shell.md §A3). The one shipped consumer follows it, computing active per item from a typed match (App.tsx:65, 78-100) and never passing activeHref at all.

  • renderLink swaps in a router's Link; the kit itself never imports a routing framework, so the default falls back to a plain <a>. Real usage wraps react-router's Link (App.tsx:158-162, RouterNavLink).

  • topbar is four independent slots (title, search, actions, user), each rendering only when passed. The one shipped consumer sets title only, to a breadcrumb component (App.tsx:440, 505); search, actions, and user have no shipped usage found. Sign-in identity does not live in the topbar's user slot here; see sidebarFooter below.

  • The translucent bar, opt-in (08-navigation-and-shell.md §B): the kit Topbar renders solid bg-base-100 always (topbar.tsx:18) and is not positioned as an overlay. AppShell renders it as a shrink-0 row beside main's own independent scroll region (app-shell.tsx:58-75), so nothing ever scrolls beneath it inside the shell. The recipe (surface at /80, backdrop-blur, a border gated on scroll) is a preset for a bar fixed or sticky over content, not a prop this component exposes; the shipped case is the playground's own marketing header, not AppShell's topbar. Composing it directly, outside this component, looks like:

    // apps/playground/src/app/landing.tsx:107-113
    <header
    className={`fixed inset-x-0 top-0 z-50 h-16 border-b transition-colors duration-200 ${
    scrolled
    ? "bg-base-100/80 border-base-300 backdrop-blur"
    : "border-transparent bg-transparent"
    }`}
    >
  • sidebarTop sits under the logo, hidden while collapsed; the one shipped consumer puts a workspace switcher there (App.tsx:509-516). sidebarFooter is pinned to the sidebar's bottom edge; the one shipped consumer puts an identity dropdown there, SidebarUserMenu, which bundles both the account row and the sign-out action into one menu (App.tsx:192-244, mounted at App.tsx:517). This diverges from Speedway's own rule that "sign-out lives in the topbar" (08-navigation-and-shell.md §A6): that rule describes Speedway's bespoke shell, not a constraint the kit component enforces. AppShell has no dedicated sign-out slot, so where sign-out lives is each app's call.

  • footer renders a thin bar under the content area. No shipped usage found.

State:

  • collapsed / defaultCollapsed / onCollapsedChange is the standard controlled-or-uncontrolled pair: pass collapsed and handle onCollapsedChange yourself, or omit collapsed and let the component hold its own state seeded from defaultCollapsed. The shipped consumer controls it, persisting the value to localStorage and additionally wiring its own Cmd/Ctrl+B shortcut (App.tsx:382-407). The kit provides the toggle button in the sidebar header, not a keyboard binding.
  • Below the lg breakpoint the sidebar is always icon-rail width regardless of collapsed, via a hard-coded Tailwind breakpoint rather than a matchMedia hook (sidebar.tsx:221-223, w-16 always, lg:w-64 only when not collapsed); the manual collapse toggle only changes anything at lg and above. There is no mobile off-canvas or hamburger drawer. hideToggle exists for an app that wants to host its own hamburger elsewhere and drive collapsed itself, but it is a prop of the standalone Sidebar only (sidebar.tsx:40): AppShellProps does not expose it and AppShell never forwards it to its internal Sidebar (app-shell.tsx:44-55), so reaching it means composing Sidebar yourself. No shipped usage found.
  • Only one active-item treatment ships: bg-primary/10 text-primary font-medium (sidebar.tsx:135), matching the canon preset verbatim (08-navigation-and-shell.md §B).
  • sidebarResizable (default false) drags the sidebar's right edge, lg and up only; the same drag/keyboard engine (internal/use-drag-resize.ts) backs SidePanel's own resize. sidebarWidth (default 256, matching the fixed lg:w-64 this replaces), sidebarMinWidth (200), sidebarMaxWidth (400), and sidebarStorageKey reach the standalone Sidebar's own resizable/width/minWidth/maxWidth/storageKey props (sidebar.tsx:40-58). The separator carries role=separator aria-orientation=vertical aria-valuemin/max/now; ArrowLeft/Right and ArrowUp/Down step 16px, Home/End jump to min/max, and a double-click resets to the starting width. Dragging under 120px snaps to the collapsed rail and calls onToggleCollapsed, rather than clamping to minWidth; expanding again restores the last dragged width. When storageKey is given, { collapsed, width } persists as one object; a bare boolean already under that key from before this prop existed is read once and folded into the new shape.

Slots and DOM:

  • Slot classnames per AppShellClassNames: className (root), sidebarClassName, topbarClassName, contentClassName. No shipped usage found for any of the four; the one consumer accepts the shell's default look throughout.
  • The content area publishes its own padding as the --shell-inset CSS variable (1.5rem below lg, 2rem at lg+) so a sticky child can cancel exactly that inset (app-shell.tsx:64-68), the same pattern Card uses for --card-inset.

Laws it enforces#

  • Every composite declares its narrow behaviour: the sidebar rail gives every item a visible mark, initials when there is no icon.
  • Machine values stay exact, human text stays human: the shell title is not a second h1.

Sanctioned combinations#

CombinationProducesWhere usedWhy
Every NavItem.active computed from a typed pathname match, activeHref never passedActive state that cannot drift from a prefix matchApp.tsx:65, 78-100 (buildNav's isActive helper applied to every item)Canon doc 8 §A3: prefix matching double-stamped active state once already; explicit active is the fix
A badge built from a small local component that returns null on value == nullA nav count that shows nothing until the real number has loaded, rather than a skeleton or a zeroApp.tsx:51-62 (CountPill), wired at App.tsx:88, 90, 94Canon doc 8 §B: "An undefined count renders no badge at all"
The same AppShell instance (same logo, railLogo, nav, renderLink) rendered for the auth-boot state, with only sidebarTop/sidebarFooter swapped to Skeleton and children swapped to a per-route skeletonBoot chrome that is pixel-identical to loaded chrome, so the two can never drift apartApp.tsx:429-451Source comment states it directly: "the REAL AppShell renders (same components as the loaded app)... Only data slots skeleton"
collapsed controlled from app state, persisted to localStorage, with an app-owned Cmd/Ctrl+B handler calling the same setter onCollapsedChange receivesA sidebar-collapse preference that survives a reload and has a keyboard shortcut the kit does not provide itselfApp.tsx:382-407The kit's toggle button covers the click path only; an app that wants a shortcut or persistence supplies both itself through the controlled prop pair

Banned combinations#

Do not infer active from activeHref plus a prefix check done outside the component, for example matching pathname.startsWith(item.href) and never setting item.active. The canon names this exact mistake, already made once and recorded as scar tissue: NavLink prefix matching double-stamped aria-current on a parent from every one of its sub-pages (08-navigation-and-shell.md §A3, citing WorkspaceNav.tsx:85-88). Compute active explicitly per item instead.

Do not render a page's own breadcrumb, sidebar, or topbar markup inside children expecting AppShell to absorb it. The canon's shell principle is that chrome is infrastructure a route declares into, never markup a page repeats (08-navigation-and-shell.md §A1); AppShell's children is the content region only.

Do not mount a second toast stack, modal host, or file-preview host per shell instance. The canon states the asymmetry directly: toasts mount once globally, modals are not centrally mounted (each callsite renders its own locally), and a second file-preview host would double-render (08-navigation-and-shell.md §B, line 41). AppShell has no toast, modal, or preview slot of its own for this reason; see Travels with.

  • A nav group kicker lighter than /65, or a hand-rolled indent on the rows: a lighter ink fails AA at 11px; hierarchy in a long column comes from navGrouping="railed" (quiet kicker, rows one step in behind a rail, canon doc 08 §A10), never from restyling the kicker or rows from an app.

Before you adopt this#

Five questions to answer before mounting AppShell or adding anything to its chrome. Each names the failure it catches; a "yes" to the first two means stop.

  1. Does the shell, a parent layout, or a global provider already render this? A second theme toggle, search box, breadcrumb trail, toast stack, modal host, or preview host is a duplicate, not a feature. The recorded case: a floating bottom-right theme toggle shipped on the playground landing while the navbar already carried one (owner O3, 2026-08-16); the navbar toggle is the only one on every page, and a floating page-level toggle is an anti-pattern.
  2. Does this app already ship a local implementation of the same thing? Adopt the kit one or keep the local one; two implementations of one chrome element is the defect (docs/SCRIPTURE.md:274-278 records the walmart Select and status-tone cases).
  3. Does this app's kit pin reach the version this component or prop landed in? Read packages/ui/package.json against the app's lockfile before writing to a prop; a pin behind the prop fails silently at runtime, not at typecheck (method/app-patterns/12-primitives-and-rules.md:54-60).
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? AppShell's collapse toggle covers the click path only; a shortcut and persistence are the app's to wire through collapsed / onCollapsedChange (App.tsx:382-407).
  5. Which canon §A rules bind this surface, and which does the composition break? For the shell: chrome is infrastructure a route declares into, never markup a page repeats (08-navigation-and-shell.md:15), and active state is explicit, never inferred by prefix (08-navigation-and-shell.md:20). The generated do-nots page (method/DO-NOTS.md) lists every rule that names this component.

Travels with#

Toasts mounts once, as a sibling of AppShell rather than inside any of its slots. The shipped consumer renders <Toasts /> immediately before <AppShell> at the same level (App.tsx:498-499).

Modal / useModal are never centrally mounted through AppShell; each call site renders its own, also as a sibling rather than through a shell slot. The shipped consumer's "New organization" modal renders directly after </AppShell> closes, at the same level as the shell itself (App.tsx:540), not inside any AppShell prop.

Skeleton fills sidebarTop and sidebarFooter during the auth-boot render of the same AppShell instance (App.tsx:441, 444). SkeletonGroup and per-route skeleton modules fill children for the same boot state; see spinner.md's "boot skeleton" pattern, which this is the shell-level half of.

Snippet#

import { AppShell, WorkspaceSwitcher } from "@versable-git/ui";
// walmart-mvp/frontend/src/App.tsx:501-524 (trimmed)
<AppShell
logo={<Logo />}
railLogo={<RailLogo />}
nav={buildNav(location.pathname, isStaffEmail, navCounts)}
topbar={{ title: <Crumb pathname={location.pathname} /> }}
sidebarTop={
<WorkspaceSwitcher
workspaces={orgs.map((o) => ({ id: o.id, name: o.name, subtitle: o.role }))}
value={activeOrg.id}
onChange={(id) => selectOrg(id)}
onCreate={() => openModal(NEW_ORG_MODAL)}
createLabel="New organization"
/>
}
sidebarFooter={
<div className="p-2">
<SidebarUserMenu user={user!} activeOrg={activeOrg} onLogout={logout} />
</div>
}
renderLink={RouterNavLink}
collapsed={sidebarCollapsed}
onCollapsedChange={handleCollapsedChange}
>
<Outlet context={{ user: user!, orgs, activeOrg, /* ... */ }} />
</AppShell>
@versable-git/ui · reference, canon, and method, read in place