diff --git a/web/src/attach.ts b/web/src/attach.ts index 22ae5c3..38d303d 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -6,6 +6,7 @@ import { attachWsUrl } from "./routes.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq } from "./keys.js"; +import { requestToggleDrawer } from "./drawer.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; @@ -80,7 +81,7 @@ export function createAttachConnection( const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId; el.innerHTML = `
- ← + ${titleHtml} connecting… @@ -100,6 +101,12 @@ export function createAttachConnection( stateEl.dataset.state = kind; }; + // Mobile-only (CSS hides it on desktop, where the sidebar is always in view): + // the session list is an off-canvas drawer and this is what opens it. + (el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () => + requestToggleDrawer(), + ); + disconnectBtn.addEventListener("click", () => { opts.onRequestClose?.(sessionId); }); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 03d7cf0..0ec782e 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -266,18 +266,6 @@ function beginRename(row: HTMLElement, s: Session): void { input.select(); } -function renderStatusbar(sessions: Session[]): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "statusbar"; - - const live = sessions.filter((s) => s.status === "Running").length; - const tally = document.createElement("span"); - tally.className = "session-tally"; - tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`; - wrap.appendChild(tally); - return wrap; -} - function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement { const el = document.createElement("div"); el.className = "empty"; @@ -302,51 +290,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement { return el; } -/** - * The global app header (full width, above the sidebar/content split): brand + - * session tally. App-level actions (help, theme, spawn) live pinned to the - * sidebar's foot instead — see {@link renderSidebar}. - */ -export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement { - const header = document.createElement("header"); - header.className = "app-header"; - header.innerHTML = ` - - - -

maestro

-
-
-
- `; - // Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer. - (header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () => - onToggleSidebar?.(), - ); - - // Session tally lives centered in the header (not the sidebar). - const statusHost = header.querySelector(".app-status") as HTMLElement; - const renderStatus = () => { - const state = store.snapshot(); - statusHost.replaceChildren(renderStatusbar([...state.sessions.values()])); - }; - store.subscribe(renderStatus); - renderStatus(); - - const actions = header.querySelector(".app-actions") as HTMLElement; - // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by - // clicking inside a proxied preview (e.g. crit live) that has no address bar. - if (import.meta.env.DEV) { - const kit = document.createElement("a"); - kit.className = "btn btn-ghost"; - kit.href = "?kit"; - kit.textContent = "kit"; - kit.title = "Open the design kit (DEV)"; - actions.appendChild(kit); - } - return header; -} - /** Two-tier sidebar highlight state (issue #33): which sessions are open in some * pane (quiet), and which one is focused (loud). */ export interface SidebarHighlights { @@ -426,6 +369,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S helpBtn.addEventListener("click", () => toggleHelp()); footer.appendChild(helpBtn); footer.appendChild(createThemeToggle()); + // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by + // clicking inside a proxied preview (e.g. crit live) that has no address bar. + if (import.meta.env.DEV) { + const kit = document.createElement("a"); + kit.className = "btn btn-ghost"; + kit.href = "?kit"; + kit.textContent = "kit"; + kit.title = "Open the design kit (DEV)"; + footer.appendChild(kit); + } const spawnBtn = document.createElement("button"); spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; diff --git a/web/src/drawer.ts b/web/src/drawer.ts index dc65976..ab8155c 100644 --- a/web/src/drawer.ts +++ b/web/src/drawer.ts @@ -8,6 +8,14 @@ import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; +/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile), + * without threading the controller through every view. */ +export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer"; + +export function requestToggleDrawer(): void { + document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT)); +} + export interface Drawer { open(): void; close(): void; @@ -31,6 +39,7 @@ export function installDrawer( sidebarEl.addEventListener("click", (e) => { if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close(); }); + document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle()); document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close()); document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close()); diff --git a/web/src/kit.ts b/web/src/kit.ts index 1d4386f..352afe2 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -205,7 +205,6 @@ export function renderKit(): HTMLElement { ); activeLine.setAttribute("data-activity", "active"); body.append( - specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")), specimen("folder-count", h("span", "folder-count", "4")), specimen("card-line2 · active", activeLine), specimen("attach-state · open", h("span", "attach-state", "live")), diff --git a/web/src/main.ts b/web/src/main.ts index 6ff41be..e9d40c7 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,9 +2,9 @@ import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; import "@fontsource/jetbrains-mono/600.css"; import "@fontsource/jetbrains-mono/700.css"; -import { renderSidebar, renderTopbar } from "./catalog.js"; +import { renderSidebar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; -import { installDrawer, installDrawerSwipe } from "./drawer.js"; +import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; import { CatalogStore } from "./events.js"; @@ -93,16 +93,15 @@ const content = document.createElement("section"); content.className = "content"; // Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in -// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn +// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn // similar, backdrop); on desktop the class it toggles is inert. const backdrop = document.createElement("div"); backdrop.className = "mv-backdrop"; const drawer = installDrawer(shellEl, sidebar.el, backdrop); installDrawerSwipe(drawer); -const toggleDrawer = () => drawer.toggle(); shellEl.append(sidebar.el, content, backdrop); -root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl); +root.replaceChildren(shellEl); // The pool owns attach connections independently of the route: navigating away // parks a session (keeps it streaming); navigating back re-mounts it instantly. @@ -182,13 +181,22 @@ function renderWelcome(): HTMLElement {

Press ? for keyboard shortcuts.

`; + const actions = el.querySelector(".empty-actions") as HTMLElement; + // Mobile-only: with no pane open there is no pane header to carry the ☰, so + // the drawer would be swipe-only. CSS hides this on desktop. + const sessions = document.createElement("button"); + sessions.type = "button"; + sessions.className = "btn btn-ghost welcome-sessions"; + sessions.textContent = "☰ Sessions"; + sessions.addEventListener("click", () => requestToggleDrawer()); + actions.appendChild(sessions); const spawn = document.createElement("button"); spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; spawn.title = "Draft pane: n — fast-forward spawn: N"; spawn.addEventListener("click", () => requestOpenSpawn()); - (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); + actions.appendChild(spawn); return el; } @@ -226,7 +234,7 @@ function showMultiView(): void { updateShellRoute(); } -// Spawn affordances (#62): the header button, the sidebar's empty state, and +// Spawn affordances (#62): the sidebar's footer button, its empty state, and // `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the // cloned session (see spawnSimilar.ts). function requestOpenSpawn(): void { diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 1408a05..9f129c6 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -470,7 +470,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { canvas.replaceChildren(); const tab = activeTab(layout); // Mobile: the tab bar is a second navigation surface we don't want, so CSS - // collapses it; the header ☰ keeps the sidebar drawer reachable. + // collapses it; the pane header's ☰ keeps the sidebar drawer reachable. el.classList.toggle("mv-mobile-solo", isMobile); if (!tab.root) { diff --git a/web/src/styles.css b/web/src/styles.css index d73701f..f2de946 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -365,73 +365,6 @@ textarea { /* ---------- app shell: sidebar + content ---------- */ -/* Global app header: brand + app-level actions (spawn, theme toggle), spanning - the full width above the split. */ -.app-header { - display: flex; - align-items: center; - gap: var(--sp-3); - flex: 0 0 auto; - padding: var(--sp-1) var(--sp-3); - border-bottom: 1px solid var(--border); - background: color-mix(in oklab, var(--surface) 82%, transparent); - backdrop-filter: blur(8px); -} - -/* Hamburger to the left of the brand — toggles the off-canvas session drawer. - Mobile only (desktop keeps the sidebar permanently in view). */ -.app-menu { - display: none; - place-items: center; - flex: none; - width: 32px; - height: 32px; - border: none; - border-radius: var(--r-1); - background: transparent; - color: var(--ink); - font-size: var(--fs-17); - cursor: pointer; -} -.app-menu:hover { - background: var(--surface-2); -} - -/* Three-part header: brand (left), status (centered), actions (right). Equal - flex on the outer two keeps the status group truly centered. */ -.app-header .brand { - flex: 1 1 0; - min-width: 0; - margin-right: 0; -} - -.app-status { - flex: 0 1 auto; - display: flex; - justify-content: center; - min-width: 0; - overflow: hidden; -} - -.app-status .statusbar { - border-bottom: 0; - background: transparent; - padding: 0; - flex-wrap: nowrap; -} - -.app-status .statusbar .spacer { - display: none; -} - -.app-actions { - display: flex; - align-items: center; - gap: var(--sp-2); - flex: 1 1 0; - justify-content: flex-end; -} - .app-shell { flex: 1 1 auto; min-height: 0; @@ -479,9 +412,6 @@ textarea { .sidebar-resize { display: none; } - .app-status { - display: none; - } } /* Sidebar's own slim header: just the session search. */ @@ -638,42 +568,6 @@ textarea { box-shadow: 0 0 0 3px var(--accent-tint); } -/* ---------- status bar ---------- */ - -.statusbar { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--sp-2) var(--sp-3); - padding: var(--sp-2) var(--sp-3); - border-bottom: 1px solid var(--border); - background: var(--surface); - font-size: var(--fs-12); - color: var(--muted); -} - -.statusbar .spacer { - /* zero basis: claim leftover space without inflating the bar's min width */ - flex: 1 1 0; - min-width: var(--sp-2); -} - -.session-tally { - display: inline-flex; - align-items: center; - gap: 7px; - font-variant-numeric: tabular-nums; -} - -.session-tally::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--live); - box-shadow: 0 0 5px var(--live-glow); -} - /* ---------- session tree ---------- */ .tree { @@ -1247,16 +1141,17 @@ input.leaf-rename:focus { width: 26px; } -.attach-header .back { +/* Opens the off-canvas session drawer. Desktop keeps the sidebar permanently in + view, so it is mobile-only — same rule as the welcome state's ☰. */ +.attach-header .sessions-btn { font-size: var(--fs-15); width: 28px; height: 26px; } -/* Desktop keeps the session list permanently in view, so the in-header "back" - arrow is redundant — the sidebar is the nav. Mobile (single-pane) keeps it. */ @media (min-width: 860px) { - .attach-header .back { + .attach-header .sessions-btn, + .welcome-sessions { display: none; } } @@ -2087,9 +1982,6 @@ input.leaf-rename:focus { max-width: none; order: -1; } - .statusbar .spacer { - display: none; - } .tree-leaf .leaf-actions { opacity: 1; } @@ -2383,11 +2275,6 @@ input.leaf-rename:focus { .mv-pane:not(.is-focused) .attach-header { opacity: 0.7; } -/* per-pane back button is meaningless in multi-view (use Close pane instead) */ -.mv-pane .attach-header .back { - display: none; -} - /* Draft pane (#57): an empty pane renders the shared spawn form inline. The host is already a flex column; the form fills and scrolls within it. */ .mv-pane-host > .mv-draft { @@ -2509,10 +2396,6 @@ input.leaf-rename:focus { } @media (max-width: 859px) { - .app-menu { - display: inline-grid; - } - /* sidebar → off-canvas drawer (override the route-based show/hide) */ .app-shell > .sidebar { display: flex !important;