# Responsive — one breakpoint, two shells

> Read this before building ANY new surface in `apps/web`. The product app has
> two shapes now, not one, and which utilities you reach for is a decision you
> make while writing the JSX — not a pass somebody does afterwards.

The product runs in two shells, and there is exactly **one** breakpoint between
them.

| | Width | The shell |
|---|---|---|
| **Desktop shell** | ≥ 800px | Rail, chat and canvas side by side. Unchanged from what it always was. |
| **Compact shell** | < 800px | No rail. Chat and canvas are exclusive — one at a time. Navigation lives in a full-screen takeover. |

800px is where the desktop shell stops fitting: a 72px rail plus a 280px chat
floor leaves 448px of canvas, and below that the canvas is narrower than the
column beside it. It is a deliberately high number — a small laptop window and a
tablet in portrait both land in the compact shell, and that is correct. Nothing
about the compact shell is phone-specific.

## The token, and the two names it gives you

`--breakpoint-shell` (`packages/ui/src/css/tailwind.css`, in a `@theme static`
block) is the single source. It gives you a Tailwind variant in each direction:

```
shell:*       →  the desktop shell   (min-width: 800px)
max-shell:*   →  the compact shell   (max-width: 799.98px)
```

**Never write the number.** Not `max-[799px]:`, not a `800` in a media query you
add, not a constant in TS. The two literal media queries that do exist
(`packages/ui/src/css/canvas-sheet.css`, `apps/web/client/css/mobile-shell.css`)
are there because CSS cannot read a `var()` inside `@media`, and both point back
at the token in a comment. JS reads the token off `:root` — which is what
`@theme static` is for, and why it is `static` rather than a plain `@theme`
(Tailwind tree-shakes theme variables nothing references, and a breakpoint is
compiled into media queries).

## The compact shell, precisely

```
┌──────────────────────────────────────────┐
│  [☰]                    [ Chat | Social ]│   MobileTopBar, 56px
├──────────────────────────────────────────┤
│                                          │
│      the chat  ── OR ──  the canvas      │   exactly one, full width
│                                          │
└──────────────────────────────────────────┘
```

- **The rail is hidden, not unmounted** (`max-shell:hidden` on `LeftNav`).
  `SettingsMenu` lives at the foot of it and owns every `?settings=` deep link
  in the app; unmounting it would take those with it. Its dialogs portal to
  `<body>`, so the hidden ancestor does not reach them.
- **The takeover replaces it** (`mobile-nav-sheet.tsx`) — project switcher, the
  same destinations the rail lists (both read `lib/area-nav`, so they cannot
  drift), credits, settings, support, sign-out.
- **Settings DRILLS, it does not fly out.** The rail's cog opens a dropdown
  whose rows open hover submenus, and hover submenus are mouse-only by
  construction. The takeover renders the same registry (`lib/settings-menu`) as
  levels with a back row. Its leaves navigate to `?settings=<panel>` and close;
  `SettingsMenu` opens the dialog it already owns. There is no second
  implementation of any settings surface.
- **Chat and canvas are exclusive**, driven by `data-shell-pane` on the app
  shell and two rules in `css/mobile-shell.css`. Both panes stay **mounted** and
  the hidden one is `display: none` — toggling is a view change, not a
  navigation, and unmounting would re-hydrate the thread (losing the composer
  draft and the scroll position) or re-run the page's queries.
- **Chat is the landing pane.** The product is chat-first and the conversation
  is the one surface already comfortable at that width.

### Who switches you to the canvas

The user always can, from the top bar. Beyond that, **anything Elle does that
puts something on the canvas takes you there** — otherwise those affordances
read as broken on a phone:

| Producer | Where it lives |
|---|---|
| A view-link opening a generated or hand-authored sheet | `ElleSheetProvider` (one effect, keyed on which sheet is open — so every producer is covered, including ones added later) |
| An in-app link in Elle's voice | `ElleMarkdownLink` (`chat-entries.tsx`) |
| A nav-link CTA in the stream | `NavLink` (`chat-entries.tsx`) |
| A destination in the takeover | `mobile-nav-sheet.tsx` |

Reach for `showCanvasPane()` (`hooks/use-shell-mode`) if you add a fifth. There
is deliberately **no** `showChatPane()`: nothing on the canvas gets to decide the
user should be reading the conversation instead.

## What you get for free

These already adapt. Compose with them and most of a new surface is done:

- **`CanvasSheet`** — full-bleed below the breakpoint (no inset, radius, border
  or shadow: a card needs a surface to sit on, and there isn't one). Side
  gutters drop 40px → 16px. The stacked recede is shallower, and scales from the
  top so the square edges stay put.
- **`CanvasSheetToolbar`** — the one-line law holds at every width, so the line
  **scrolls** instead of wrapping. Your `justify-between` row becomes `w-max` and
  packs left; that is correct, not a regression.
- **`OverflowNavTabs`** — collapses into **More** against the live container
  width. It re-measures on resize rather than once at mount, which is what makes
  it right in a pane that was hidden when it mounted.
- **`DataTable`** — already scrolls in both axes inside its own region, and
  `fill` still caps it against the sheet.
- **`ElleBlock`** — the block-owned companion canvas is **dropped, not
  narrowed**: below the breakpoint the chat takes the whole viewport and the
  pane never mounts (founder ruling 2026-08-19). `api.canvasAvailable` tells a
  caller so, and a stream carrying view-links must not render them when it is
  false. Onboarding is the surface this exists for.
- **Every text control** — `input`, `textarea`, `select` and `contenteditable`
  compute at a literal 16px on touch surfaces, so a focused field never zooms
  mobile Safari. One unlayered rule in `css/mobile-shell.css`; you do not add a
  class for it, and you cannot forget it on a new field.
- **`Dialog` / `AlertDialog`** — already viewport-clamped.
- Stat grids, `content-grid`, `library-grid` — `auto-fill minmax()` collapses to
  one or two columns on its own.

## What you owe, building something new

1. **Reach for `max-shell:` / `shell:` in the JSX.** CSS needs no hydration and
   cannot be a render behind. `useIsCompactShell()` is for the cases CSS cannot
   express — an inline style, a prop that must not be passed, an effect.
2. **A row of controls that cannot fit is a scrolling line or an overflow menu,
   never a second row** (`rules.md`). If it is a `CanvasSheetToolbar` you have
   this already.
3. **`min-w-0` on every flex/grid child that holds text.** Flex and grid items
   default to `min-width: auto`, so a child sizes to its longest unbreakable
   word and refuses to shrink. Its parent then overflows a container with
   `overflow-x: hidden` and the content is not merely ugly, it is unreachable.
   This is the single most common cause of a clipped compact-shell layout.
4. **Two columns side by side need a stacking rule.** `flex-col sm:flex-row`, or
   a grid whose `grid-template-columns` collapses. A `shrink-0` action group
   beside a text column will overflow rather than wrap.
5. **Never measure once at mount.** A pane can be `display: none` when it
   mounts, in which case every `offsetWidth` in it is 0. Observe with a
   `ResizeObserver` and **guard against adopting a zero** — keep the last real
   measurement until there is a better one. `OverflowNavTabs` is the worked
   example; the bug it fixes rendered every tab past the edge with no More
   button, because "all zeros" reads as "measured, and everything fits".
6. **A reservation for a fixed-size control does not scale with the screen.**
   The sheet header's right gutter is 40px on every viewport because that is the
   close button's own width plus its inset. Narrowing it to the compact gutter
   put a header action underneath the close.
7. **Touch targets are 44px or the row is 48px.** The takeover's rows are
   `RailRow` (48px) for exactly this reason; toolbar controls stay `size="sm"`
   (32px) because they sit in a scrolling strip, not a tap list.

## Verifying

Chrome DevTools MCP, `emulate` with a device viewport — **not** `resize_page`,
which cannot go below the browser's ~500px window floor and will quietly lie to
you:

```
emulate  viewport: "390x844x3,mobile,touch"     # a phone
emulate  viewport: "799x900x1"                  # one pixel into the compact shell
emulate  viewport: "800x900x1"                  # the desktop shell, at its floor
```

Then, on each surface you touched:

- Both panes, both themes.
- `document.documentElement.scrollWidth === window.innerWidth` — the page body
  must never scroll horizontally.
- Nothing clipped: walk the sheet's descendants for a `getBoundingClientRect()`
  right edge past the viewport inside an `overflow-x: hidden` ancestor.
- Every control still reachable — scroll the strips, open the menus.
- **800px and 1440px, to prove you changed nothing there.** Every rule in this
  doc is additive by construction; a desktop diff means you wrote a bare
  utility where a `max-shell:` one belonged.

## Onboarding

`/onboarding` is a different shell — no rail, no pane toggle — and it answers
the same question differently: **the canvas is not shown at all.**

- It is a visual for wide viewports and nothing more (founder ruling
  2026-08-19; about half of onboarding traffic is on a phone). Everything it
  carries is also in the conversation, and every commitment happens at the
  composer anyway — see the core principle in
  `apps/web/docs/AGENT_APP_UI_CONVENTIONS.md`.
- So the block never mounts the pane, never auto-opens a gate artifact onto it,
  and drops the aside's resize floor. `ElleBlock` owns all of that; a caller
  reads `api.canvasAvailable`.
- **The view-links go with it.** `OnboardingChat` skips `view-link` entries
  entirely when there is no canvas — a toggle whose sheet cannot appear is a
  dead affordance, and `rules.md` removes those rather than disabling them.
  This is not the append-only law being broken: nothing is withdrawn from a
  stream that showed it, the surface simply never had a canvas to carry it.
- Its own 72px rail (`OnboardingRail`) becomes a **56px top bar** rather than
  disappearing. Two controls are worth 56px of height and not 72px of a 390px
  width — and one of them is Support, on the one surface where a stuck user has
  no composer to ask Elle in.

## Not adapted yet

Honest inventory, so nobody reads silence as coverage.

- **The `/create/*` builders** (`FullscreenModal`). Desktop creative tools;
  untouched.
- **Per-page density.** The structure holds everywhere, but individual rows
  written against a 900px canvas are cramped at 390px — the schedule list row is
  the clearest case, where a fixed time column and a badge leave the title about
  80px. That is per-surface design work, not a shell problem.