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.

WidthThe shell
Desktop shell≥ 800pxRail, chat and canvas side by side. Unchanged from what it always was.
Compact shell< 800pxNo 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:

ProducerWhere it lives
A view-link opening a generated or hand-authored sheetElleSheetProvider (one effect, keyed on which sheet is open — so every producer is covered, including ones added later)
An in-app link in Elle’s voiceElleMarkdownLink (chat-entries.tsx)
A nav-link CTA in the streamNavLink (chat-entries.tsx)
A destination in the takeovermobile-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.

Raw markdown for agents: /docs/responsive.md