From 5c3f9cab9fefc3b62cb78475af8c030dcbf5717e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:36:48 +0100 Subject: [PATCH 1/2] feat(maestro): replace spawn modal with draft panes (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft pane covers every field the modal had; the modal was a second code path for the same job. The header Spawn button, sidebar empty state, welcome button, and ?mock&spawn now open a draft pane via MultiView.openDraft — reusing an empty focused pane, else splitting it. "Spawn similar" seeds a draft with the session's cwd + command. Modal-only CSS (backdrop, sheet/dialog chrome, keyframes) is deleted; .modal-body form styling folds into .spawn-form, which also picks up the select styling the worktree branch picker was borrowing from the modal. --- web/README.md | 9 +-- web/src/catalog.ts | 23 +++---- web/src/html.ts | 4 +- web/src/kit.css | 2 +- web/src/kit.ts | 6 +- web/src/main.ts | 20 +++++- web/src/mock.ts | 4 +- web/src/multiview.test.ts | 137 ++++++++++++++++++++++++++++++++++++++ web/src/multiview.ts | 28 ++++++++ web/src/spawn.ts | 68 ------------------- web/src/spawnForm.ts | 12 ++-- web/src/styles.css | 128 +++-------------------------------- 12 files changed, 223 insertions(+), 218 deletions(-) create mode 100644 web/src/multiview.test.ts delete mode 100644 web/src/spawn.ts diff --git a/web/README.md b/web/README.md index 2dec9ca..5451a56 100644 --- a/web/README.md +++ b/web/README.md @@ -20,9 +20,10 @@ src/ main.ts # entry point, hash-route dispatch api.ts # daemon HTTP API client events.ts # `/api/events` WebSocket subscriber - catalog.ts # session tree view (groups by working directory) - attach.ts # xterm.js + attach WS view - spawn.ts # spawn-session modal + catalog.ts # session tree view (groups by working directory) + attach.ts # xterm.js + attach WS view + multiview.ts # tabs + split panes; draft panes host the spawn form + spawnForm.ts # shared spawn form (preset tiles, project picker, worktree) theme.ts # dark/light theme toggle + persistence types.ts # mirror of `maestro-protocol` JSON shapes styles.css # design-system tokens + component styles @@ -40,7 +41,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in `mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev server with `?mock` to seed a representative catalog without a running daemon, -`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme. +`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme. Routing is hash-based: - `#/` → catalog diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 9475ca3..b96f4f5 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -6,9 +6,15 @@ import type { Session } from "./types.js"; import { abbreviateFolderName, sessionName } from "./types.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; -import { openSpawnModal } from "./spawn.js"; import { createThemeToggle } from "./theme.js"; +/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the + * multi-view controller lives; dispatched from every Spawn affordance. */ +export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; +export function requestOpenSpawn(): void { + document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); +} + const collapsed = new Set(); function renderNode(node: TreeNode, depth: number): HTMLElement { @@ -190,7 +196,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; - spawnBtn.addEventListener("click", () => openSpawnModal()); + spawnBtn.addEventListener("click", () => requestOpenSpawn()); actions.appendChild(spawnBtn); return header; } @@ -342,7 +348,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S treeHost.replaceChildren(); if (sessions.length === 0) { treeHost.appendChild( - renderEmpty(filter.trim().length > 0, () => openSpawnModal()), + renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()), ); return; } @@ -364,14 +370,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const rerenderHandler = () => refresh(); document.addEventListener("catalog-rerender", rerenderHandler); - // Spawn similar: reopen the spawn modal pre-seeded from a session's dir and - // command (matched back to a preset by the form) — but with a freshly - // generated name rather than the original's, since it's a new session. - const spawnSimilarHandler = (ev: Event) => { - const s = (ev as CustomEvent).detail; - openSpawnModal({ cwd: s.cwd, command: s.command }); - }; - document.addEventListener("catalog-spawn-similar", spawnSimilarHandler); + // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the + // multi-view controller can seed a draft pane with the session's dir/command. const unsubscribe = store.subscribe(refresh); @@ -391,7 +391,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }, dispose() { document.removeEventListener("catalog-rerender", rerenderHandler); - document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler); unsubscribe(); }, }; diff --git a/web/src/html.ts b/web/src/html.ts index 93f5646..01db561 100644 --- a/web/src/html.ts +++ b/web/src/html.ts @@ -1,5 +1,5 @@ -// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts, -// types.ts and attach.ts. +// One HTML escaper. Previously written four times, in catalog.ts, types.ts +// and attach.ts. export function escapeHtml(s: string): string { return s diff --git a/web/src/kit.css b/web/src/kit.css index 062c9a3..29116c9 100644 --- a/web/src/kit.css +++ b/web/src/kit.css @@ -102,7 +102,7 @@ background: color-mix(in oklab, var(--surface) 40%, var(--bg)); } -.kit-slot .modal-body { +.kit-slot .spawn-form { padding: 0; min-width: 200px; } diff --git a/web/src/kit.ts b/web/src/kit.ts index bc42371..f33759c 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -135,7 +135,7 @@ export function renderKit(): HTMLElement { { const { el, body } = section( "Buttons — current", - "The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.", + "The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.", ); body.append( specimen("btn-primary", btn("btn btn-primary", "+ Spawn")), @@ -170,12 +170,12 @@ export function renderKit(): HTMLElement { search.type = "search"; search.className = "search"; search.placeholder = "Search sessions…"; - const text = h("div", "modal-body"); + const text = h("div", "spawn-form"); const ti = document.createElement("input"); ti.type = "text"; ti.placeholder = "branch-name"; text.appendChild(ti); - const sel = h("div", "modal-body"); + const sel = h("div", "spawn-form"); const s = document.createElement("select"); s.innerHTML = ``; sel.appendChild(s); diff --git a/web/src/main.ts b/web/src/main.ts index 7e81f41..27de74c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,9 +7,9 @@ import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; -import { openSpawnModal } from "./spawn.js"; +import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; -import { sessionName } from "./types.js"; +import { sessionName, type Session } from "./types.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -212,7 +212,7 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; - spawn.addEventListener("click", () => openSpawnModal()); + spawn.addEventListener("click", () => requestOpenSpawn()); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; } @@ -251,6 +251,20 @@ function showMultiView(): void { updateShellRoute(); } +// Spawn affordances (#62): the header button, the sidebar's empty state, and +// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the +// cloned session's dir + command. +function requestOpenSpawn(): void { + showMultiView(); + mv.openDraft(); +} +document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); +document.addEventListener("catalog-spawn-similar", (ev) => { + const s = (ev as CustomEvent).detail; + showMultiView(); + mv.openDraft({ cwd: s.cwd, command: s.command }); +}); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } diff --git a/web/src/mock.ts b/web/src/mock.ts index 098978d..dc9fc6f 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -135,10 +135,10 @@ export function seedMock(store: CatalogStore): void { sessions, }); - // `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn + // `?mock&spawn` auto-opens a draft pane for visual review, with the spawn // backend endpoints stubbed so presets/projects/gitroot all render. if (location.search.includes("spawn")) { stubSpawnApi(); - void import("./spawn.js").then((m) => m.openSpawnModal()); + document.dispatchEvent(new CustomEvent("catalog-open-spawn")); } } diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts new file mode 100644 index 0000000..4040a70 --- /dev/null +++ b/web/src/multiview.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { emptyLayout, leaves, type Layout } from "./layout.js"; +import { splitLeaf } from "./layout.js"; +import { createMultiView, type MultiView } from "./multiview.js"; +import type { ConnectionPool } from "./connections.js"; +import type { SidebarHandle } from "./catalog.js"; + +function stubFetch(): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/info") + ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } + : url.includes("/api/projects") + ? [] + : url.includes("/api/names") + ? { names: ["allegro-cello"] } + : undefined; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +function fakePool(): ConnectionPool { + return { + setVisible() {}, + setFocus() {}, + } as unknown as ConnectionPool; +} + +function fakeSidebar(): SidebarHandle { + return { + el: document.createElement("aside"), + setActive() {}, + setHighlights() {}, + dispose() {}, + }; +} + +function makeMv(layout: Layout): MultiView { + return createMultiView({ + pool: fakePool(), + layout, + sidebar: fakeSidebar(), + infoFor: () => ({ name: "sess", command: "bash" }), + onDisconnect: () => {}, + renderWelcome: () => document.createElement("div"), + }); +} + +/** Drain the microtask queue so the draft form's async seed application lands. */ +async function flush(): Promise { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} + +describe("multiview openDraft (#62)", () => { + beforeEach(() => { + stubFetch(); + document.body.replaceChildren(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("turns an empty tab into a draft pane", async () => { + const layout = emptyLayout(); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + + mv.openDraft(); + await flush(); + + const ls = leaves(layout.tabs[0].root); + expect(ls).toHaveLength(1); + expect(ls[0].sessionId).toBeNull(); + expect(ls[0].draft).not.toBeNull(); + expect(mv.el.querySelector(".mv-draft")).toBeTruthy(); + }); + + it("splits the focused session pane into a fresh draft", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + tab.focusedLeafId = first; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(); + await flush(); + + const ls = leaves(tab.root); + expect(ls).toHaveLength(2); + expect(ls[0].sessionId).toBe("s1"); + const draftLeaf = ls.find((l) => l.sessionId === null)!; + expect(draftLeaf.draft).not.toBeNull(); + expect(tab.focusedLeafId).toBe(draftLeaf.id); + expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2); + }); + + it("reuses the focused pane when it is already empty", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + const empty = splitLeaf(tab, first, null, "row", true); + tab.focusedLeafId = empty; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(); + await flush(); + + const ls = leaves(tab.root); + expect(ls).toHaveLength(2); + const target = ls.find((l) => l.id === empty)!; + expect(target.draft).not.toBeNull(); + expect(tab.focusedLeafId).toBe(empty); + }); + + it("seeds a clone's cwd and command into the draft", async () => { + const layout = emptyLayout(); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + + mv.openDraft({ cwd: "/home/user/proj", command: "claude" }); + await flush(); + + const leaf = leaves(layout.tabs[0].root)[0]; + expect(leaf.draft?.cwd).toBe("/home/user/proj"); + expect(leaf.draft?.command).toBe("claude"); + }); +}); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 0a727c0..f588239 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -13,6 +13,7 @@ import { closeTab, findLeaf, type Dir, + type Draft, type Layout, leaves, locateSession, @@ -52,6 +53,10 @@ export interface MultiView { closeSession: (sessionId: string) => void; /** Every session currently shown in some pane, across all tabs. */ openSessions: () => Set; + /** Open a draft pane (issue #62): reuse the focused pane if it is already + * empty, otherwise split the focused pane. `seed` pre-fills the form + * ("Spawn similar" clones a session's cwd + command). */ + openDraft: (seed?: Partial) => void; /** Start dragging a sidebar session into the canvas (issue #32). The * controller owns drag-vs-tap: a tap lets the row's normal click open it, a * drag shows drop zones to place it. */ @@ -366,6 +371,28 @@ export function createMultiView(opts: MultiViewOptions): MultiView { render(); } + function openDraft(seed?: Partial): void { + const tab = activeTab(layout); + const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); + if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { + // Empty tab (or stale focus): the draft becomes the first pane. + tab.focusedLeafId = splitLeaf(tab, "", null, "row", true); + findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft(); + render(); + return; + } + const target = findLeaf(tab.root, tab.focusedLeafId)!; + if (!target.sessionId && !target.draft) { + // An already-empty pane becomes the draft in place. + target.draft = draft(); + } else { + // Split a fresh draft beside the focused pane. + tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true); + findLeaf(tab.root, tab.focusedLeafId)!.draft = draft(); + } + render(); + } + // ---- top-level render ---- function render(): void { @@ -640,6 +667,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return { el, openSession, + openDraft, closeSession, openSessions: () => openSessions(layout), beginSessionDrag, diff --git a/web/src/spawn.ts b/web/src/spawn.ts deleted file mode 100644 index 34683c8..0000000 --- a/web/src/spawn.ts +++ /dev/null @@ -1,68 +0,0 @@ -// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared -// spawn form (spawnForm.ts). The modal coexists with the inline draft panes -// (#57) until a later issue deletes it. - -import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js"; - -/** Seed values for the spawn form — used by "clone" to reopen the dialog with - * the same cwd, command, and name as an existing session. */ -export type SpawnPrefill = SpawnFormSeed; - -export function openSpawnModal(prefill?: SpawnPrefill): void { - const existing = document.getElementById("modal-host"); - if (existing) existing.remove(); - - const host = document.createElement("div"); - host.id = "modal-host"; - host.className = "modal-backdrop"; - host.innerHTML = ` - - `; - document.body.appendChild(host); - - const formHandle = createSpawnForm({ - seed: prefill, - class: "modal-body", - onSuccess(session) { - close(); - location.hash = `#/attach/${encodeURIComponent(session.id)}`; - }, - }); - (host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el); - - const close = () => { - formHandle.destroy(); - host.remove(); - }; - // The modal keeps its Cancel affordance; the shared form ships only Submit. - const actions = formHandle.el.querySelector(".form-actions") as HTMLElement; - const cancel = document.createElement("button"); - cancel.type = "button"; - cancel.className = "btn btn-ghost cancel"; - cancel.textContent = "Cancel"; - cancel.addEventListener("click", close); - actions.prepend(cancel); - - host.querySelector(".close")?.addEventListener("click", close); - host.addEventListener("click", (e) => { - if (e.target === host) close(); - }); - window.addEventListener( - "keydown", - (e) => { - if (e.key === "Escape") close(); - }, - { once: true }, - ); - - // ---- clone seed ------------------------------------------------------ - if (prefill) { - (host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session"; - } -} diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 2c11e34..238d31a 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -16,9 +16,9 @@ import { abbreviateCwd, abbreviateFolderName } from "./types.js"; * from the repository's existing branches (#61). */ export type WorktreeBranchMode = "new" | "existing"; -/** Seed values for the spawn form — used by "clone" to reopen with the same - * cwd, command, and name as an existing session, and by draft panes to restore - * their own half-filled state after a re-render. */ +/** Seed values for the spawn form — used by "clone" to open a draft with the + * same cwd, command, and name as an existing session, and by draft panes to + * restore their own half-filled state after a re-render. */ export interface SpawnFormSeed { command?: string; cwd?: string; @@ -99,7 +99,7 @@ export interface SpawnFormHandle { export interface SpawnFormOptions { seed?: SpawnFormSeed; - /** Extra class(es) for the
root (e.g. `modal-body` in the modal). */ + /** Extra class(es) for the root (e.g. `mv-draft` in a draft pane). */ class?: string; /** Fired whenever any value changes (input, tile pick, checkbox). */ onChange?: (values: SpawnFormValues) => void; @@ -108,7 +108,7 @@ export interface SpawnFormOptions { } /** The spawn form itself — preset tiles, project picker, name, worktree. - * Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */ + * Mounted inline by draft panes (issue #57). */ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const prefill = opts.seed ?? {}; @@ -174,7 +174,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { `; // Field queries are scoped to this form instance — several forms can be - // mounted at once (modal + draft panes), so ids would collide. + // mounted at once (one per draft pane), so ids would collide. const errEl = form.querySelector(".form-error") as HTMLElement; const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; diff --git a/web/src/styles.css b/web/src/styles.css index c1800ce..86673da 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1205,72 +1205,9 @@ textarea { border-color: var(--accent-strong); } -/* ---------- modal ---------- */ +/* ---------- forms ---------- */ -.modal-backdrop { - position: fixed; - inset: 0; - background: oklch(0.12 0.01 256 / 0.6); - backdrop-filter: blur(3px); - display: flex; - align-items: flex-end; - justify-content: center; - padding: 0; - z-index: var(--z-modal); - animation: fade var(--dur) var(--ease-out); -} - -@media (min-width: 600px) { - .modal-backdrop { - align-items: center; - padding: var(--sp-6); - } -} - -.modal { - background: var(--surface); - border-radius: var(--r-3) var(--r-3) 0 0; - border: 1px solid var(--border); - width: 100%; - max-width: 560px; - max-height: 92vh; - display: flex; - flex-direction: column; - box-shadow: var(--shadow-2); - animation: sheet-up var(--dur-slow) var(--ease-out); -} - -@media (min-width: 600px) { - .modal { - border-radius: var(--r-3); - animation: pop var(--dur) var(--ease-out); - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-3); - border-bottom: 1px solid var(--border); -} - -.modal-header h2 { - margin: 0; - font-size: var(--fs-15); - font-weight: 600; - display: inline-flex; - align-items: center; - gap: var(--sp-2); -} - -.modal-header h2::before { - content: "+"; - color: var(--accent); - font-weight: 700; -} - -.modal-body { +.spawn-form { padding: var(--sp-3); overflow: auto; display: flex; @@ -1280,7 +1217,6 @@ textarea { scrollbar-color: var(--border-strong) transparent; } -.modal-body label, .spawn-form label { display: flex; flex-direction: column; @@ -1290,7 +1226,6 @@ textarea { letter-spacing: 0.01em; } -.modal-body label.row-inline, .spawn-form label.row-inline { flex-direction: row; align-items: center; @@ -1300,10 +1235,9 @@ textarea { cursor: pointer; } -.modal-body input[type="text"], -.modal-body input[type="search"], -.modal-body select, -.spawn-form input[type="text"] { +.spawn-form input[type="text"], +.spawn-form input[type="search"], +.spawn-form select { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-2); @@ -1318,23 +1252,20 @@ textarea { background var(--dur-fast) var(--ease-out); } -.modal-body input[type="text"]:hover, -.modal-body select:hover, -.spawn-form input[type="text"]:hover { +.spawn-form input[type="text"]:hover, +.spawn-form select:hover { border-color: var(--border-strong); } -.modal-body input[type="text"]:focus, -.modal-body input[type="search"]:focus, -.modal-body select:focus, -.spawn-form input[type="text"]:focus { +.spawn-form input[type="text"]:focus, +.spawn-form input[type="search"]:focus, +.spawn-form select:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-tint); } -.modal-body input[type="checkbox"], .spawn-form input[type="checkbox"] { width: 18px; height: 18px; @@ -1359,13 +1290,6 @@ textarea { font-size: var(--fs-13); } -.modal-actions { - display: flex; - gap: var(--sp-2); - justify-content: flex-end; - margin-top: var(--sp-2); -} - .form-error { color: var(--danger); margin: 0; @@ -1381,7 +1305,6 @@ textarea { /* ---------- spawn form: tiles + recents picker (#27) ---------- */ -.spawn-modal .field, .spawn-form .field { display: flex; flex-direction: column; @@ -1611,7 +1534,7 @@ textarea { .dir-ac { position: fixed; - z-index: var(--z-toast); /* above the modal it completes within */ + z-index: var(--z-toast); /* above the draft pane it completes within */ margin: 0; padding: var(--sp-1); list-style: none; @@ -1759,26 +1682,6 @@ textarea { } } -@keyframes fade { - from { - opacity: 0; - } -} - -@keyframes sheet-up { - from { - transform: translateY(16px); - opacity: 0; - } -} - -@keyframes pop { - from { - transform: scale(0.98); - opacity: 0; - } -} - /* ---------- responsive ---------- */ @media (max-width: 520px) { @@ -2119,15 +2022,6 @@ textarea { flex: 1; min-width: 0; min-height: 0; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--border-strong) transparent; - /* The modal form got its stacking and padding from `.modal-body`; in a pane - the form is the host, so it carries them itself. */ - display: flex; - flex-direction: column; - gap: var(--sp-2); - padding: var(--sp-3); } /* pane tools cluster (disconnect / split / close), top-right corner. The attach From 51f91b3f5d9ba3d97ef7e7618dd465876d49ed77 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:36:51 +0100 Subject: [PATCH 2/2] docs(maestro): amend DESIGN.md for draft panes (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawn modal is no longer one of the three surfaces: the Components section now describes the draft pane, and the stale ≥600px bottom-sheet→dialog breakpoint note is dropped. --- DESIGN.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 282b754..7cb8220 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`. `connecting` state pill pulses. - Interaction: row hover/focus, button press (1px), input focus ring, chevron rotation, theme-toggle sun/moon swap. -- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px. - Every animation has a `prefers-reduced-motion: reduce` path (pulses off, transitions near-instant). ## Layout & structure Mobile-first, structural responsiveness (not fluid type). Single `≤520px` -breakpoint stacks the header (full-width search, wrapped actions). `≥600px` -switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as -terminal atmosphere (very low contrast, not decoration). +breakpoint stacks the header (full-width search, wrapped actions). A faint +slate grid texture sits on `--bg` as terminal atmosphere (very low contrast, +not decoration). ## Components @@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class: no-sessions, no-search-match. - **Attach** — header (back, `session `, live/closed/error state pill) over a framed dark xterm with a green cursor. -- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus - ring; grouped worktree fieldset. +- **Draft pane** — the spawn form mounted inline in an empty split pane, + focusable like any session pane; mono inputs with green focus ring; grouped + worktree fieldset. Spawn opens one (reusing an empty focused pane, else + splitting), and "Spawn similar" seeds one from a session's dir + command. Interactive elements ship default / hover / focus-visible / active / disabled states; the accent green is used only for action, selection, focus, and live @@ -111,5 +112,5 @@ status — never decoration. ## Dev review aids (DEV-only, stripped from production) `web/src/mock.ts` + query params seed a representative catalog without a running -daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal), +daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.