// layout — split from src/client/mobile.css.ts (2026-08-16), order preserved. // Do not reorder: styles/index.ts concatenates in this exact order. export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */ @media (max-width: 1023px) { /* --- Phone chrome --- The system status bar stays visible (no fullscreen). Two adjustments make it behave: - touch-action: manipulation kills double-tap-to-zoom (and the 300ms tap delay) while keeping pan and pinch zoom; the client also suppresses legacy-iOS gesturestart as a fallback. - With the client's viewport-fit=cover, env(safe-area-inset-top) is the status bar / notch height; the rules below push the app content below it so the status bar never covers anything. Off notched phones (or in a normal browser tab where the layout viewport already sits below the status bar) the inset is 0 and nothing shifts. */ html, body { touch-action: manipulation !important; } /* AppFrame: the drawer takes the sidebar column out of grid flow, so the remaining in-flow items (center, details) land in tracks 1..2: give the center every pixel and keep the details track at zero. The top padding clears the status bar / notch for every in-flow surface (session header, messages, composer); the absolutely-positioned drawer is unaffected (its containing block is the frame's padding box, i.e. still the frame top). */ /* box-sizing: border-box is load-bearing (S2.1, 2026-08-17). The official frame carries height: 100% with the default content-box, so the safe-area padding ADDED 54px to its height instead of taking 54px out of the content area: the frame grew past the viewport, the document became scrollable by exactly the inset, and the very first scroll put the header right back under the notch — measured with ?mobile-nav-inset=54 (frame 898px tall inside an 844px root, html at y=-54). Invisible on every desktop browser because the inset is 0 there, which is how it shipped. */ [data-mobile-nav="frame"] { position: relative !important; box-sizing: border-box !important; grid-template-columns: minmax(0, 1fr) 0 0 !important; padding-top: var(--mnav-sat) !important; } /* The sidebar column (first grid child) becomes a left drawer. The drawer hugs the sidebar content exactly (the wide sidebar carries an inline width, ~280px): a fixed 92vw box would leave a white strip where the container background shows beside the content. Closed state: translateX(-110%) — more than -100% of the max-content width — guarantees the whole drawer (and its shadow, had it one) leaves the viewport. A mere -100% leaves a sliver on screen; -105% (as used before) left 14px of the drawer plus a long 32px-blur shadow gradient visible along the left edge of the main UI. No box-shadow at all: the dimmed backdrop already separates drawer from content. */ [data-mobile-nav="frame"] > :first-child { position: absolute !important; inset: 0 auto 0 0 !important; width: max-content !important; max-width: 92vw !important; z-index: 40 !important; transform: translateX(-110%); transition: transform .28s var(--ds-ease-in-out, ease-in-out); background: var(--dsw-alias-bg-base, #ffffff); /* Keep the drawer's own content below the status bar / notch: the drawer spans the full frame height (its absolute containing block is the frame's padding box, so the frame's own safe-area padding does NOT reach it). The drawer background paints the status-bar strip, which the client's theme-color meta matches, so the strip reads seamless. */ padding-top: var(--mnav-sat) !important; /* Kill the official sidebarCol right border: with the backdrop the edge reads cleanly, and the settings dialog (width:100% of this box) stays pixel-flush with the drawer. */ border-right: none !important; } /* The official right sidebar (DSH 0.1.5+) in its full-screen mode — the one the phone's header sidebar button opens (sidebar-panels.ts). The host promotes the panel to position:fixed; inset:0 there, so exactly like the drawer above it escapes the frame's safe-area padding and its tab strip + chrome buttons landed under the status bar (real-device report, 2026-09-21). Both insets: the status bar / notch on top, the home indicator at the bottom. Padding, no box-sizing change: fixed with both top and bottom set resolves its used height from the containing block minus padding (CSS 2.1 10.6.4), so the content box shrinks by itself — the same reasoning the legacy better-sidebar panel rule in compat.css.ts spells out. \`fullscreen\` is the host's own attribute value (\`push\` while docked, dsh-client-ui-sidebar-right), so the docked panel is untouched; a manual full-screen on desktop is outside this media block anyway. Verify with ?mobile-nav-inset=54,34 — env() is 0 in every desktop browser. */ [data-sidebar-right-panel="fullscreen"] { padding-top: var(--mnav-sat) !important; padding-bottom: var(--mnav-sab) !important; } /* Expanded state (frame without data-sidebar-collapsed) slides the drawer in. The open state must be transform:none — NOT translateX(0): an identity transform still makes the drawer the containing block for fixed-position descendants (the settings dialog's .VOzbGW_overlay is portaled into the sidebar DOM). With the identity transform the wide settings sheet (100vw-16) overflows the 280px drawer, the dialog's focus scrolls the overflow:hidden drawer to scrollLeft=102, and every static child (plus the fixed overlay) shifts 102px off-screen. With transform:none the overlay is viewport-anchored: it dims the full screen and the sheet sits at left:8. */ [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) > :first-child { transform: none !important; } /* Drag handles are useless on touch and would float over the drawer. */ [data-side="sidebar"], [data-side="details"] { display: none !important; } /* --- Conversation text on mobile --- The official message flow keeps desktop's 32px side gutters and 16px type. On a phone: shrink the type a notch and widen the lines by trimming the gutters (the sidebar drawer list keeps its size). The flow's scroll container is the only _scroll element holding markdown

paragraphs — the composer's own scroll (textarea) is excluded via :has(p). */ /* The official main scroll body reserves scrollbar-gutter for desktop scrollbars (8px), which shoves every column off-center on a phone. Classic desktop scrollbars (Edge/Chrome) also occupy ~8-17px in a phone-sized viewport, shifting the column further. Mobile scrolling is touch/wheel, so remove the scrollbar entirely on phones: the column is then exactly centered in every browser. */ [data-phase] [class$="_scrollBody"] { scrollbar-gutter: auto !important; scrollbar-width: none !important; } [data-phase] [class$="_scrollBody"]::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; } /* Message action rows (copy / run-time badges) can overflow the right edge on narrow screens — keep them inside the message width. */ [data-phase] [class$="_actions"] { overflow: hidden !important; } [data-phase] [class$="_actions"] [class$="_timeEnd"] { flex: 0 1 auto !important; min-width: 0 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } [data-phase] [class$="_scroll"]:has(p) { padding-left: 20px !important; padding-right: 20px !important; font-size: 15px !important; } /* The official markdown styles set an explicit 16px on paragraphs and list items, so the container's inherited 15px is not enough. User messages render their text in a div whose class carries _text_ (16px too) — cover it as well. */ [data-phase] [class$="_scroll"]:has(p) p, [data-phase] [class$="_scroll"]:has(p) li, [data-phase] [class$="_scroll"]:has(p) [class*="_text_"] { font-size: 15px !important; } /* Markdown tables: the official table uses width:max-content, so on a phone it hugs the content and leaves dead space beside/inside the table. Force the table to fill the message column and let the table wrapper handle overflow if a cell is genuinely too wide. */ [data-phase] table { width: 100% !important; max-width: 100% !important; } [data-phase] th, [data-phase] td { max-width: none !important; min-width: 0 !important; } /* User bubbles: the official stack is capped at min(525px, 82%), which on a phone leaves a large blank strip on the left and pushes the bubble high. On mobile let the user message fill the same full width as assistant messages (the bubble background then spans the whole message column). */ [data-phase] [class$="_userStack"], [data-phase] [class$="_userStack"] [class$="_bubble"] { box-sizing: border-box !important; width: fit-content !important; max-width: 100% !important; } /* --- Composer bottom row on mobile --- The official row gives the model pill (trailing) flex:0 0 auto, which squeezes the agent-permission pill (modes) down to 15px: the pill's chevron then overflows on top of the model name. Let the permission pill keep its natural width and let the model pill shrink instead. Anchored by the composer card (:has(textarea)): row = the _row class containing a _trailing group, tools = its first child, permission pill = its 2nd child, model pill = the _trailing group. NOTE: do NOT anchor these by the card's :last-child — a client effect moves the git-graph branch chip INTO the card, which becomes the card's new last child and silently disables every :last-child rule (the model pill then falls back to the official fixed layout and long model IDs overflow instead of ellipsizing). Structural anchors only. */ /* The official row pads every group with 12px gaps, which on a phone eats width the model pill needs for the full model ID. Tighten the row to 8px gaps (the modes pill keeps its natural width) so the model name gets every spare pixel. */ [data-phase] [class*="_card"]:has(textarea) [class$="_row"]:has([class$="_trailing"]) { gap: 8px !important; } [data-phase] [class*="_card"]:has(textarea) [class$="_row"]:has([class$="_trailing"]) > :first-child { gap: 8px !important; } [data-phase] [class*="_card"]:has(textarea) [class$="_row"]:has([class$="_trailing"]) > :first-child > :nth-child(2) { flex: 0 0 auto !important; gap: 8px !important; } [data-phase] [class*="_card"]:has(textarea) [class$="_trailing"] { flex: 1 1 auto !important; gap: 8px !important; min-width: 0 !important; } /* Let the model selector take the free space in the trailing group so the model name is not squeezed/truncated; the context meter stays fixed. */ [data-phase] [class*="_card"]:has(textarea) [class$="_root"]:has(> [class$="_trigger"][aria-haspopup="menu"]) { flex: 1 1 auto !important; min-width: 0 !important; } [data-phase] [class*="_card"]:has(textarea) [class$="_root"]:has(> [class$="_trigger"][aria-haspopup="menu"]) > [class$="_trigger"] { width: 100% !important; max-width: 100% !important; } /* The model label must absorb the trigger's free width: officially it is a fixed-content flex item, so the spare width of a grown pill sits unused between the chevron and the pill edge. Let the label grow and shrink: the full model ID shows whenever the row can fit it, and the ellipsis lands exactly at the available width otherwise. */ [data-phase] [class*="_card"]:has(textarea) [class$="_root"]:has(> [class$="_trigger"][aria-haspopup="menu"]) > [class$="_trigger"] > [class$="_triggerLabel"] { flex: 1 1 auto !important; min-width: 0 !important; } [data-phase] [class*="_card"]:has(textarea) [class$="_root"]:has(> [class$="_trigger"]:not([aria-haspopup="menu"])) { flex: 0 0 auto !important; } /* Model switcher menu: the official dropdown is right-aligned to the trigger (right:0). The model pill sits near the center of the composer, so on a phone the 240px menu overflows the left edge and looks off-center. Center the menu on the trigger instead. */ [data-phase] [class*="_card"]:has(textarea) [class$="_root"]:has(> [class$="_trigger"]) > [class$="_menu"] { left: 50% !important; right: auto !important; transform: translateX(-50%) !important; } /* --- Session header on mobile --- Layout goal: [toggle] [session title] [mode badge] in a row, with the Session log capsule removed from the header (relocated to the drawer footer). Stable structural hooks only: [data-phase] header the session header element header > :first-child titleRow (titleCluster + utilities) header > :first-child > :last-child headerUtilities (Session log seat) */ [data-phase] header { padding-right: 12px !important; } /* Give the title row a lane clear of the absolutely-placed toggle, then balance the header: with header padding-right 12px, a 20px left padding puts the title's geometric center exactly on the viewport center (measured 195/195 at 390px). */ [data-phase] header > :first-child { padding-left: 20px !important; } /* The directory toggle sits at the far left of the header (the header is position:relative; the data-slot wrappers are display:contents). */ [data-mobile-nav="toggle"] { position: absolute !important; left: 8px !important; top: 12px !important; z-index: 2 !important; } /* The Files action stays in-flow inside headerActions. On mobile headerActions does not expand to fill the row: it is pushed to the right edge and keeps its content size, so [mode] [subagent/jobs] [files] sit close together with the normal 8px gaps — compact, no artificial blank columns. Explicit flex order keeps Files rightmost regardless of DOM order. */ [data-mobile-nav="files"] { position: static !important; left: auto !important; right: auto !important; top: auto !important; z-index: auto !important; } [data-phase] header [class$="_headerActions"] { flex: 0 1 auto !important; min-width: 0 !important; margin-left: auto !important; justify-content: flex-end !important; } /* The session title/crumb must yield space on mobile so the mode label and subagent cluster are not squeezed to a single letter. Cap it at ~24vw and let it truncate with ellipsis. */ [data-phase] header [class$="_crumbs"] { flex: 0 1 auto !important; min-width: 0 !important; max-width: 24vw !important; } /* Mode label (AgentPresetLabel): the official pill uses inline-flex, whose direct text node is only clipped — no ellipsis. Convert the pill to a block container, keep its icon absolutely positioned on the left, and let it size naturally up to 42vw so the mode name stays readable while the subagent/Files cluster still fits on a phone. */ [data-phase] header [class$="_label"]:has(> svg) { order: 1 !important; flex: 0 1 auto !important; min-width: 0 !important; max-width: 42vw !important; display: block !important; position: relative !important; box-sizing: border-box !important; padding-left: 18px !important; padding-right: 2px !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } [data-phase] header [class$="_label"]:has(> svg) > svg { position: absolute !important; left: 0 !important; top: 50% !important; transform: translateY(-50%) !important; } /* Subagent/jobs helpers: keep their triggers on one line and never let the row grow artificial empty space. */ [data-phase] header [class$="_root"]:has(> button[class$="_trigger"]) { order: 2 !important; flex: 0 0 auto !important; min-width: max-content !important; max-width: none !important; white-space: nowrap !important; /* On mobile the popover is anchored to the header (viewport-width containing block) instead of the trigger, so a small left inset keeps it fully on screen and never clips the tree's status dots. */ position: static !important; } [data-phase] header [class$="_root"]:has(> button[class$="_trigger"]) > button, [data-phase] header [class$="_root"]:has(> button[class$="_trigger"]) > button * { white-space: nowrap !important; } [data-phase] header [data-mobile-nav="files"] { order: 3 !important; flex: 0 0 auto !important; } /* Session log download: gone from the header row on mobile (the utilities seat holds only the session-log-export capsule). */ [data-phase] header > :first-child > :last-child { display: none !important; } /* --- Header popovers on mobile (dsh-client-ui-jobs / dsh-client-ui-subagent) --- The official entries sit in the session header actions. Their popovers are anchored to the trigger's LEFT edge (left: 0), so from a right-edge trigger the 336px panel spills past the viewport. Re-anchor them to the trigger's right edge instead. The popover classes are hashed CSS-module names, so target them by the stable _menu suffix inside the header. */ /* Header popovers on mobile: anchor to the left edge with a small inset and clamp the width, instead of right-aligning to a trigger near the right edge (which pushed the panel left off-screen and clipped the status dots). Also cap the height so the panel does not run into the composer/task bar. */ [data-phase] header [class$="_menu"] { left: 8px !important; right: auto !important; width: min(336px, calc(100vw - 16px)) !important; max-width: none !important; max-height: min(420px, calc(100dvh - 120px)) !important; } /* --- Settings dialog on mobile --- Desktop: 800px two-column flex (188px nav + content). Mobile: a near-full-width sheet — nav tabs wrap into rows on top, option rows stay horizontal (title+description left, control right). Structural selectors are scoped to the unique aria-modal dialog; every settings-specific rule is gated with :has(> :first-child > :last-child > button) — the settings nav tab list holds