# App shells

> An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and a menu behind them, a search slot and a place for a page's own actions. You declare that ONCE, as an `AppChrome`, and hand it to the shell you want. Every shell takes the same value and the same slots, so changing arrangement is one word and you lose nothing but the layout.

<!-- id: ui-shell · area: ui · stability: stable · html: https://osysharp.com/reference/ui/shell/ -->

## Summary        {#summary}

A shell is the frame every page renders inside. You declare what it holds **once** — in your `[Layout]` — and hand
that one value to whichever arrangement you want:

```osy title="a whole app's chrome, and the shell that arranges it" test app=ui-shell
[Layout]
[AllowAnonymous]
component AppShell() {
  bool accountOpen = false;
  string accountName = "Olivia Rhye";

  action OpenAccount() { accountOpen = true; }
  action CloseAccount() { accountOpen = false; }
  action Appearance() { Theme.Toggle(); }
  action SignOut() { Session.SignOut(); }
  action Palette() { }
  action Inbox() { }
  action Help() { }

  render {
    SidebarShell(new AppChrome {
      Product = "Expensely",
      Tagline = "Finance operations",
      Mark = Icons.Chart,
      Home = "/",
      User = new ShellUser {
        Name = "Olivia Rhye",
        Secondary = "Finance manager",
        Initials = "OR",
        Menu = [
          new MenuAction { Label = "Account", Icon = Icons.User, OnPress = OpenAccount },
          new MenuAction { Label = "Appearance", Icon = Icons.Eye, OnPress = Appearance },
          new MenuAction { Label = "Sign out", Icon = Icons.Logout, OnPress = SignOut, Tone = Tone.Danger, Divided = true },
        ],
      },
      Nav = [
        new NavItem { Label = "Dashboard", To = "/", Icon = Icons.Home },
        new NavItem { Label = "Approvals", To = "/approvals", Icon = Icons.CheckCircle, Badge = "12", BadgeTone = Tone.Warning },
        new NavItem { Kind = NavKind.Section, Label = "Administration", Children = [
          new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
            new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
            new NavItem { Label = "Billing", To = "/settings/billing", Icon = Icons.Tag, Children = [
              new NavItem { Label = "Invoices", To = "/settings/billing/invoices", Icon = Icons.File, Children = [
                new NavItem { Label = "Drafts", To = "/settings/billing/invoices/drafts", Icon = Icons.Pencil },
              ] },
            ] },
          ] },
        ] },
      ],
    }) {
      Outlet(retain: 8);
      slot search { ShellSearch("Search requests, people or departments", onPress: Palette); }
      slot actions { ShellCountButton("Notifications", 3, onPress: Inbox) { Icon(Icons.Bell, size: 18); } }
      slot railFoot {
        ShellFootCard("Need a hand?", "Read the expense guides, or ask us.", "Visit help centre", onPress: Help);
      }
      slot aside {
        if (Navigation.CurrentPath == "/approvals") {
          ShellAside("Waiting on you") {
            Text("Beside the page", fontWeight: FontWeight.Semibold);
            Text("Three requests need a decision today.", fontSize: FontSize.Caption, color: Colors.TextMuted);
          }
        }
      }
    }
    if (accountOpen) {
      Dialog("Account", onDismiss: CloseAccount) {
        Field("Display name", value: accountName);
        slot actions { Button("Save", onPress: CloseAccount, tone: Tone.Primary); }
      }
    }
  }
}

[Page("/")] [Layout(AppShell)] [Title("Dashboard")] [Render(CSR)] [AllowAnonymous]
component Home() {
  render {
    PageHead("Dashboard", subtitle: "What is waiting on you.");
    Card("This month") { Text("Nothing needs you right now."); }
  }
}

[Page("/approvals")] [Layout(AppShell)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component Approvals() {
  render {
    PageHead("Approvals");
    Card("Waiting") { Text("Three requests need a decision today."); }
  }
}

[Page("/settings")] [Layout(AppShell)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component Settings() { render { PageHead("Settings"); } }

[Page("/settings/people")] [Layout(AppShell)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component People() { render { PageHead("People"); } }

[Page("/settings/billing")] [Layout(AppShell)] [Title("Billing")] [Render(CSR)] [AllowAnonymous]
component Billing() { render { PageHead("Billing"); } }

[Page("/settings/billing/invoices")] [Layout(AppShell)] [Title("Invoices")] [Render(CSR)] [AllowAnonymous]
component Invoices() { render { PageHead("Invoices"); } }

[Page("/settings/billing/invoices/drafts")] [Layout(AppShell)] [Title("Drafts")] [Render(CSR)] [AllowAnonymous]
component Drafts() { render { PageHead("Drafts"); } }
```

`SidebarShell` is the arrangement. `TabbedShell`, `RailShell` and `FocusedShell` take the **same** `AppChrome` and
the **same** slots, so switching is that one word — see [what each arrangement does](#arrangements).

## Signature      {#signature}

```osy syntax
SidebarShell(chrome)  ·  TabbedShell(chrome)  ·  RailShell(chrome)  ·  FocusedShell(chrome)

class AppChrome {
  string   Product;      // the product's name
  string   Tagline;      // a second line — the workspace, the tenant, the environment
  Icons?   Mark;         // the brand mark as a GLYPH — yours or a built-in; unset draws the product's initial
  string   MarkSrc;      // …or as an IMAGE — a full-colour logo, a tenant's own. Wins over `Mark`
  string   Home;         // where the brand lockup goes when pressed
  NavItem[] Nav;         // the navigation TREE
  ShellUser User;        // who is signed in, or null for nobody
  bool     Loading;      // draw skeleton nav rows instead of an empty column
}

class NavItem {
  string    Label;  string To;  Icons Icon;   // a built-in, or any `.svg` your app ships
  NavItem[] Children;    // non-empty ⇒ a GROUP, unless `Kind` says Section
  NavKind   Kind;        // Link (default) · Group (inferred) · Section
  string    Badge;  Tone BadgeTone;
  bool      Exact;       // match this route exactly, never as a prefix
}

class ShellUser  { string Name, Secondary, Initials, AvatarSrc;  MenuAction[] Menu; }
class MenuAction { string Label;  Action OnPress;  Icons Icon;  Tone Tone;  bool Divided; }

// the slots — the same six on every shell. A slot reserves a POSITION; the control you put in it
// brings the chrome, which is why an unfilled slot costs nothing.
(default)  the routed page — your `Outlet(retain: n)`
search     a global search affordance   · `ShellSearch(placeholder, onPress, hint)`
actions    top-bar controls             · `ShellCountButton(label, count, onPress) { Icon(…); }`
railFoot   pinned at the foot of a rail · `ShellFootCard(title, body, actionLabel, onPress)`
aside      a right rail                 · `ShellAside(label) { … }` — a column beside the page where
                                          there is room, a section under it where there is not
```

## Description    {#description}

### What an app declares, and what a shell decides   {#chrome}

`AppChrome` is **what**; the shell is **how**. The app says "these are my sections, this is who is signed in, this
is my product"; the shell decides whether that becomes a left rail, a tab strip, an icon strip or nothing at all.

That split is why the nav is **data** and not slot children. A `Nav { NavItem(…) NavItem(…) }` block reads nicely
and cannot be REARRANGED — the child components would decide their own layout, so a tabbed shell handed a rail's
children renders a rail. A shell has to be able to walk the tree, flatten it, nest it, or drop it. Everything a
shell genuinely cannot rearrange — a search box, a page's own buttons — stays a slot.

⚠ **Build the chrome at the CALL SITE, not in a `live var`.** A `MenuAction` carries an `Action`, and an action may
only be set where the platform can run it. Hoisting the whole `new AppChrome { … }` into a `live var` is refused,
and the refusal says so.

### How a nav row knows it is the page you are on   {#nav}

Active state is **derived from the route**, never passed in per page. Getting this wrong is the single thing that
makes a shell feel fake, so the rule is stated once, in `AppChrome.CurrentRoute`, and every shell uses it:

- a row **matches** `path` when `To == path`, or when `path` starts with `To + "/"` — so `/orders` claims
  `/orders/42` and never `/orders-archive`;
- **`/` is exempt from prefix matching.** The home route is a prefix of every path in the app, so without this Home
  is lit on every page. Set `Exact = true` on any other row that must not claim its own children;
- exactly one row is **current**: the LONGEST match across the whole tree wins. With `/settings` and
  `/settings/users` both in the nav, a per-row test lights *both* on the child's page, and two current rows read as
  a bug;
- every ancestor of the current row is **on the trail** — a lighter treatment, and it OPENS.

**How deep can a nav go?** As deep as you like. A shell draws three levels of INDENT — section, group, leaf — and
flattens everything below the third to it, so a row five levels down appears beside its parent rather than
disappearing. Active state is computed over the whole tree at any depth, by the same walk that decides what renders,
so the row you are standing on is marked wherever it sits.

```osy title="one row current, its ancestors open — the rule, not a per-page flag" syntax
/settings/policy/chains   ⇒  Administration  section, shown
                             Settings        group, on the trail, OPEN
                             Policy          group, on the trail, OPEN
                             Approval chains CURRENT
```

⭐ **A deep link arrives with its ancestors already open.** Landing straight on `/settings/policy/chains` — from a
bookmark, from an email — must not leave the current row hidden inside a collapsed group. A group is open when it
holds the current route; a reader who then closes it is remembered, so it does not spring back.

### The person, and the menu behind them   {#identity}

`ShellUser` is what a shell shows. The app supplies it: `Session.CurrentUser` is the obvious source, but which of
your own columns is the NAME and which is the ROLE is your app's question, not the kit's.

`Menu` is the app's too — the shell never hardcodes which entries exist, so an app with no billing has no Billing
row. Each `MenuAction` carries a verb, and a verb may open a **dialog**:

```osy title="a menu item that opens a dialog" syntax
new MenuAction { Label = "Account", Icon = Icons.User, OnPress = OpenAccount }

action OpenAccount() { accountOpen = true; }     // …and render a `Dialog` beside the shell
```

The shell closes its own menu and drawer *before* running your action, so a dialog opens over a clean page. Render
the `Dialog` next to the shell in your layout, not inside it — nothing the rail does with transforms or overflow can
then clip it.

⚠ **A user with no `Menu` gets a LABEL, not a button.** A chip that opens nothing is an affordance that lies.

### What a page contributes, and what it cannot   {#slots}

A page renders inside an `Outlet`, so it **cannot fill a slot of the shell that hosts it**. Two consequences worth
knowing before you design around them:

| you want | where it goes |
|---|---|
| the bar's title | nowhere — the shell reads the page's own `[Title("…")]` (or its last `Navigation.SetTitle`) |
| a page's own actions | on the page, in its `PageHead(…)` |
| a right rail | the layout's `slot aside`, keyed on the route, holding a `ShellAside(…)` — or the page's own two-column `Row` |
| global search, notifications, help | the layout's `search` / `actions` / `railFoot` slots |

The title is the one worth pausing on: a page names itself once, with `[Title]`, and every shell's bar follows. There
is nothing to keep in sync, and a page that declares no title leaves the bar empty rather than inventing one from
the URL.

### What changes at each width?   {#bands}

A shell is not one layout that stretches. `SidebarShell` reads the band **once per render** and everything follows
from it — the rail's width, whether a group is an accordion or a flyout, whether a menu is a dropdown or a sheet,
whether `aside` is a column or a section:

| band | width | the design |
|---|---|---|
| **compact** | < 768 | the rail is an off-canvas DRAWER over a scrim; a menu button in the bar; menus are bottom SHEETS within thumb reach; `aside` stacks under the page |
| **cozy** | 768+ | the rail DOCKS as a 60px icon strip — always visible — and a group opens as a FLYOUT beside it; one content column |
| **wide** | 1100+ | the rail opens to 264px with labels, badges and section headings; the page and its `aside` sit SIDE BY SIDE |

⭐ **The wide band is not the narrow one stretched.** A single centred column at 1440px is a phone layout on a
desktop: dead space, and one thing visible at a time. A wide screen should show MORE — that is what it is for.

⭐ **And the compact band is not the wide one squeezed.** Every target is at least `Length.Touch` (44px), nothing
load-bearing is reachable only by hovering, and overlays are sheets rather than dropdowns pinned to a corner a thumb
cannot reach.

### What each arrangement does with the same declaration   {#arrangements}

| shell | nav on a pointer | nav on a phone | identity | when to reach for it |
|---|---|---|---|---|
| `SidebarShell` (this page) | a persistent left rail; groups disclose in place | an off-canvas drawer | a chip in the top bar | the default for anything with more than about five sections |
| [TabbedShell](https://osysharp.com/reference/ui/shell-tabbed/) `TabbedShell` | a horizontal strip under the brand row; a group becomes a dropdown | a **bottom tab bar**, plus a "More" sheet | a chip in the brand row | a handful of peer sections, and a phone-heavy audience |
| [RailShell](https://osysharp.com/reference/ui/shell-rail/) `RailShell` | icons only, always; labels arrive on hover and focus | a labelled off-canvas drawer | **at the rail's foot** | a tool where the canvas is the product |
| [FocusedShell](https://osysharp.com/reference/ui/shell-focused/) `FocusedShell` | **none** — the `Nav` becomes an ordered set of STEPS | the same, as "Step 2 of 4" | minimal, beside the exit | a wizard, a checkout, a reader |

⚠ **`FocusedShell` drops NAVIGATION, not the nav DATA**, and the distinction is the whole reason switching to it
costs nothing. A focused flow has one job and a rail beside it is an invitation to leave — so there is no rail. But
the same `Nav` you already declared is re-read as a linear FLOW: a top-level entry is a step, in order, and its
children are that step's parts. You declare nothing new to switch, and nothing is lost switching back.

⚠ **THIS TABLE DESCRIBED FOUR SHELLS WHILE ONE EXISTED**, from 2.3.0 until 2.5.0. Two rows were wrong when the
other three actually shipped and are corrected above: `TabbedShell`'s identity is in the brand row rather than at
the strip's end (the strip needs its full width for tabs), and `FocusedShell` shows the `Nav` as steps rather than
hiding it. Read a version note as a promise until the thing exists.

### The standard a shell has to meet   {#standard}

This is the bar `SidebarShell` was built to, and what the other arrangements are held to. It is here rather than in
a brief because a standard that lives in a brief expires; a downloader forking a shell should be able to read it.

**Responsive** — three bands, each designed on its own terms. Judge each width by "is this the best layout for a
screen this size", never by "does it survive being resized to this".

**Touch** — every interactive target at least `Length.Touch`; `Length.TouchDense` only where the neighbours are the
same control and a miss is harmless. Nothing load-bearing behind a hover: a hover-only disclosure does not exist on
a touch screen, so a group needs a tap path at every width.

**Overlays** — a drawer sits over its own scrim (`ZIndex.Drawer`) and under a dialog (`ZIndex.Modal`). Opening one
overlay closes the others the shell owns, so nothing is left hanging underneath. On a phone a menu is a bottom
sheet, and a sheet still traps focus, still closes on Escape, and still returns focus where it came from.

**Active state** — derived from the route by longest match, never hand-passed. Exactly one row current; ancestors on
the trail and open; a deep link arrives open.

**Landmarks and keyboard** — `nav`, `banner` and `main` regions, each NAMED (a page with two unnamed `nav` regions
is unnavigable). A visible focus ring on every control — replace the default, never remove it. `current:` on the
current nav row, so a screen reader announces "current page" and not just a colour.

**Overflow** — a long product name, a 35-character nav label, twenty rows, a user with no avatar and an empty nav
all leave the layout intact. Truncate with intent: `TextOverflow.Ellipsis` needs `WhiteSpace.Nowrap` and
`Overflow.Hidden` beside it or there is nothing to clip. **The page body never scrolls sideways at any width** —
`minW: "0"` on the work column is what lets a wide table scroll inside it instead of shoving the shell.

**Images** — the brand mark, a nav glyph and an avatar are the three, and an app can supply ALL of them: one
vocabulary (`Icons`, which holds the app's own `.svg` files beside the built-ins) for anything drawn as a glyph, one
`…Src` string for anything whose address is only known at runtime, and a derived fallback for each so nothing is
required to get a shell. ⚠ A shell that lets an app choose its user's photograph but not its own logo has the
priority backwards — that was true here until 2.4.0, and it is written down so the next arrangement does not
inherit it.

**Both themes** — every colour from a token, never a literal, and dark checked rather than assumed. Borders and
elevation are where a dark theme fails first.

**Hierarchy** — primary, secondary and destructive actions visually distinct; nav sections carry group labels; icons
consistently sized and optically centred. Motion from `Motion.Fast` / `Motion.Normal`: a shell with no transitions
feels dead, one with slow transitions feels cheap.

**Loading and empty** — the shell owns the states it can own. `chrome.Loading` draws skeleton nav rows rather than an
empty column, and an empty `Nav` draws a sentence rather than a blank rail.

**A slot is a POSITION, never chrome** — and this one is a rule rather than a preference, because breaking it is
invisible. A shell reserves *where* optional content goes; the width, the border, the fill and the sticky box arrive
with the content, in the control the app puts there — `ShellSearch`, `ShellCountButton`, `ShellFootCard`,
`ShellAside`. A shell that draws the box itself draws it whether or not anyone filled the slot: `SidebarShell` did
exactly that for `aside` and cost every app that ignored it **26% of a 1280 screen**, a full-height hairline and a
band of `Surface`, for an empty region.

⚠ **And you cannot fix that by asking whether the slot was filled.** The renderer collapses an element whose whole
content is an unfilled named slot, but it deliberately stops short of a container whose contents can *arrive* —
collapsing one would move layout under the reader. An aside is exactly that container: it is keyed on the route, and
a route-keyed fill is an `if`, which leaves an anchor whichever way it goes. So a "was it filled?" answer is
computed once, at build, and is wrong the moment the reader navigates. Let the caller bring the box and the question
never arises.

### It is a floor, not a ceiling   {#forking}

Every shell is ordinary Osy# whose source ships. `osy kit SidebarShell` prints it, and declaring a component of the
same name in your own app shadows it whole — nothing here is privileged and nothing is unforkable. The interior
pieces are marked `[Part]`, so they stay out of `osy kit`'s catalogue while remaining just as forkable.

Fork when you outgrow it; compose first. Most of what a shell needs varying is already a slot or a field.

### The three images a shell draws   {#images}

A shell shows a **brand mark**, a **glyph per nav row and menu row**, and the signed-in person's **avatar**. There
is one vocabulary and one fallback rule for all of them, and both are worth learning once:

| | what you set | falls back to |
|---|---|---|
| brand mark | `Mark = Icons.X` (a glyph) or `MarkSrc = "…"` (an image) | the product's first letter |
| nav row · menu row | `Icon = Icons.X` | `Icons.Folder` · `Icons.ChevronRight` |
| the person | `AvatarSrc = "…"` | `Initials`, then a person glyph |

⭐ **`Icons` is YOUR vocabulary, not a kit one.** Every `.svg` under your app's `icons` glob is a member of it
beside the built-ins, under the name of the file — `icons/receipt.svg` is `Icons.Receipt` — and the shell holds
exactly that type. So an app icon and a built-in are written the same way, in the same field, and nothing about a
nav row prefers the ones the platform happens to ship:

```osy title="an app's own two glyphs, in the brand and in the nav" test app=file-manager
// `Icons.Logo` and `Icons.Chev` are this app's OWN files — `model/icons/logo.svg` and `model/icons/chev.svg`.
// Neither is a built-in, and neither is spelled differently from `Icons.Home` two lines down.
[Layout]
[AllowAnonymous]
component BrandedShell() {
  render {
    SidebarShell(new AppChrome {
      Product = "Ledger", Home = "/", Mark = Icons.Logo,
      Nav = [
        new NavItem { Label = "Files", To = "/", Icon = Icons.Home },
        new NavItem { Label = "Archive", To = "/archive", Icon = Icons.Chev },
      ],
    }) { Outlet(retain: 4); }
  }
}
```

⚠ **A GLYPH TAKES THE SHELL'S COLOUR; AN IMAGE KEEPS ITS OWN.** `Mark` is drawn like every other icon — one colour,
inherited — on the brand tile. A logo with colours of its own is not a glyph, so it goes in `MarkSrc`, which
replaces the tile rather than tinting what you put on it.

⭐ **`MarkSrc` is also how a per-tenant logo works.** It is a plain `src`, so it can come from a row: a white-label
app reads its tenant's logo at render time and hands it over, exactly as it already does for a user's photograph.
That is the reason `AvatarSrc` stays a `string` rather than folding into `Icons` — a design asset is known when you
compile, and a person's photograph is not.

### What it cannot do yet   {#limits}

⚠ **A slot cannot hold the brand mark.** `Mark`/`MarkSrc` cover a glyph and an image; an arbitrary node there — a
full-colour inline `Svg(Art.X)`, a wordmark you want to lay out yourself — needs a fork of `ShellBrand`, which
`osy kit ShellBrand` prints for you.

## See also       {#see-also}

- [TabbedShell](https://osysharp.com/reference/ui/shell-tabbed/) — top tabs on a pointer, a bottom tab bar on a phone
- [RailShell](https://osysharp.com/reference/ui/shell-rail/) — a permanent icon rail with flyout labels
- [FocusedShell](https://osysharp.com/reference/ui/shell-focused/) — one task, no navigation, the nav read as progress
- [layout primitives](https://osysharp.com/reference/ui/layout/) — `Stack`, `Row`, `Box`, and how `gap`/`align`/`justify` work
- [routes and pages](https://osysharp.com/reference/ui/routing/) — `[Page]`, `[Layout]`, `Outlet` and retention
- [Navigation](https://osysharp.com/reference/ui/navigation/) — `Navigation.Routes`, `[Title]`, and what a shell reads from them
- [Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard](https://osysharp.com/reference/ui/dialogs/) — `Dialog.Open` and the unit-of-work choice a modal makes
- [accessibility](https://osysharp.com/reference/ui/accessibility/) — landmarks, `role:`, `current:` and the naming props
- [Osysharp.Ui (the UI kit)](https://osysharp.com/reference/ui/kit/) — the rest of the controls a shell frames
