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:hiddenonLeftNav).SettingsMenulives 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 readlib/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;SettingsMenuopens the dialog it already owns. There is no second implementation of any settings surface. - Chat and canvas are exclusive, driven by
data-shell-paneon the app shell and two rules incss/mobile-shell.css. Both panes stay mounted and the hidden one isdisplay: 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. Yourjustify-betweenrow becomesw-maxand 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, andfillstill 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.canvasAvailabletells 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,selectandcontenteditablecompute at a literal 16px on touch surfaces, so a focused field never zooms mobile Safari. One unlayered rule incss/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
- 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. - A row of controls that cannot fit is a scrolling line or an overflow menu,
never a second row (
rules.md). If it is aCanvasSheetToolbaryou have this already. min-w-0on every flex/grid child that holds text. Flex and grid items default tomin-width: auto, so a child sizes to its longest unbreakable word and refuses to shrink. Its parent then overflows a container withoverflow-x: hiddenand the content is not merely ugly, it is unreachable. This is the single most common cause of a clipped compact-shell layout.- Two columns side by side need a stacking rule.
flex-col sm:flex-row, or a grid whosegrid-template-columnscollapses. Ashrink-0action group beside a text column will overflow rather than wrap. - Never measure once at mount. A pane can be
display: nonewhen it mounts, in which case everyoffsetWidthin it is 0. Observe with aResizeObserverand guard against adopting a zero — keep the last real measurement until there is a better one.OverflowNavTabsis 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”. - 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.
- Touch targets are 44px or the row is 48px. The takeover’s rows are
RailRow(48px) for exactly this reason; toolbar controls staysize="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 anoverflow-x: hiddenancestor. - 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.
ElleBlockowns all of that; a caller readsapi.canvasAvailable. - The view-links go with it.
OnboardingChatskipsview-linkentries entirely when there is no canvas — a toggle whose sheet cannot appear is a dead affordance, andrules.mdremoves 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.