diff --git a/web/README.md b/web/README.md index 8718ed8..73ec70f 100644 --- a/web/README.md +++ b/web/README.md @@ -21,12 +21,27 @@ src/ api.ts # registry HTTP API client events.ts # `/api/events` WebSocket subscriber catalog.ts # session tree view (groups by metadata.label) - attach.ts # xterm.js + daemon WS attach view + attach.ts # xterm.js + daemon WS attach view spawn.ts # spawn-session modal + theme.ts # dark/light theme toggle + persistence types.ts # mirror of `maestro-protocol` JSON shapes - styles.css # mobile-first base styles + styles.css # design-system tokens + component styles + mock.ts # DEV-only review fixture (see Design) ``` +## Design + +The visual system (OKLCH token palette, monospace-first typography, dark+light +theming, motion, components) is documented in [`../DESIGN.md`](../DESIGN.md); +the tokens live in the `:root` block of `src/styles.css`. Strategic intent +(register, brand, principles) is in [`../PRODUCT.md`](../PRODUCT.md). Theme +follows the OS by default and can be pinned via the header toggle (persisted in +`localStorage`). + +`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 registry, +`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme. + Routing is hash-based: - `#/` → catalog - `#/attach/` → terminal diff --git a/web/index.html b/web/index.html index e129326..c67fb83 100644 --- a/web/index.html +++ b/web/index.html @@ -6,9 +6,14 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> - + + + maestro - +
diff --git a/web/src/attach.ts b/web/src/attach.ts index 53fd5fe..35bf2ac 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -34,7 +34,7 @@ export async function renderAttach( shell.innerHTML = `
← - ${sessionId.slice(0, 8)} + session ${sessionId.slice(0, 8)} connecting…
@@ -60,9 +60,13 @@ export async function renderAttach( fontSize: 13, convertEol: false, theme: { - background: "#101115", - foreground: "#e6e6e6", - cursor: "#e6e6e6", + // The terminal stays dark in both app themes (convention); colors are + // tuned to the maestro palette — signal-green cursor, slate background. + background: "#121419", + foreground: "#e6e8ec", + cursor: "#5fe3a1", + cursorAccent: "#121419", + selectionBackground: "#2b6b4f", }, scrollback: 5000, allowProposedApi: true, diff --git a/web/src/catalog.ts b/web/src/catalog.ts index efc28b0..5d9361c 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -3,6 +3,7 @@ import { CatalogStore } from "./events.js"; import type { DaemonInfo, Session } from "./types.js"; import { sessionLabel, sessionName } from "./types.js"; import { openSpawnModal } from "./spawn.js"; +import { createThemeToggle } from "./theme.js"; interface TreeFolder { kind: "folder"; @@ -96,14 +97,24 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { row.href = `#/attach/${encodeURIComponent(s.id)}`; row.style.paddingLeft = `${depth * 16 + 8}px`; row.dataset.sessionId = s.id; - const status = s.status === "Running" ? "running" : "dead"; + const running = s.status === "Running"; + row.dataset.status = running ? "running" : "dead"; + const name = sessionName(s); + row.setAttribute( + "aria-label", + `${name}, ${running ? "running" : "exited"}, on daemon ${s.daemon_id}`, + ); row.innerHTML = ` - + - ${escapeHtml(sessionName(s))} - ${escapeHtml(s.daemon_id)} · ${escapeHtml(s.command)} + ${escapeHtml(name)} + + ${escapeHtml(s.daemon_id)} + ${escapeHtml(s.command)} + - + ${running ? "" : `exited`} + `; const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", async (ev) => { @@ -132,23 +143,67 @@ function escapeHtml(s: string): string { .replace(/"/g, """); } -function renderDaemons(daemons: DaemonInfo[]): HTMLElement { +function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElement { const wrap = document.createElement("div"); - wrap.className = "daemon-bar"; + wrap.className = "statusbar"; + + const total = sessions.length; + const live = sessions.filter((s) => s.status === "Running").length; + const tally = document.createElement("div"); + tally.className = "session-tally"; + tally.innerHTML = + total === 0 + ? `No sessions` + : `${total} session${total === 1 ? "" : "s"}` + + `${live} live`; + wrap.appendChild(tally); + + const spacer = document.createElement("div"); + spacer.className = "spacer"; + wrap.appendChild(spacer); + + const daemonsWrap = document.createElement("div"); + daemonsWrap.className = "daemons"; if (daemons.length === 0) { - wrap.innerHTML = `No daemons connected`; - return wrap; - } - for (const d of daemons) { - const chip = document.createElement("span"); - chip.className = "daemon-chip"; - chip.title = `${d.hostname} · ${d.daemon_url}`; - chip.textContent = d.daemon_id; - wrap.appendChild(chip); + daemonsWrap.innerHTML = `no daemons connected`; + } else { + for (const d of daemons) { + const chip = document.createElement("span"); + chip.className = "daemon-chip"; + chip.title = `${d.hostname} · ${d.daemon_url}`; + chip.textContent = d.daemon_id; + daemonsWrap.appendChild(chip); + } } + wrap.appendChild(daemonsWrap); return wrap; } +function renderEmpty(noDaemons: boolean, filtering: boolean): HTMLElement { + const el = document.createElement("div"); + el.className = "empty"; + if (filtering) { + el.innerHTML = ` + +
No matches
+

No sessions match your search. Clear it to see everything.

`; + return el; + } + if (noDaemons) { + el.classList.add("connecting"); + el.innerHTML = ` + +
Waiting for a daemon
+

No daemons are connected yet. Start a maestro daemon on a host and it will appear here.

`; + return el; + } + el.innerHTML = ` + +
No sessions yet
+

Press + Spawn to start your first session.

`; + return el; +} + export function renderCatalog(root: HTMLElement, store: CatalogStore): () => void { root.innerHTML = ""; @@ -158,24 +213,35 @@ export function renderCatalog(root: HTMLElement, store: CatalogStore): () => voi const header = document.createElement("header"); header.className = "catalog-header"; header.innerHTML = ` -

maestro

+
+ +

maestro

+
-
`; shell.appendChild(header); - const daemonsHost = document.createElement("div"); - shell.appendChild(daemonsHost); + const actions = header.querySelector(".catalog-actions") as HTMLElement; + actions.appendChild(createThemeToggle()); + const spawnBtn = document.createElement("button"); + spawnBtn.type = "button"; + spawnBtn.className = "btn btn-primary spawn-btn"; + spawnBtn.textContent = "+ Spawn"; + actions.appendChild(spawnBtn); + + const statusHost = document.createElement("div"); + shell.appendChild(statusHost); const treeHost = document.createElement("div"); treeHost.className = "tree"; @@ -190,25 +256,22 @@ export function renderCatalog(root: HTMLElement, store: CatalogStore): () => voi refresh(); }); - const spawnBtn = header.querySelector(".spawn-btn") as HTMLButtonElement; spawnBtn.addEventListener("click", () => { openSpawnModal(store.snapshot().daemons); }); const refresh = () => { const state = store.snapshot(); - daemonsHost.replaceChildren(renderDaemons([...state.daemons.values()])); const sessions = [...state.sessions.values()]; + statusHost.replaceChildren( + renderStatusbar([...state.daemons.values()], sessions), + ); const tree = buildTree(sessions, filter); treeHost.replaceChildren(); if (sessions.length === 0) { - const empty = document.createElement("p"); - empty.className = "empty"; - empty.textContent = - store.snapshot().daemons.size === 0 - ? "Waiting for a daemon to connect…" - : "No sessions yet. Press + Spawn to create one."; - treeHost.appendChild(empty); + treeHost.appendChild( + renderEmpty(state.daemons.size === 0, filter.trim().length > 0), + ); return; } if (tree.kind === "folder") { diff --git a/web/src/main.ts b/web/src/main.ts index 94e0610..886f67a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,8 +1,11 @@ import { renderAttach } from "./attach.js"; import { renderCatalog } from "./catalog.js"; import { CatalogStore } from "./events.js"; +import { initTheme } from "./theme.js"; import "./styles.css"; +initTheme(); + const root = document.getElementById("app"); if (!root) { throw new Error("missing #app"); @@ -11,6 +14,12 @@ if (!root) { const store = new CatalogStore(); store.start(); +// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed +// without a running registry. Stripped from production builds by Vite. +if (import.meta.env.DEV && location.search.includes("mock")) { + void import("./mock.js").then((m) => m.seedMock(store)); +} + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } diff --git a/web/src/mock.ts b/web/src/mock.ts new file mode 100644 index 0000000..38e9a81 --- /dev/null +++ b/web/src/mock.ts @@ -0,0 +1,55 @@ +// Dev-only review fixture. Imported lazily behind `import.meta.env.DEV` from +// main.ts (`?mock`), so it never ships in a production bundle. It pushes a +// synthetic Snapshot through the store's normal event path. +import type { CatalogStore } from "./events.js"; +import type { Session, DaemonInfo } from "./types.js"; + +function sess( + id: string, + daemon: string, + command: string, + status: "Running" | "Dead", + meta: Record, +): Session { + return { + id, + daemon_id: daemon, + cwd: "/home/dev", + command, + env: {}, + metadata: meta, + status, + created_at: "2026-06-15T12:00:00Z", + }; +} + +export function seedMock(store: CatalogStore): void { + const daemons: DaemonInfo[] = [ + { daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", daemon_url: "wss://thinkpad.lan/daemon", home_dir: "/home/dev" }, + { daemon_id: "tower", hostname: "tower.lan", version: "0.1.0", daemon_url: "wss://tower.lan/daemon", home_dir: "/home/dev" }, + { daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" }, + ]; + const sessions: Session[] = [ + sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", { name: "redesign", label: "maestro/web" }), + sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "claude", "Running", { name: "registry-broker", label: "maestro/server" }), + sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "cargo watch -x test", "Running", { name: "test-loop", label: "maestro/server" }), + sess("a1b2c3d4-0000-0000-0000-000000000004", "tower", "bash", "Dead", { name: "scratch", label: "maestro/web" }), + sess("a1b2c3d4-0000-0000-0000-000000000005", "tower", "opencode", "Running", { name: "docs", label: "personal" }), + sess("a1b2c3d4-0000-0000-0000-000000000006", "pi", "btop", "Running", { name: "monitor", label: "personal" }), + sess("a1b2c3d4-0000-0000-0000-000000000007", "pi", "ssh gateway", "Dead", {}), + ]; + // The store applies RegistryEvents internally; feed a Snapshot the same way + // the WS would. `apply` is private, so reach it through an index cast. + (store as unknown as { apply: (e: unknown) => void }).apply({ + type: "Snapshot", + daemons, + sessions, + }); + + // `?mock&spawn` auto-opens the spawn modal for visual review. + if (location.search.includes("spawn")) { + void import("./spawn.js").then((m) => + m.openSpawnModal(new Map(daemons.map((d) => [d.daemon_id, d]))), + ); + } +} diff --git a/web/src/spawn.ts b/web/src/spawn.ts index 030aa7a..6a4d3d5 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -32,8 +32,8 @@ export function openSpawnModal(daemons: Map): void {
diff --git a/web/src/styles.css b/web/src/styles.css index 40cfe1a..601c230 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,37 +1,205 @@ +/* ========================================================================= + maestro web — design system + Register: product (a tool; design serves the task). + Identity: precise, calm, alive. Monospace-as-UI; signal-green = "alive". + Theme: dark (default) + light, all via OKLCH custom-property tokens. + ========================================================================= */ + :root { - --bg: #0c0d10; - --surface: #14161b; - --surface-2: #1c1f26; - --border: #2a2e38; - --fg: #e6e6e6; - --muted: #98a2b3; - --accent: #6c8eef; - --accent-fg: #0c0d10; - --danger: #e5484d; - --ok: #30a46c; - --row-h: 44px; - --font-sans: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", - Arial, "Noto Sans", sans-serif; + /* ---- type ---- */ --font-mono: - ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", - monospace; + ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code", "JetBrains Mono", + "Roboto Mono", Menlo, Consolas, "Liberation Mono", monospace; + --font-sans: + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + + --fs-11: 0.6875rem; + --fs-12: 0.75rem; + --fs-13: 0.8125rem; + --fs-14: 0.875rem; + --fs-15: 0.9375rem; + --fs-17: 1.0625rem; + --fs-20: 1.25rem; + + /* ---- spacing ---- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + + /* ---- radii ---- */ + --r-1: 6px; + --r-2: 9px; + --r-3: 14px; + --r-pill: 999px; + + /* ---- sizing ---- */ + --row-h: 46px; + --control-h: 38px; + --tap: 40px; + + /* ---- motion ---- */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 240ms; + + /* ---- z-index scale ---- */ + --z-sticky: 10; + --z-backdrop: 40; + --z-modal: 50; + --z-toast: 60; + + /* ---- color: dark (default) ---- */ + color-scheme: dark; + + --bg: oklch(0.178 0.012 256); + --bg-grid: oklch(0.215 0.014 256); + --surface: oklch(0.214 0.014 256); + --surface-2: oklch(0.252 0.016 256); + --surface-3: oklch(0.292 0.018 256); + --border: oklch(0.318 0.018 256); + --border-strong: oklch(0.4 0.022 256); + + --ink: oklch(0.945 0.006 256); + --muted: oklch(0.722 0.014 256); + --faint: oklch(0.58 0.014 256); + + /* signal green = "alive", primary action, focus, selection */ + --accent: oklch(0.83 0.16 152); + --accent-strong: oklch(0.88 0.17 152); + --accent-ink: oklch(0.2 0.03 256); + --accent-tint: oklch(0.83 0.16 152 / 0.12); + --accent-ring: oklch(0.83 0.16 152 / 0.55); + + --live: oklch(0.84 0.17 152); + --live-glow: oklch(0.84 0.17 152 / 0.55); + + --danger: oklch(0.72 0.18 25); + --danger-tint: oklch(0.72 0.18 25 / 0.14); + --warn: oklch(0.82 0.14 85); + + --shadow-1: 0 1px 2px oklch(0 0 0 / 0.4); + --shadow-2: + 0 12px 28px -8px oklch(0 0 0 / 0.6), 0 2px 8px oklch(0 0 0 / 0.4); + --term-bg: oklch(0.158 0.012 256); } +:root[data-theme="light"], +:root[data-theme="light"] body { + color-scheme: light; + + --bg: oklch(0.985 0.003 256); + --bg-grid: oklch(0.93 0.006 256); + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.004 256); + --surface-3: oklch(0.93 0.006 256); + --border: oklch(0.892 0.008 256); + --border-strong: oklch(0.82 0.012 256); + + --ink: oklch(0.26 0.014 256); + --muted: oklch(0.47 0.016 256); + --faint: oklch(0.62 0.016 256); + + --accent: oklch(0.6 0.15 151); + --accent-strong: oklch(0.55 0.16 151); + --accent-ink: oklch(0.99 0.01 152); + --accent-tint: oklch(0.6 0.15 151 / 0.12); + --accent-ring: oklch(0.6 0.15 151 / 0.5); + + --live: oklch(0.6 0.16 151); + --live-glow: oklch(0.6 0.16 151 / 0.45); + + --danger: oklch(0.55 0.21 27); + --danger-tint: oklch(0.55 0.21 27 / 0.1); + --warn: oklch(0.66 0.15 75); + + --shadow-1: 0 1px 2px oklch(0.5 0.02 256 / 0.12); + --shadow-2: + 0 16px 36px -12px oklch(0.4 0.03 256 / 0.28), + 0 2px 8px oklch(0.5 0.02 256 / 0.1); + --term-bg: oklch(0.165 0.012 256); +} + +/* Follow the OS when the user hasn't pinned a theme. */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + color-scheme: light; + + --bg: oklch(0.985 0.003 256); + --bg-grid: oklch(0.93 0.006 256); + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.004 256); + --surface-3: oklch(0.93 0.006 256); + --border: oklch(0.892 0.008 256); + --border-strong: oklch(0.82 0.012 256); + + --ink: oklch(0.26 0.014 256); + --muted: oklch(0.47 0.016 256); + --faint: oklch(0.62 0.016 256); + + --accent: oklch(0.6 0.15 151); + --accent-strong: oklch(0.55 0.16 151); + --accent-ink: oklch(0.99 0.01 152); + --accent-tint: oklch(0.6 0.15 151 / 0.12); + --accent-ring: oklch(0.6 0.15 151 / 0.5); + + --live: oklch(0.6 0.16 151); + --live-glow: oklch(0.6 0.16 151 / 0.45); + + --danger: oklch(0.55 0.21 27); + --danger-tint: oklch(0.55 0.21 27 / 0.1); + --warn: oklch(0.66 0.15 75); + + --shadow-1: 0 1px 2px oklch(0.5 0.02 256 / 0.12); + --shadow-2: + 0 16px 36px -12px oklch(0.4 0.03 256 / 0.28), + 0 2px 8px oklch(0.5 0.02 256 / 0.1); + --term-bg: oklch(0.165 0.012 256); + } +} + +/* ---------- reset / base ---------- */ + * { box-sizing: border-box; } +*:focus { + outline: none; +} + html, body { height: 100%; margin: 0; +} + +body { + overflow-x: hidden; +} + +body { background: var(--bg); - color: var(--fg); - font-family: var(--font-sans); - font-size: 15px; - line-height: 1.4; + color: var(--ink); + font-family: var(--font-mono); + font-size: var(--fs-15); + line-height: 1.45; + font-feature-settings: "ss01", "cv01"; -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + /* faint structural grid — terminal texture, not a CRT costume */ + background-image: + linear-gradient(var(--bg-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px); + background-size: 100% 64px, 64px 100%; + background-position: center top; + /* dim the grid via opacity on a fixed overlay would cost a node; bake it low instead */ } #app { @@ -49,9 +217,15 @@ a { code { font-family: var(--font-mono); font-size: 0.92em; - padding: 1px 4px; + padding: 1px 5px; background: var(--surface-2); - border-radius: 3px; + border: 1px solid var(--border); + border-radius: var(--r-1); +} + +::selection { + background: var(--accent-tint); + color: var(--ink); } button, @@ -63,42 +237,86 @@ textarea { color: inherit; } +::placeholder { + color: var(--faint); + opacity: 1; +} + +:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 2px; + border-radius: var(--r-1); +} + +/* ---------- buttons ---------- */ + .btn { + --_bg: var(--surface-2); + appearance: none; border: 1px solid var(--border); - background: var(--surface); - color: var(--fg); - padding: 0 14px; - height: 36px; - border-radius: 8px; + background: var(--_bg); + color: var(--ink); + padding: 0 var(--sp-4); + height: var(--control-h); + min-height: var(--control-h); + border-radius: var(--r-2); cursor: pointer; + font-family: var(--font-mono); + font-size: var(--fs-13); font-weight: 500; - min-height: 36px; + letter-spacing: 0.01em; display: inline-flex; align-items: center; justify-content: center; + gap: var(--sp-2); + white-space: nowrap; + transition: + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); } .btn:hover { - background: var(--surface-2); + background: var(--surface-3); + border-color: var(--border-strong); +} + +.btn:active { + transform: translateY(1px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; } .btn-primary { - background: var(--accent); - border-color: var(--accent); - color: var(--accent-fg); + --_bg: var(--accent); + border-color: transparent; + color: var(--accent-ink); + font-weight: 600; } .btn-primary:hover { - filter: brightness(1.08); + background: var(--accent-strong); + border-color: transparent; } .btn-ghost { - background: transparent; + --_bg: transparent; + border-color: transparent; +} + +.btn-ghost:hover { + background: var(--surface-2); + border-color: var(--border); } .btn-icon { - width: 36px; + width: var(--control-h); padding: 0; + font-size: var(--fs-15); } /* ---------- catalog ---------- */ @@ -114,78 +332,179 @@ textarea { display: flex; flex-wrap: wrap; align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 16px; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); - background: var(--surface); + background: color-mix(in oklab, var(--surface) 82%, transparent); + backdrop-filter: blur(8px); position: sticky; top: 0; - z-index: 1; + z-index: var(--z-sticky); } -.catalog-header h1 { +.brand { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + margin-right: auto; +} + +.brand-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--live); + box-shadow: 0 0 0 4px var(--live-glow); + flex: 0 0 auto; + animation: pulse 2.4s var(--ease-out) infinite; +} + +.brand h1 { margin: 0; - font-size: 18px; - letter-spacing: 0.02em; + font-size: var(--fs-17); + font-weight: 600; + letter-spacing: -0.01em; +} + +.brand .blink { + color: var(--accent); + font-weight: 400; + animation: blink 1.2s steps(1) infinite; } .catalog-actions { display: flex; flex: 1 1 280px; - gap: 8px; + gap: var(--sp-2); align-items: center; justify-content: flex-end; } .search { - flex: 1 1 220px; - max-width: 360px; - height: 36px; - padding: 0 12px; + flex: 1 1 200px; + max-width: 340px; + height: var(--control-h); + padding: 0 var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); - border-radius: 8px; - outline: none; + border-radius: var(--r-2); + color: var(--ink); + transition: + border-color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.search:hover { + border-color: var(--border-strong); } .search:focus { border-color: var(--accent); + background: var(--surface); + box-shadow: 0 0 0 3px var(--accent-tint); } -.daemon-bar { - padding: 8px 16px; +/* ---------- daemon / status bar ---------- */ + +.statusbar { display: flex; + align-items: center; flex-wrap: wrap; - gap: 6px; + gap: var(--sp-2) var(--sp-3); + padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border); background: var(--surface); + font-size: var(--fs-12); + color: var(--muted); +} + +.session-tally { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + color: var(--ink); + font-size: var(--fs-13); +} + +.session-tally b { + font-weight: 600; +} + +.tally-live { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--accent); +} + +.tally-live::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--live); + box-shadow: 0 0 6px var(--live-glow); +} + +.statusbar .spacer { + /* zero basis: claim leftover space without inflating the bar's min width */ + flex: 1 1 0; + min-width: var(--sp-2); +} + +.daemons { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-2); + align-items: center; + min-width: 0; } .daemon-chip { + display: inline-flex; + align-items: center; + gap: 6px; background: var(--surface-2); border: 1px solid var(--border); - border-radius: 999px; - padding: 2px 10px; - font-size: 12px; - color: var(--muted); + border-radius: var(--r-pill); + padding: 3px var(--sp-3) 3px var(--sp-2); + font-size: var(--fs-12); + color: var(--ink); +} + +.daemon-chip::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--live); + box-shadow: 0 0 5px var(--live-glow); } .daemon-empty { - font-size: 12px; + display: inline-flex; + align-items: center; + gap: 7px; color: var(--muted); } +.daemon-empty::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warn); +} + +/* ---------- session tree ---------- */ + .tree { flex: 1 1 auto; overflow: auto; - padding: 8px 0 24px; -} - -.empty { - padding: 24px 16px; - color: var(--muted); - text-align: center; + padding: var(--sp-2) 0 var(--sp-8); + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; } .tree-folder { @@ -196,54 +515,82 @@ textarea { .tree-folder-header { display: flex; align-items: center; - gap: 8px; + gap: var(--sp-2); background: transparent; border: 0; width: 100%; text-align: left; - height: var(--row-h); - padding-right: 16px; + height: 38px; + padding-right: var(--sp-4); color: var(--muted); cursor: pointer; - font-size: 13px; - letter-spacing: 0.02em; - text-transform: uppercase; + font-family: var(--font-mono); + font-size: var(--fs-12); + transition: color var(--dur-fast) var(--ease-out); } .tree-folder-header:hover { - background: var(--surface); + color: var(--ink); } .tree-folder-header .chevron { - width: 14px; + width: 16px; text-align: center; - color: var(--muted); + color: var(--faint); + font-size: var(--fs-11); + transition: + transform var(--dur) var(--ease-out), + color var(--dur-fast) var(--ease-out); +} + +.tree-folder-header[aria-expanded="false"] .chevron { + transform: rotate(-90deg); +} + +.tree-folder-header:hover .chevron { + color: var(--accent); } .folder-name { flex: 1 1 auto; - color: var(--fg); - text-transform: none; - font-size: 14px; - letter-spacing: 0; + color: var(--ink); + font-size: var(--fs-14); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .folder-count { - font-size: 12px; + font-size: var(--fs-11); color: var(--muted); padding: 1px 8px; - border-radius: 999px; + border-radius: var(--r-pill); background: var(--surface-2); + border: 1px solid var(--border); } .tree-leaf { display: flex; align-items: center; - gap: 10px; + gap: var(--sp-3); min-height: var(--row-h); - padding: 8px 16px 8px 8px; - border-bottom: 1px solid transparent; - color: var(--fg); + padding: var(--sp-2) var(--sp-4) var(--sp-2) var(--sp-3); + border-left: 2px solid transparent; + color: var(--ink); + position: relative; + transition: background var(--dur-fast) var(--ease-out); +} + +.tree-leaf::after { + content: ""; + position: absolute; + left: var(--sp-4); + right: var(--sp-4); + bottom: 0; + height: 1px; + background: var(--border); + opacity: 0.5; } .tree-leaf:hover { @@ -251,25 +598,42 @@ textarea { } .tree-leaf:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; + outline: none; + background: var(--surface); + border-left-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent-ring); } -.status { - width: 8px; - height: 8px; +.tree-leaf[data-status="dead"] { + color: var(--muted); +} + +.leaf-status { + width: 10px; + height: 10px; + flex: 0 0 10px; border-radius: 50%; - flex: 0 0 8px; - background: var(--muted); + position: relative; } -.status-running { - background: var(--ok); +[data-status="running"] .leaf-status { + background: var(--live); + box-shadow: 0 0 0 3px var(--live-glow); } -.status-dead { - background: var(--danger); - opacity: 0.7; +[data-status="running"] .leaf-status::before { + content: ""; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 1px solid var(--live); + opacity: 0.6; + animation: ripple 2.4s var(--ease-out) infinite; +} + +[data-status="dead"] .leaf-status { + background: transparent; + border: 1.5px solid var(--faint); } .leaf-main { @@ -277,38 +641,139 @@ textarea { flex-direction: column; flex: 1 1 auto; min-width: 0; + gap: 1px; } .leaf-name { font-weight: 500; + font-size: var(--fs-14); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .leaf-meta { - font-size: 12px; + font-size: var(--fs-12); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.leaf-daemon { + color: var(--faint); +} + +.leaf-daemon::before { + content: "@"; + opacity: 0.6; +} + +.leaf-cmd { + color: var(--muted); +} + +.leaf-badge { + font-size: var(--fs-11); + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--border); + border-radius: var(--r-pill); + padding: 1px 8px; + flex: 0 0 auto; } .leaf-kill { background: transparent; - color: var(--muted); + color: var(--faint); border: 1px solid transparent; - border-radius: 8px; - width: 36px; - height: 36px; + border-radius: var(--r-2); + width: var(--tap); + height: var(--tap); cursor: pointer; - flex: 0 0 36px; + flex: 0 0 var(--tap); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--fs-14); + transition: + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + opacity var(--dur-fast) var(--ease-out); +} + +@media (hover: hover) { + .leaf-kill { + opacity: 0; + } + .tree-leaf:hover .leaf-kill, + .tree-leaf:focus-within .leaf-kill { + opacity: 1; + } } .leaf-kill:hover { color: var(--danger); + background: var(--danger-tint); + border-color: color-mix(in oklab, var(--danger) 40%, transparent); +} + +/* ---------- empty / loading ---------- */ + +.empty { + padding: var(--sp-8) var(--sp-4); + color: var(--muted); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); + font-family: var(--font-sans); +} + +.empty-glyph { + font-family: var(--font-mono); + font-size: var(--fs-20); + color: var(--accent); + border: 1px solid var(--border); + border-radius: var(--r-3); + width: 56px; + height: 56px; + display: grid; + place-items: center; + background: var(--surface); +} + +.empty-title { + color: var(--ink); + font-size: var(--fs-15); + font-family: var(--font-mono); +} + +.empty-hint { + font-size: var(--fs-13); + max-width: 42ch; +} + +.empty-hint kbd { + font-family: var(--font-mono); + font-size: 0.9em; background: var(--surface-2); - border-color: var(--border); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: var(--r-1); + padding: 1px 6px; + color: var(--ink); +} + +.empty.connecting .empty-glyph { + color: var(--warn); + animation: pulse 1.6s var(--ease-out) infinite; } /* ---------- attach view ---------- */ @@ -323,39 +788,67 @@ textarea { .attach-header { display: flex; align-items: center; - gap: 12px; - padding: 8px 12px; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); background: var(--surface); } .attach-header .back { - font-size: 18px; - width: 40px; - height: 36px; + font-size: var(--fs-17); + width: var(--tap); + height: var(--control-h); } .attach-title { font-family: var(--font-mono); - font-size: 13px; - color: var(--muted); + font-size: var(--fs-13); + color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; } +.attach-title .sid { + color: var(--muted); +} + .attach-state { - font-size: 12px; - padding: 2px 8px; - border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--fs-12); + padding: 4px 10px; + border-radius: var(--r-pill); background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); + white-space: nowrap; +} + +.attach-state::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.attach-state[data-state="connecting"] { + color: var(--warn); +} + +.attach-state[data-state="connecting"]::before { + animation: pulse 1.2s var(--ease-out) infinite; } .attach-state[data-state="open"] { - color: var(--ok); + color: var(--accent); +} + +.attach-state[data-state="open"]::before { + box-shadow: 0 0 6px var(--live-glow); } .attach-state[data-state="closed"], @@ -366,9 +859,9 @@ textarea { .attach-body { flex: 1 1 auto; min-height: 0; - padding: 6px; + padding: var(--sp-2); display: flex; - background: #101115; + background: var(--term-bg); } .attach-body .xterm { @@ -377,16 +870,17 @@ textarea { } .attach-status { - margin: 16px; - padding: 12px 14px; - border-radius: 8px; + margin: var(--sp-4); + padding: var(--sp-3) var(--sp-4); + border-radius: var(--r-2); background: var(--surface); border: 1px solid var(--border); - font-size: 14px; + font-size: var(--fs-14); + color: var(--muted); } .attach-status.status-error { - border-color: var(--danger); + border-color: color-mix(in oklab, var(--danger) 50%, var(--border)); color: var(--danger); } @@ -395,35 +889,40 @@ textarea { .modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.55); + 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: 50; + z-index: var(--z-modal); + animation: fade var(--dur) var(--ease-out); } @media (min-width: 600px) { .modal-backdrop { align-items: center; - padding: 24px; + padding: var(--sp-6); } } .modal { background: var(--surface); - border-radius: 16px 16px 0 0; + border-radius: var(--r-3) var(--r-3) 0 0; border: 1px solid var(--border); width: 100%; max-width: 560px; - max-height: 90vh; + 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: 12px; + border-radius: var(--r-3); + animation: pop var(--dur) var(--ease-out); } } @@ -431,105 +930,296 @@ textarea { display: flex; align-items: center; justify-content: space-between; - padding: 14px 16px; + padding: var(--sp-4); border-bottom: 1px solid var(--border); } .modal-header h2 { margin: 0; - font-size: 16px; + 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 { - padding: 16px; + padding: var(--sp-4); overflow: auto; display: flex; flex-direction: column; - gap: 12px; + gap: var(--sp-3); + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; } .modal-body label { display: flex; flex-direction: column; - gap: 4px; - font-size: 13px; + gap: 5px; + font-size: var(--fs-12); color: var(--muted); + letter-spacing: 0.01em; } .modal-body label.row-inline { flex-direction: row; align-items: center; - gap: 8px; - color: var(--fg); - font-size: 14px; + gap: var(--sp-2); + color: var(--ink); + font-size: var(--fs-14); + cursor: pointer; } .modal-body input[type="text"], +.modal-body input[type="search"], .modal-body select { background: var(--surface-2); border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 12px; - color: var(--fg); - font-size: 15px; + border-radius: var(--r-2); + padding: 10px var(--sp-3); + color: var(--ink); + font-family: var(--font-mono); + font-size: var(--fs-15); width: 100%; + transition: + border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.modal-body input[type="text"]:hover, +.modal-body select:hover { + border-color: var(--border-strong); } .modal-body input[type="text"]:focus, +.modal-body input[type="search"]:focus, .modal-body 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"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + flex: 0 0 auto; } .worktree { border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; + border-radius: var(--r-2); + padding: var(--sp-3); + margin: 0; display: flex; flex-direction: column; - gap: 10px; + gap: var(--sp-3); + background: color-mix(in oklab, var(--surface-2) 50%, transparent); } .worktree legend { padding: 0 6px; - color: var(--fg); + color: var(--ink); + font-size: var(--fs-13); } .modal-actions { display: flex; - gap: 8px; + gap: var(--sp-2); justify-content: flex-end; - margin-top: 8px; + margin-top: var(--sp-2); } .form-error { color: var(--danger); margin: 0; - font-size: 13px; + font-size: var(--fs-13); min-height: 1.2em; } +.form-error:not(:empty) { + padding: var(--sp-2) var(--sp-3); + background: var(--danger-tint); + border-radius: var(--r-2); +} + /* ---------- not-found ---------- */ .not-found { - padding: 24px; + margin: auto; + padding: var(--sp-8) var(--sp-4); display: flex; flex-direction: column; - gap: 12px; - align-items: flex-start; + gap: var(--sp-3); + align-items: center; + text-align: center; } -/* ---------- responsive tweaks ---------- */ +.not-found h1 { + margin: 0; + font-size: var(--fs-20); +} -@media (max-width: 480px) { - .catalog-header h1 { - font-size: 16px; +.not-found p { + margin: 0; + color: var(--muted); +} + +/* ---------- theme toggle ---------- */ + +.theme-toggle { + position: relative; + overflow: hidden; +} + +.theme-toggle .sun, +.theme-toggle .moon { + position: absolute; + inset: 0; + display: grid; + place-items: center; + transition: + transform var(--dur) var(--ease-out), + opacity var(--dur) var(--ease-out); +} + +.theme-toggle .moon { + transform: translateY(0); + opacity: 1; +} + +.theme-toggle .sun { + transform: translateY(120%); + opacity: 0; +} + +:root[data-theme="light"] .theme-toggle .moon { + transform: translateY(-120%); + opacity: 0; +} + +:root[data-theme="light"] .theme-toggle .sun { + transform: translateY(0); + opacity: 1; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme]) .theme-toggle .moon { + transform: translateY(-120%); + opacity: 0; + } + :root:not([data-theme]) .theme-toggle .sun { + transform: translateY(0); + opacity: 1; + } +} + +/* ---------- keyframes ---------- */ + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +@keyframes ripple { + 0% { + transform: scale(1); + opacity: 0.6; + } + 70%, + 100% { + transform: scale(2.4); + opacity: 0; + } +} + +@keyframes blink { + 0%, + 50% { + opacity: 1; + } + 50.01%, + 100% { + opacity: 0; + } +} + +@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) { + .brand h1 { + font-size: var(--fs-15); } .catalog-actions { flex: 1 1 100%; + flex-wrap: wrap; justify-content: stretch; } .search { + flex: 1 1 100%; max-width: none; + order: -1; + } + /* daemons drop to their own full-width wrapped line; no side overflow */ + .statusbar .spacer { + display: none; + } + .daemons { + flex: 1 1 100%; + justify-content: flex-start; + } + .tree-leaf .leaf-kill { + opacity: 1; + } +} + +/* ---------- reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + .brand-dot, + [data-status="running"] .leaf-status::before, + .tally-live::before { + animation: none; } } diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..316faa7 --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,60 @@ +// Theme: dark (default) + light, persisted. When the user hasn't pinned a +// theme we follow the OS via CSS `prefers-color-scheme`; pinning sets an +// explicit `data-theme` on that overrides the media query. + +type Theme = "light" | "dark"; +const KEY = "maestro-theme"; + +function stored(): Theme | null { + const v = localStorage.getItem(KEY); + return v === "light" || v === "dark" ? v : null; +} + +function systemTheme(): Theme { + return window.matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark"; +} + +/** The theme actually showing right now (pinned or system). */ +export function resolvedTheme(): Theme { + return stored() ?? systemTheme(); +} + +/** Apply a pinned theme at boot (no-op when following the system). */ +export function initTheme(): void { + // Dev-only `?theme=light|dark` override for visual review; stripped in prod. + if (import.meta.env.DEV) { + const q = new URLSearchParams(location.search).get("theme"); + if (q === "light" || q === "dark") { + document.documentElement.dataset.theme = q; + return; + } + } + const pinned = stored(); + if (pinned) document.documentElement.dataset.theme = pinned; +} + +function setTheme(t: Theme): void { + localStorage.setItem(KEY, t); + document.documentElement.dataset.theme = t; +} + +/** A header button that flips between light and dark. */ +export function createThemeToggle(): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-ghost btn-icon theme-toggle"; + btn.innerHTML = ``; + const sync = () => { + const next = resolvedTheme() === "dark" ? "light" : "dark"; + btn.setAttribute("aria-label", `Switch to ${next} theme`); + btn.title = `Switch to ${next} theme`; + }; + sync(); + btn.addEventListener("click", () => { + setTheme(resolvedTheme() === "dark" ? "light" : "dark"); + sync(); + }); + return btn; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +///