Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f91b3f5d | ||
|
|
5c3f9cab9f | ||
|
|
117f371c41 |
@@ -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 <id>`, 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`.
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+11
-12
@@ -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<string>();
|
||||
|
||||
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<Session>).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();
|
||||
},
|
||||
};
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
||||
sel.appendChild(s);
|
||||
|
||||
+17
-3
@@ -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<Session>).detail;
|
||||
showMultiView();
|
||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
|
||||
+2
-2
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
/** 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<Draft>) => 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<Draft>): 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,
|
||||
|
||||
@@ -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 = `
|
||||
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="spawn-title">Spawn session</h2>
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div class="form-slot"></div>
|
||||
</div>
|
||||
`;
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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 <form> root (e.g. `modal-body` in the modal). */
|
||||
/** Extra class(es) for the <form> 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 {
|
||||
</div>
|
||||
`;
|
||||
// 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;
|
||||
|
||||
+11
-117
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user