fix(panel,ui): phone shell in landscape, no zoom on focus, layers clear of the composer
Fixes the shell group of the mobile web audit and its code review: layout and layers on a phone. Part of 📱 Web support (https://tryluke.dev/stage/plan/0b805267-0061-4b69-8e2c-964920ec4a30). A desktop with a mouse is unchanged except for the one call-card note below.
What changes
A phone in landscape is narrow (P1). Under a coarse pointer, a device whose screen's shorter side is under 500px (min(screen.width, screen.height)) is a phone, and stands narrow whichever way it is turned. The screen is read, not the stage, because a keyboard shortens the stage: by an inset on iOS, and by shrinking the window on Android. A tablet's stage must not turn into a phone's on every focus. A mouse is never a phone. useStageRoom also measures again when the main pointer changes, as when a tablet gains or loses a trackpad. The width's 640/672 band is unchanged.
No zoom on focus (P1). On a coarse pointer, .luke-inline-edit[data-look] takes max(16px, 1em). That covers the tab rename, the workspace and integration renames, the PR title, and the list row rename. The Browser tab's address and the plain comment field (.comment-text, 13px) take 16px. A sweep of every input, textarea and contenteditable found no other field under 16px on a coarse pointer.
Floating layers clear of the composer (P2). On narrow, the toasts and the shell's captions stand above the column's dock (column-dock.ts) and never rise past half of the visible height. The toasts and the floating call card, which are fixed to the page rather than the stage, keep inside env(safe-area-inset-*). To make this work:
--column-dockis now written on the document root, because the toast stack is portalled tobody.- A chat's composer joins the dock's parts (
.chat-composer[data-compact="false"], never a thread's reply box), and the Chat page is a column. On a phone, and over a full-screen side panel, it always docks. Beside the work, only the folded card reads the dock, so the composer docks only where it reaches under the card, past a rail narrower than the card. - A part in a hidden pane (a narrow Chat rail's left-behind channel) docks nothing.
- However tall the dock is, the toasts and captions keep the visible height less three controls to stand in, rather than half of it, and the offset never goes below zero.
- Beyond the brief, and the one desktop change: on the desktop, the folded sidebar's floating call card now also rises above a chat composer it used to cover: Luke's, over a full-screen side panel, and the Chat page's, where the rail is narrower than the card.
Hover-only leftovers on touch (P2). On (hover: none), these show at rest instead of staying as invisible presses:
- an archived agent's Unarchive, in place of its failed mark
- a fold's chevron, in place of its glyph
- a list row's chevron
- the summary's chevrons (pinned and card)
The drawer (P2).
- A phone's browser tab no longer draws ← → in the drawer's head (or in Settings' and the version history's lists), since the browser's own Back and Forward walk the same history. An installed web app (
display-mode: standalone) keeps them, because it has no browser around it. - The section ⋯ buttons already take a 44px
::beforeon a coarse pointer. The audit agent re-probed them: the "24px" flag came from rows sitting under the account footer, not from the hit area. So nothing changed there.
The drawer is modal (code review). While it stands over the work in a narrow room, main.desktop-main and the side panel's toggle are inert. Escape already put it away (hover-pane.ts). It now gives the keyboard back once the drawer is drawn away, after inert has lifted, rather than straight after close(), when the work could take no focus. The press outside still puts it away, since it is heard on document. Not done: Android Back closing it. The navigation history (navigation-history.ts) records places with addresses, and the drawer has no address, so a history step for it doesn't fit that pattern.
Small fixes (code review).
reply-to-selection.tsx:coarsePointerNow()is checked beforepickedIn(), so a mouse drag is never measured.bundle-loader.ts: a bundle atom is no longer kept alive. A module that landed is still kept, in the loader's own closure, so a failed import goes when its last reader does and the next reader imports again. Note that a browser may keep a failedimport()of the same URL in its module map, so a retry helps most where the failure was in the module's own evaluation, or the chunk was briefly unreachable before the browser recorded it.desktop-sidebar.tsx:- The empty Pinned placeholder is gated on
(pointer: coarse)through a newuseCoarsePointer(), not on the room, so a narrow window used with a mouse can still drop a first pin. - The page rows keep their two positions. Placing them once with CSS would change either the desktop sidebar or the drawer's scroller. They remount only when the room flips, which a phone no longer does when it turns, and they hold no state. A comment says this; the orchestrator agreed.
- The empty Pinned placeholder is gated on
- The dialog breakpoint:
useStageRoomwritesdata-luke-roomon<html>.- ui's
dialog.cssand the panel's New task field cap key on it, so a portalled dialog turns exactly when the shell does, and does so in landscape too. - A page that writes no room (the site, the catalog) keeps the old
luke-dialog-roomcontainer query. - The toasts key on the same attribute.
- ui's
Evidence
Before and after on the web dev server, with the audit's signed-in fake (Playwright, iPhone 13).
Landscape, 844×390, before: two panes in 390px of height.

Landscape, after: one pane.

Drawer at 390×844, before: ← → in the head, and the work behind it reachable (inert false).

Drawer, after: no arrows in a browser tab, and the work behind it inert. The probe reads { overlay: 'open', mainInert: true, arrows: false }.

Tests
Each test below fails without its fix:
stage-room.test.ts:- A landscape phone is narrow under a coarse pointer and wide with a fine one.
- It turns narrow when the pointer becomes coarse without a resize.
- A tablet whose stage the keyboard shortens stays wide.
desktop/column-dock.test.ts:- On a phone's Chat page the dock is the channel's composer, not a thread's reply above it.
- Beside the work, the composer docks only where it reaches under the card.
- A composer in a hidden pane docks nothing.
styles/touch-density.test.ts:- Every field is at least 16px on touch and keeps its exact former size with a mouse: the inline edit in all three looks, the address bar, and the comment field.
- The Unarchive and the chevrons show on touch and wait for the pointer with a mouse.
desktop/shell-room.test.ts:- The drawer makes the work and the panel's toggle inert while it is open, and Escape puts it away.
- An open floating sidebar beside the work leaves the work answering.
- The arrows are hidden in a phone's browser tab and kept in an installed app.
- Escape hands the keyboard back to the work the drawer was opened from. The focus stand-in refuses an inert element, as a browser does.
desktop/sidebar-drawer-narrow.test.ts: the empty Pinned is gone under a finger and kept for a mouse in a narrow room.tasks/narrow-composers.test.ts: a landscape room, narrow but with a wide dimmed window, stands New task's dialog as a phone's, and a page that writes no room falls back to the container query.bundle-loader.test.ts: a failed import is imported again by the next reader, and a landed one is kept.
Commands
pnpm check:affected: lint, oxlint, knip, discarded-effect and tests pass. In the 8 GB sandbox,@luke/weband@luke/paneltypechecks ran out of memory under turbo, and each passes alone with--max-old-space-size=6000. The desktop'sapp-menu.test.tsfails here only because the sandbox has no Electron binary.