diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 65d8efd..5dd1045 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -1,6 +1,7 @@ import { escapeHtml } from "./html.js"; import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { openContextMenu } from "./contextMenu.js"; +import { toggleHelp } from "./help.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { @@ -325,6 +326,14 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): kit.title = "Open the design kit (DEV)"; actions.appendChild(kit); } + const helpBtn = document.createElement("button"); + helpBtn.type = "button"; + helpBtn.className = "btn btn-ghost btn-icon help-btn"; + helpBtn.textContent = "?"; + helpBtn.title = "Keyboard shortcuts (?)"; + helpBtn.setAttribute("aria-label", "Keyboard shortcuts"); + helpBtn.addEventListener("click", () => toggleHelp()); + actions.appendChild(helpBtn); actions.appendChild(createThemeToggle()); const spawnBtn = document.createElement("button"); spawnBtn.type = "button"; @@ -338,12 +347,10 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): } /** Two-tier sidebar highlight state (issue #33): which sessions are open in some - * pane (quiet), which one is focused (loud), and a stable per-session colour so - * a row maps to its pane. */ + * pane (quiet), and which one is focused (loud). */ export interface SidebarHighlights { open: Set; focused: string | null; - colors: Map; } /** Live handle to the persistent sidebar (built once, survives navigation). */ @@ -353,7 +360,7 @@ export interface SidebarHandle { /** Highlight the row for `id` (or clear it with `null`) as the focused session. * Back-compat shim over {@link setHighlights}. */ setActive(id: string | null): void; - /** Apply the full open/focused/colour highlight state. */ + /** Apply the full open/focused highlight state. */ setHighlights(h: SidebarHighlights): void; /** Tear down the store subscription + listeners. */ dispose(): void; @@ -462,7 +469,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S refresh(); }); - let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() }; + let highlights: SidebarHighlights = { open: new Set(), focused: null }; const applyHighlights = () => { for (const n of treeHost.querySelectorAll(".tree-leaf")) { const id = n.dataset.sessionId; @@ -471,9 +478,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const open = highlights.open.has(id); n.classList.toggle("is-active", focused); n.classList.toggle("is-open", open && !focused); - const color = highlights.colors.get(id); - if (color && (open || focused)) n.style.setProperty("--mv-color", color); - else n.style.removeProperty("--mv-color"); } }; @@ -526,7 +530,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S highlights = { open: id ? new Set([id]) : new Set(), focused: id, - colors: new Map(), }; applyHighlights(); }, diff --git a/web/src/help.test.ts b/web/src/help.test.ts new file mode 100644 index 0000000..660e06d --- /dev/null +++ b/web/src/help.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + SHORTCUTS, + closeHelp, + installHelpKeybind, + isHelpOpen, + isTypingTarget, + openHelp, +} from "./help.js"; + +beforeEach(() => { + installHelpKeybind(); +}); + +afterEach(() => { + closeHelp(); + document.body.replaceChildren(); +}); + +function press(key: string, init: KeyboardEventInit = {}): void { + window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); +} + +function rows(): string[] { + return [...document.querySelectorAll(".help-row")].map((r) => + [...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "), + ); +} + +describe("help overlay", () => { + it("renders every registered shortcut, grouped", () => { + openHelp(); + const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent); + expect(groups).toEqual(SHORTCUTS.map((g) => g.title)); + expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" ")))); + expect(document.querySelectorAll(".help-desc")).toHaveLength( + SHORTCUTS.reduce((n, g) => n + g.items.length, 0), + ); + }); + + it("opens on ? and closes on a second ?", () => { + press("?", { shiftKey: true }); + expect(isHelpOpen()).toBe(true); + press("?", { shiftKey: true }); + expect(isHelpOpen()).toBe(false); + }); + + it("closes on Escape, on the close button, and on a click outside the panel", () => { + openHelp(); + press("Escape"); + expect(isHelpOpen()).toBe(false); + + openHelp(); + (document.querySelector(".help-close") as HTMLButtonElement).click(); + expect(isHelpOpen()).toBe(false); + + openHelp(); + (document.querySelector(".help-overlay") as HTMLElement).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + expect(isHelpOpen()).toBe(false); + }); + + it("stays open for a click inside the panel", () => { + openHelp(); + (document.querySelector(".help-panel") as HTMLElement).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + expect(isHelpOpen()).toBe(true); + }); + + it("opening twice leaves one overlay", () => { + openHelp(); + openHelp(); + expect(document.querySelectorAll(".help-overlay")).toHaveLength(1); + }); + + it("ignores ? while typing and when a non-shift modifier is held", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true })); + expect(isHelpOpen()).toBe(false); + + press("?", { ctrlKey: true }); + expect(isHelpOpen()).toBe(false); + }); + + it("installing the keybind twice still toggles once", () => { + installHelpKeybind(); + press("?", { shiftKey: true }); + expect(isHelpOpen()).toBe(true); + expect(document.querySelectorAll(".help-overlay")).toHaveLength(1); + }); +}); + +describe("isTypingTarget", () => { + it("is true for form fields and editable nodes, false otherwise", () => { + const el = (html: string) => { + const d = document.createElement("div"); + d.innerHTML = html; + return d.firstElementChild as HTMLElement; + }; + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el('
'))).toBe(true); + expect(isTypingTarget(el(""))).toBe(false); + expect(isTypingTarget(null)).toBe(false); + }); +}); diff --git a/web/src/help.ts b/web/src/help.ts new file mode 100644 index 0000000..2437aec --- /dev/null +++ b/web/src/help.ts @@ -0,0 +1,159 @@ +/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help + * overlay. One registry drives both the rendering and what we claim is bound, + * so a binding added to `main.ts` without an entry here reads as missing. */ + +export interface Shortcut { + /** Keycaps, rendered left to right as separate . */ + keys: string[]; + description: string; +} + +export interface ShortcutGroup { + title: string; + items: Shortcut[]; +} + +export const SHORTCUTS: ShortcutGroup[] = [ + { + title: "Sessions", + items: [ + { keys: ["n"], description: "Open a spawn draft pane" }, + { keys: ["N"], description: "Spawn a session with every default resolved" }, + ], + }, + { + title: "Help", + items: [ + { keys: ["?"], description: "Show or hide this help" }, + { keys: ["Esc"], description: "Close the help overlay or an open menu" }, + ], + }, + { + title: "Debug", + items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }], + }, +]; + +/** True when the event target swallows plain keystrokes — a form field, or the + * hidden textarea xterm types into. Shared with `main.ts` so every global + * single-key binding is deaf in the same places. */ +export function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || typeof el.tagName !== "string") return false; + return ( + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || + el.isContentEditable + ); +} + +let open: { el: HTMLElement; dispose: () => void } | null = null; + +export function isHelpOpen(): boolean { + return open !== null; +} + +export function closeHelp(): void { + open?.dispose(); +} + +export function toggleHelp(): void { + if (open) closeHelp(); + else openHelp(); +} + +/** Mount the overlay. Opening while already open is a no-op re-render, so a + * stray second call can never stack two dialogs. */ +export function openHelp(): HTMLElement { + closeHelp(); + + const el = document.createElement("div"); + el.className = "help-overlay"; + el.setAttribute("role", "dialog"); + el.setAttribute("aria-modal", "true"); + el.setAttribute("aria-label", "Keyboard shortcuts"); + + const panel = document.createElement("div"); + panel.className = "help-panel"; + + const head = document.createElement("header"); + head.className = "help-head"; + const title = document.createElement("h2"); + title.className = "help-title"; + title.textContent = "Keyboard shortcuts"; + const close = document.createElement("button"); + close.type = "button"; + close.className = "help-close"; + close.setAttribute("aria-label", "Close"); + close.textContent = "✕"; + close.addEventListener("click", closeHelp); + head.append(title, close); + panel.appendChild(head); + + for (const group of SHORTCUTS) { + const section = document.createElement("section"); + section.className = "help-group"; + const heading = document.createElement("h3"); + heading.className = "help-group-title"; + heading.textContent = group.title; + const list = document.createElement("dl"); + list.className = "help-list"; + for (const item of group.items) { + const row = document.createElement("div"); + row.className = "help-row"; + const keys = document.createElement("dt"); + keys.className = "help-keys"; + for (const key of item.keys) { + const cap = document.createElement("kbd"); + cap.textContent = key; + keys.appendChild(cap); + } + const desc = document.createElement("dd"); + desc.className = "help-desc"; + desc.textContent = item.description; + row.append(keys, desc); + list.appendChild(row); + } + section.append(heading, list); + panel.appendChild(section); + } + + // A press on the scrim closes; one inside the panel is left alone. + el.addEventListener("click", (ev) => { + if (!panel.contains(ev.target as Node)) closeHelp(); + }); + el.appendChild(panel); + document.body.appendChild(el); + + const dispose = () => { + if (open?.el !== el) return; + open = null; + el.remove(); + }; + open = { el, dispose }; + close.focus(); + return el; +} + +let installed = false; + +/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a + * second call during a hot reload doesn't double-toggle. */ +export function installHelpKeybind(): void { + if (installed) return; + installed = true; + window.addEventListener("keydown", (e) => { + if (e.key === "Escape" && open) { + e.preventDefault(); + closeHelp(); + return; + } + // Shift is how `?` is typed on most layouts; the others would be a + // different binding entirely. + if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return; + if (isTypingTarget(e.target)) return; + e.preventDefault(); + toggleHelp(); + }); +} diff --git a/web/src/main.ts b/web/src/main.ts index 11130bd..cb268aa 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,6 +1,7 @@ import { renderSidebar, renderTopbar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; import { installHudKeybind } from "./debug.js"; +import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; import { CatalogStore } from "./events.js"; import { shouldRetryClose } from "./reconnect.js"; import { @@ -15,6 +16,7 @@ import { resolveFastForward, } from "./fastForward.js"; import { FALLBACK_PRESETS } from "./spawnForm.js"; +import { spawnSimilarSeed } from "./spawnSimilar.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; @@ -28,6 +30,7 @@ import "./styles.css"; initTheme(); installViewportSizing(); installHudKeybind(); +installHelpKeybind(); registerServiceWorker(); // Register the PWA service worker so the app is installable and the shell loads @@ -219,6 +222,7 @@ function renderWelcome(): HTMLElement { Pick a session from the list, or start a new one. Open sessions keep streaming in the background — switching back is instant.

+

Press ? for keyboard shortcuts.

`; const spawn = document.createElement("button"); spawn.type = "button"; @@ -265,8 +269,8 @@ function showMultiView(): void { } // 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. +// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the +// cloned session (see spawnSimilar.ts). function requestOpenSpawn(): void { showMultiView(); // A new tab, not a split of the pane you were reading (#100). "Spawn @@ -279,7 +283,7 @@ 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 }); + mv.openDraft(spawnSimilarSeed(s)); }); // Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns @@ -327,16 +331,9 @@ async function requestFastForward(): Promise { // textarea, search) so an attached session's keys are never hijacked. document.addEventListener("keydown", (e) => { if (e.ctrlKey || e.altKey || e.metaKey) return; - const target = e.target as HTMLElement | null; - if ( - target && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable) - ) { - return; - } + if (isTypingTarget(e.target)) return; + // The help overlay owns the keyboard while it is up. + if (isHelpOpen()) return; if (e.key === "n") { e.preventDefault(); requestOpenSpawn(); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index f19de89..3da26a8 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -22,7 +22,6 @@ import { removeLeaf, splitLeaf, } from "./layout.js"; -import { sessionColor } from "./sessioncolor.js"; import type { AttachInfo } from "./attach.js"; import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; @@ -97,12 +96,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // ---- highlight helpers ---- - function colorMap(): Map { - const m = new Map(); - for (const id of openSessions(layout)) m.set(id, sessionColor(id)); - return m; - } - function focusedLeafSession(): string | null { const tab = activeTab(layout); const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null; @@ -120,7 +113,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { sidebar.setHighlights({ open: openSessions(layout), focused: focusedLeafSession(), - colors: colorMap(), }); } @@ -142,10 +134,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const pane = document.createElement("div"); pane.className = "mv-pane"; pane.dataset.leafId = leaf.id; - if (leaf.sessionId) { - pane.dataset.sessionId = leaf.sessionId; - pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId)); - } + if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId; const host = document.createElement("div"); host.className = "mv-pane-host"; @@ -482,7 +471,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { pool.setVisible([]); pool.setFocus(null); canvas.appendChild(renderWelcome()); - sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() }); + sidebar.setHighlights({ open: openSessions(layout), focused: null }); persist(); return; } @@ -523,7 +512,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { pill.type = "button"; pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : ""); pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty"; - if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId)); pill.addEventListener("click", () => { tab.focusedLeafId = l.id; render(); @@ -543,7 +531,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [], ); pool.setFocus(focused.sessionId ?? null); - sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() }); + sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null }); } function closeSession(sessionId: string): void { diff --git a/web/src/sessioncolor.ts b/web/src/sessioncolor.ts deleted file mode 100644 index a61caab..0000000 --- a/web/src/sessioncolor.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Stable per-session accent colour, used to map a sidebar row to the pane -// showing that session (issue #33). Hash the session id into a fixed OKLCH -// palette tuned to the maestro tokens; collisions are tolerated when there are -// more open sessions than hues — the colour is a hint, not an identity. - -import type { Uuid } from "./types.js"; - -const PALETTE = [ - "oklch(0.80 0.13 152)", // green (the house accent) - "oklch(0.80 0.12 90)", // amber - "oklch(0.76 0.13 30)", // coral - "oklch(0.78 0.11 210)", // teal - "oklch(0.74 0.13 265)", // blue - "oklch(0.76 0.14 320)", // magenta - "oklch(0.80 0.11 60)", // gold - "oklch(0.78 0.12 185)", // cyan -]; - -export function sessionColor(id: Uuid): string { - let h = 0; - for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; - return PALETTE[h % PALETTE.length]; -} diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 8055757..96fa2a1 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -589,7 +589,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwd = seedCwd; cwdRaw.value = seedCwd; // Restoring must not touch the worktree toggle — the seed carries it. - if (!restoreSelection(seedCwd)) clearProjectSelection(); + // A path no box matches (the project is gone, or the scan never saw + // it) falls back to the raw input showing that path: an empty picker + // with an invisible cwd reads as a form that lost the directory. + if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false); } else { showRawCwd(seedCwd, false); } diff --git a/web/src/spawnSimilar.test.ts b/web/src/spawnSimilar.test.ts new file mode 100644 index 0000000..e80e9ac --- /dev/null +++ b/web/src/spawnSimilar.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { spawnSimilarSeed } from "./spawnSimilar.js"; +import type { Session } from "./types.js"; + +function session(overrides: Partial = {}): Session { + return { + id: "s1", + cwd: "/repos/app", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...overrides, + } as Session; +} + +describe("spawnSimilarSeed (#28)", () => { + it("seeds the project picker, not the free-text hatch", () => { + const seed = spawnSimilarSeed(session()); + expect(seed.cwd).toBe("/repos/app"); + expect(seed.command).toBe("claude"); + expect(seed.cwdRawVisible).toBe(false); + }); + + it("keeps the worktree toggle off for a session that has no worktree", () => { + expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false); + }); + + it("clones a worktree session as a new worktree of its base repo", () => { + const seed = spawnSimilarSeed( + session({ + cwd: "/repos/app-worktrees/feature", + worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" }, + }), + ); + expect(seed.cwd).toBe("/repos/app"); + expect(seed.worktreeEnabled).toBe(true); + }); + + it("never carries the cloned session's name", () => { + const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } })); + expect(seed.name).toBeUndefined(); + }); +}); diff --git a/web/src/spawnSimilar.ts b/web/src/spawnSimilar.ts new file mode 100644 index 0000000..0d85f9b --- /dev/null +++ b/web/src/spawnSimilar.ts @@ -0,0 +1,30 @@ +// "Spawn similar" (#28): the seed a cloned session hands to a draft pane. +// Pure — the caller owns opening the pane. + +import type { Draft } from "./layout.js"; +import type { Session } from "./types.js"; + +/** Clone a session into draft values: same project, same command, same + * worktree decision, fresh name. + * + * A worktree session seeds its *base repository*, not the checkout the daemon + * made for it: "similar" is another session of that project, and the daemon + * never adopts an existing worktree (docs/WORKTREES.md), so seeding the + * checkout would ask it to build a worktree out of a worktree. The picker + * then lands on that repository's box with "+ new" active. + * + * `cwdRawVisible: false` keeps the two-step project picker on screen with the + * cloned project selected, instead of dropping the path into the free-text + * escape hatch. + * + * The name is left out on purpose: a clone is a new session and takes its own + * generated handle. */ +export function spawnSimilarSeed(s: Session): Partial { + const wt = s.worktree ?? null; + return { + command: s.command, + cwd: wt ? wt.base_repo : s.cwd, + worktreeEnabled: wt != null, + cwdRawVisible: false, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css index cca53fd..566c681 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -876,6 +876,113 @@ input.leaf-rename:focus { background: var(--danger-tint); } +/* ---------- help overlay (`?`) ---------- */ +.help-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: grid; + place-items: center; + padding: var(--sp-4); + background: oklch(0 0 0 / 0.5); + backdrop-filter: blur(4px); +} + +.help-panel { + width: min(440px, 100%); + max-height: min(80vh, 640px); + overflow-y: auto; + padding: var(--sp-4); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-3); + box-shadow: var(--shadow-2); +} + +.help-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); + margin-bottom: var(--sp-3); +} + +.help-title { + margin: 0; + font-family: var(--font-mono); + font-size: var(--fs-15); + color: var(--ink); +} + +.help-close { + appearance: none; + background: none; + border: 0; + border-radius: var(--r-2); + color: var(--muted); + font: inherit; + line-height: 1; + padding: var(--sp-1) var(--sp-2); + cursor: pointer; +} + +.help-close:hover, +.help-close:focus-visible { + background: var(--surface-3); + color: var(--ink); + outline: none; +} + +.help-group + .help-group { + margin-top: var(--sp-4); +} + +.help-group-title { + margin: 0 0 var(--sp-2); + font-size: var(--fs-11); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} + +.help-list { + margin: 0; +} + +/* Keycaps in a fixed column so the descriptions line up down the list. */ +.help-row { + display: grid; + grid-template-columns: 14ch 1fr; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-1) 0; +} + +.help-keys { + display: flex; + flex-wrap: nowrap; + gap: var(--sp-1); + margin: 0; +} + +.help-keys kbd { + font-family: var(--font-mono); + font-size: var(--fs-11); + background: var(--surface-2); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: var(--r-1); + padding: 1px 6px; + color: var(--ink); +} + +.help-desc { + margin: 0; + font-size: var(--fs-13); + color: var(--muted); +} + /* Line 2 — dot + duration in the state's colour, then muted fragments. The state word itself is never spelled out here; the colour carries it. */ .card-line2 { @@ -2146,7 +2253,7 @@ input.leaf-rename:focus { .mv-canvas > .mv-split, .mv-canvas > .mv-pane { position: absolute; - inset: var(--sp-2); + inset: 0; } .mv-canvas > .content-welcome { position: absolute; @@ -2234,20 +2341,10 @@ input.leaf-rename:focus { min-width: 0; min-height: 0; } -/* per-session colour strip — maps a pane to its sidebar row */ -.mv-pane::before { - content: ""; - position: absolute; - inset: 0 0 auto 0; - height: 2px; - background: var(--mv-color, transparent); - z-index: 3; - pointer-events: none; -} -/* focused pane: accent ring + glow, full strength */ +/* focused pane: accent ring, full strength */ .mv-pane.is-focused { border-color: var(--accent); - box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring); + box-shadow: inset 0 0 0 1px var(--accent); } /* unfocused panes recede so the eye finds the live one */ .mv-pane:not(.is-focused) { @@ -2350,31 +2447,12 @@ input.leaf-rename:focus { border-radius: var(--r-1); } -/* sidebar two-tier highlight + colour dot (issue #33). +/* sidebar two-tier highlight (issue #33). .is-active = focused pane (loud, from existing rule); .is-open = open - elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */ + elsewhere (quiet). */ .tree-leaf.is-open { background: color-mix(in oklab, var(--accent) 6%, transparent); } -.tree-leaf.is-open::after, -.tree-leaf.is-active::after { - content: ""; - position: absolute; - right: 9px; - top: 50%; - width: 7px; - height: 7px; - margin-top: -3.5px; - border-radius: 50%; - background: var(--mv-color, var(--accent)); - pointer-events: none; -} -.tree-leaf.is-open::after { - opacity: 0.7; -} -.tree-leaf.is-active::after { - box-shadow: 0 0 8px var(--mv-color, var(--accent-ring)); -} /* ========================================================================= multi-view mobile (#35): below 860px the split tree projects to a pill strip @@ -2386,7 +2464,6 @@ input.leaf-rename:focus { .mv-mobile { position: absolute; - /* Flush to the edges — a single full-screen pane needs no canvas inset. */ inset: 0; display: flex; flex-direction: column; @@ -2420,14 +2497,6 @@ input.leaf-rename:focus { cursor: pointer; border: 1px solid transparent; } -.mv-pill::before { - content: ""; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--mv-color, var(--faint)); - flex: none; -} .mv-pill.is-active { background: var(--accent-tint); color: var(--accent-strong); diff --git a/web/src/worktreeStep.test.ts b/web/src/worktreeStep.test.ts index 6fb1431..f84047d 100644 --- a/web/src/worktreeStep.test.ts +++ b/web/src/worktreeStep.test.ts @@ -240,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => { expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); expect(toggle(form).checked).toBe(false); }); + + it("lands a 'spawn similar' seed on the cloned project with '+ new' active", async () => { + urls = stubFetch([MAESTRO, WT_A, WT_B]); + handle = createSpawnForm({ + onSuccess() {}, + seed: { cwd: MAESTRO.path, cwdRawVisible: false, worktreeEnabled: true }, + }); + document.body.appendChild(handle.el); + await drain(); + const form = handle.el; + + expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); + expect(activeChoice(form)!.dataset.kind).toBe("new"); + expect(toggle(form).checked).toBe(true); + expect(form.querySelector(".cwd-raw-wrap")!.hidden).toBe(true); + }); + + it("falls back to the raw input when the seeded directory matches no project", async () => { + urls = stubFetch([MAESTRO, WT_A]); + handle = createSpawnForm({ + onSuccess() {}, + seed: { cwd: "/home/user/gone", cwdRawVisible: false, worktreeEnabled: false }, + }); + document.body.appendChild(handle.el); + await drain(); + const form = handle.el; + + const raw = form.querySelector(".cwd-raw")!; + expect(form.querySelector(".cwd-raw-wrap")!.hidden).toBe(false); + expect(raw.value).toBe("/home/user/gone"); + }); });