---
name: shell-admin
load-when: authoring or debugging an admin-shell app frame (sidebar + topbar + command palette + pages)
load-size: ~2.5k tokens
required-for: [shell-selection — admin path]
---

# admin-shell — the app frame

Full SaaS/admin chrome from `@adia-ai/web-modules`. Register the **cluster barrel**: `import '@adia-ai/web-modules/shell'`. (Claims re-verified against the kit's live source 2026-08-26; re-verify on a MINOR cut.)

## Cluster roster

`<admin-shell>` (host coordinator) · `<admin-sidebar>` (resizable/collapsible, `slot="leading"|"trailing"`) · `<admin-command>` (Cmd+K palette) · `<admin-content>` (center column) · `<admin-topbar>` / `<admin-statusbar>` (chrome bars, shared slots) · `<page-scroll>` (vertical scroll container) · `<page-ui band>` + `<header-ui>` / `<section-ui>` / `<footer-ui>` (the page; header/footer always-sticky with border+background, body scrolls) · `<admin-entity-item>` (icon+label+badge identity row). Three are JS-bearing (shell, sidebar, command); the rest are CSS-only structure.

## Canonical skeleton

```html
<admin-shell mode="rounded borderless">
  <admin-sidebar slot="leading" resizable collapsible>
    <admin-topbar slot="header"><span slot="heading">Workspace</span></admin-topbar>
    <nav-ui>…</nav-ui>
    <admin-statusbar slot="footer"><admin-entity-item slot="heading">…</admin-entity-item></admin-statusbar>
    <div data-sidebar-resize></div>                          <!-- required when [resizable] -->
  </admin-sidebar>
  <admin-content>
    <admin-topbar slot="header">
      <button-ui data-sidebar-toggle="leading" icon="sidebar" variant="ghost"></button-ui>
      <breadcrumb-ui slot="heading">…</breadcrumb-ui>
    </admin-topbar>
    <page-scroll>
      <page-ui band max-width="xwide" padding="10">
        <header-ui><span slot="heading">Page</span></header-ui>
        <section-ui>…</section-ui>
      </page-ui>
    </page-scroll>
    <admin-statusbar slot="footer"><span>Status</span></admin-statusbar>
  </admin-content>
  <admin-command><command-ui placeholder="Search…"></command-ui></admin-command>
</admin-shell>
```

## Props · events · methods

- `<admin-shell mode>` — space-separated (`rounded` `borderless`; default `"rounded borderless"`). The host has **NO public methods and forwards no events** — its whole behavior is delegated click routing: a `[data-sidebar-toggle="<name>"]` click anywhere reaches the matching sidebar's `.toggle()`, `[data-command-trigger]` reaches `<admin-command>.show()`, and `command-select` → nav routing. Programmatic control lives on the CHILDREN (below); the events `sidebar-toggle {name, expanded}` / `sidebar-resize {name, width}` / `command-select {value}` are dispatched by the children and **bubble** — listen on the shell, but don't model it as the API owner.
- `<admin-sidebar>` — `resizable` `collapsible` `name` `min-width`; reflects `[collapsed]` (snap ≤96px) / `[resizing]`. Methods `.toggle()/.collapse()/.expand()`. A `[data-sidebar-toggle="leading"]` button anywhere wires to it via delegation.
- `<admin-command>` — `open` `shortcut` (`both`|`cmd+k`|`ctrl+k`) `no-shortcut`; `.show()/.hide()`. A `[data-command-trigger]` opens it.
- Read cross-cutting state off the child: `shell.querySelector('admin-sidebar[slot="leading"]').hasAttribute('collapsed')`; style with `admin-shell:has(admin-sidebar[collapsed]) …`.

## Subnav-rail pages

For a page that needs its own left-rail nav (e.g. a Settings page with Preferences/Account
sections), stamp `<aside data-subnav>` as the first child of `<page-scroll>`, followed by
`<admin-page>`: `page-scroll` grid-layouts automatically once it detects an unhidden
`[data-subnav]` sibling — the rail scrolls independently in its own column (`overflow-y: auto`)
while the content column scrolls separately; neither drags the other out of view. Size the rail
with `--subnav-width` (default `14rem`). Toggle the rail with the `hidden` attribute, not by
removing it — the grid layout keys off `[data-subnav]:not([hidden])`.

## Chrome-tier vs content-tier — the allocation model

The most common admin-shell layout regression is putting **page-tier content in shell-tier
chrome** (or the reverse). The split is strict: shell-tier chrome is *persistent* — it renders
identically across every route (sidebar nav, brand, the topbar's breadcrumb + global controls,
the command palette, the statusbar). Page-tier content is *per-route* — it changes with the view
(the page header's `<h1>` + page CTAs, the page body). What lives where:

| Region | Container | Belongs here | Does NOT |
|---|---|---|---|
| Shell sidebar | `<admin-sidebar slot="leading">` | primary nav; brand in `slot="header"`; **user-account controls only** in `slot="footer"` | app-wide preference toggles (scheme/theme) |
| Shell topbar | `<admin-topbar>` in `<admin-content>` | sidebar-toggle + **breadcrumb** (location); trailing cluster = **persistent global controls** (theme, notifications, command trigger) | page title, page CTAs, dataset search |
| Page header | `<header-ui>` inside `<page-ui band>` | **page title `<h1>`** + page-scoped CTAs (`+ New`, `Export`) via `slot="action"` | location chrome |
| Page body | `<section-ui>` inside `<page-ui band>` | the page's primary content | — |
| Table toolbar | `<table-toolbar-ui>` adjacent to `<table-ui>` | in-page search/filter/sort scoped to the table | — |
| Statusbar | `<admin-statusbar slot="footer">` | app-wide status (sync, row count, version) | — |

**The four allocation anti-patterns** (each silently breaks the model, no warning):

- ❌ **Page CTA (`+ New Claim`) in the shell topbar** → it sticks across every route and never updates. Page CTAs go in the `<header-ui>` inside `<page-ui band>`, via `slot="action"`.
- ❌ **Page title (`<h1>`) in the topbar** instead of a breadcrumb → the topbar carries *location*; the breadcrumb's terminal segment IS the page title.
- ❌ **Dataset-search input in the topbar** → a search scoped to one dataset belongs in `<table-toolbar-ui>`, not the app-wide topbar.
- ❌ **Scheme/theme toggle in the sidebar footer** → it reads as nav and vanishes when the sidebar collapses. App-wide preferences belong in the topbar trailing cluster.

## SPA vs SSR

SPA mounts the full markup. SSR keeps the shell + chrome fixed and swaps only the **page body** via the framework outlet — replace the inner page content with `{children}` / `<slot/>`; never mount `<router-ui>` (see `host-wiring`).

## Gotchas (mechanized where noted)

- **Piecemeal import** → `AdminSidebar`/`AdminCommand` unregistered; `.toggle()/.show()` undefined. Import the barrel.
- **Wrapping shell children in `<col-ui>`/`<row-ui>`** → breaks the grid (it reads tag selectors). Generics go _inside_ `admin-content`/`page-ui`'s `<section-ui>`.
- **Raw `<header>` vs `<header-ui>` inside `<page-ui band>`**: prefer `<header-ui>`. `page-ui`'s `@scope` rules target both `<header>` and `<header-ui>` identically for geometry, but only `<header-ui>` has the shadow DOM that routes `slot="heading"` / `slot="description"` / `slot="icon"` / `slot="action"` children — a raw `<header>` matches the layout rhythm but silently drops every named slot.
- **`[resizable]` without a child `<div data-sidebar-resize>`** → no drag handle.
- **Hardcoded `[collapsed]`** → won't auto-clear on resize; use `.collapse()/.expand()`.
- **Multiple `<page-ui>` in one `<page-scroll>`** → single-axis scroll breaks; one page per scroll.
- **`@container (…)` instead of `@container page-content (…)`** → won't react to sidebar collapse.
- **Legacy shapes** (`<aside data-sidebar>`, `<dialog data-command>`) — retired v0.4.0 (`adia-lint` `LEGACY-SHELL`).
- **Sidebar nav is `<nav-ui>` + `<nav-item-ui>`**, never `<menu-ui>`/`<menu-item-ui>` — menu-ui is for Popover-API dropdowns, not persistent navigation.
- **Full-height mount:** an intermediate wrapper (`<main id="app">`) between `body { height: 100dvh; display: flex }` and the shell needs `flex: 1; display: flex; min-height: 0` — without it the flex chain breaks and the shell collapses to content height.
- **`header-ui` has no CSS of its own** — its icon/heading/description/action grid comes from the parent's `@scope` (`page-ui` provides it); bespoke chrome reusing the header-ui vocabulary must supply the grid + text ellipsis locally.
- **A persistent `<drawer-ui data-mobile-nav-drawer>` is always in the DOM** (relocated mobile-nav mechanism, gh#1984/ADR-0090), not conditionally created — only its visibility responds to a container query at the shell's mobile-nav breakpoint. A selector assuming exactly one `drawer-ui`/`dialog` on the page (e.g. an E2E test) must scope past it: `drawer-ui:not([data-mobile-nav-drawer])`.

Real usage: `apps/saas/app/admin-dashboard/`.
