# RailShell

> An app shell built around a permanently narrow icon rail, in the shape Slack, Linear and Discord converge on. Every row reveals its label on hover and on keyboard focus, groups open a flyout beside the rail, and the signed-in person sits at the rail's foot rather than in the top bar.

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

## Summary        {#summary}

`RailShell` is a **permanently narrow** icon rail — 60px, at every pointer width, with no collapse control. Reach
for it when the canvas is the product and the navigation should take as little of it as possible, and when the app
has few enough top-level sections that a glyph each is genuinely legible.

```osy title="the same chrome, arranged as a rail" test app=ui-shell-rail
[Layout]
[AllowAnonymous]
component RailLayout() {
  action Palette() { }
  action SignOut() { Session.SignOut(); }

  render {
    RailShell(new AppChrome {
      Product = "Expensely",
      Home = "/",
      User = new ShellUser {
        Name = "Olivia Rhye", Secondary = "Finance manager", Initials = "OR",
        Menu = [ new MenuAction { Label = "Sign out", Icon = Icons.Logout, OnPress = SignOut, Tone = Tone.Danger } ],
      },
      Nav = [
        new NavItem { Label = "Overview", To = "/", Icon = Icons.Home, Exact = true },
        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 },
          ] },
        ] },
      ],
    }) {
      Outlet(retain: 8);
      slot search { ShellSearch("Search", onPress: Palette); }
    }
  }
}

[Page("/")] [Layout(RailLayout)] [Title("Overview")] [Render(CSR)] [AllowAnonymous]
component ROverview() { render { PageHead("Overview"); Card("This month") { Text("Nothing needs you."); } } }

[Page("/approvals")] [Layout(RailLayout)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component RApprovals() { render { PageHead("Approvals"); } }

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

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

## Signature      {#signature}

```osy syntax
RailShell(AppChrome chrome) {
  Outlet(retain: 8);          // the routed page
  slot search   { … }         // the top bar
  slot actions  { … }         // the top bar, right of search
  slot railFoot { … }         // the phone DRAWER only — a 60px rail cannot hold a card
  slot aside    { … }         // beside the page at 1100+, under it below that
  slot primary  { … }         // a task's commit bar — `ShellTaskBar`. See [FocusedShell](https://osysharp.com/reference/ui/shell-focused/)
}
```

## Description    {#description}

### How is this not the sidebar collapsed?   {#versus-sidebar}

`SidebarShell` narrows to an icon strip as a **state** — the reader chooses it, the wide band un-chooses it, and
while it is narrow a row shows a glyph and nothing else. `RailShell` is narrow **always**, and is built around that
constraint rather than tolerating it. Three things differ, and all three are mechanism:

- **Every row carries its label**, as a flyout on hover *and* on keyboard focus. The sidebar's collapsed rail has
  none, which makes an icon-only nav a memory test — the single biggest reason narrow rails have a bad reputation.
- **The identity lives at the rail's foot**, not in the top bar. That is not decoration: it frees the entire top bar
  for page context, which is what lets this arrangement give a page a genuinely wide, uncluttered header.
- **It expands into a panel that FLOATS.** Press "Expand navigation" at the rail's foot and the 60px rail widens
  into a labelled panel *over* the page. The content column never reflows, because the 60px column stays exactly
  where it was and the panel is a layer you dismiss rather than a resize you undo.

⛔ **Floating rather than pushing is what keeps these two shells two shells.** `SidebarShell` already expands by
**changing its width**, with the page reflowing each time; that is its model and a good one for a shell whose nav
is the app's primary structure. A rail that did the same would be `SidebarShell` with a smaller collapsed width and
no reason to exist.

### Expanding the rail   {#expand}

The control sits at the **foot of the rail, above the identity** — chrome *about* the rail belongs with the rail's
other chrome, not among the destinations (a row that is not a place) and not crowding a 60px brand tile.

It is a real button with a changing accessible name — `"Expand navigation"` / `"Collapse navigation"` — and it
carries `expanded:`, so assistive technology is told which state it is in and a test can drive it. An expansion only
a mouse can reach is not an expansion.

```osy title="driving the rail's expansion in a test" syntax
Assert.Hidden("Approvals", within: "Main navigation");   // narrow: glyphs only, the label is a hover flyout
Ui.Click("Expand navigation");
Assert.Visible("Approvals", within: "Main navigation");  // the panel labels every row in place
Ui.Click("Collapse navigation");
```

While expanded, every row shows its label and badge in the row itself, `railFoot` appears (a panel has the width for
a card), and the identity chip shows the person's name rather than only their mark. Following any row collapses the
panel again — the panel covers the page, and the page is what you just asked for.

⚠ **Expansion is a pointer affordance only.** On a phone the drawer already shows every label, so there is nothing
to expand into and the control is not drawn.

### How a label appears with no JavaScript   {#tips}

The tip is an **ancestor-conditioned variant**: a descendant's `base` block names an ancestor component and a
pseudo-state, and it lowers to a plain CSS descendant rule.

```osy title="the rule that reveals a rail label" syntax
variants {
  base { Display = Display.None; Position = Position.Absolute; Left = "100%";
         inside RailNavLink.Hover { Display = Display.Flex; }
         inside RailNavLink.Focus { Display = Display.Flex; } }
}
```

⚠ **The `.Focus` twin is not optional.** A rail whose labels exist only under a pointer is unusable from the
keyboard. And `:focus` matches only the **focusable element itself**, so the ancestor named here has to be the
`Link` — a wrapper element as the component's root would make the keyboard half silently dead.

⚠ **`Display.None`, never `Opacity = 0`.** A merely transparent tip keeps its text in the layout and in
`textContent`, so every rail label would read as on-screen to `Assert.Visible` whether or not anybody could see it.

### What does a group do?   {#groups}

A group's children open a **flyout beside the rail**, pinned by a **click** — not by hover. A hover-only disclosure
does not exist on a touch screen, so its children would be unreachable, which is the same as not shipping them.
Hover and focus reveal the row's *label*, which is purely informational and safe to leave to the pointer.

A group carrying a `To` **navigates as well as disclosing**: a link with a thin chevron strip down its trailing
edge, named `"Settings sub-pages"` so the two targets in one row do not answer to the same words. Anything nested
deeper than the flyout draws is flattened into it rather than dropped.

⚑ **This is the one shape a rail keeps that a tab strip does not**, and the difference is the model. A rail row is a
row in a *list* — disclosing under or beside it is what a list does, and the flyout exists because 60px has no room
for the children's names. A tab is a *destination*, so [TabbedShell](https://osysharp.com/reference/ui/shell-tabbed/) refuses the same mechanism and puts a
section's areas in a secondary strip instead. Expanding the rail closes any pinned flyout, because the panel shows
those names in place and the same information twice is worse than once.

### What changes on a phone?   {#bands}

The rail becomes a **labelled off-canvas drawer** opened from the top bar — a drawer has the width for words, so
the rows are labelled there rather than icon-only, and `railFoot` appears because a drawer can hold a card.

⚑ This is close to the sidebar's compact band **on purpose**. On a phone a left-nav app *is* a drawer; inventing a
difference in order to look different would be a worse design. What the arrangement keeps is its own identity
placement — the person is at the drawer's foot, not in the top bar.

## Examples       {#examples}

```osy title="driving the rail in a test" syntax
// The rail is narrow — the claim that distinguishes the arrangement, and the only one geometry can check.
Assert.Narrower("Main navigation", "Page");
Assert.LeftOf("Main navigation", "Page");

// A group pins its children BESIDE the rail rather than over it.
Ui.Click("Settings sub-pages");
Assert.Visible("People");
Assert.RightOf("People", "Overview");
```

## Notes          {#notes}

⚠ **A hover-revealed affordance cannot be driven by a test today** — there is no `Ui.Hover` verb, so the tip is
covered by a screenshot rather than an assertion. Everything reachable by a click or by focus is asserted normally.

⚠ **`railFoot` is hidden in the docked bands.** A 60px rail cannot hold a card, and a clipped card reads as a
rendering fault where its absence reads as a narrow rail.

## See also       {#see-also}

- [App shells](https://osysharp.com/reference/ui/shell/) — the shared `AppChrome` contract every arrangement reads
- [TabbedShell](https://osysharp.com/reference/ui/shell-tabbed/) — top tabs, and a bottom bar on a phone
- [FocusedShell](https://osysharp.com/reference/ui/shell-focused/) — one task, no navigation at all
- [style props](https://osysharp.com/reference/ui/styling/) — the style-prop vocabulary, including `inside <Component>.<State>`
- [accessibility](https://osysharp.com/reference/ui/accessibility/) — landmarks, `role:`, `current:` and the naming props
