feat(maestro): redesign web UI — token system, dark/light, a11y status
ci / rust (pull_request) Failing after 3m32s
ci / rust (pull_request) Failing after 3m32s
Rewrite styles.css as an OKLCH design-system: cool-slate neutrals with a single committed signal-green for "alive" (running status, primary action, focus, selection). Monospace becomes the UI typeface; dark is default with a full light theme via tokens, OS-following with a persisted header toggle. - catalog: pulsing wordmark, status-bar tally (N sessions / M live) + daemon chips, color-independent session status (pulsing dot for running, hollow ring + "exited" badge for dead), reveal-on-hover kill, three teaching empty states. - attach: state pill (live/closed/error), framed dark xterm w/ green cursor. - spawn: bottom-sheet/dialog form, mono inputs with green focus rings. - a11y: AA contrast both themes, visible focus, prefers-reduced-motion paths, status never color-only. - theme.ts: dark/light toggle + persistence; mock.ts: DEV-only review fixture (?mock / ?mock&spawn / ?theme=), tree-shaken from production builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-2
@@ -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/<session-id>` → terminal
|
||||
|
||||
+7
-2
@@ -6,9 +6,14 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#1a1d24" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#fbfbfc" media="(prefers-color-scheme: light)" />
|
||||
<title>maestro</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231a1d24'/%3E%3Ccircle cx='16' cy='16' r='6' fill='%235fe3a1'/%3E%3Ccircle cx='16' cy='16' r='10' fill='none' stroke='%235fe3a1' stroke-opacity='0.4' stroke-width='1.5'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+8
-4
@@ -34,7 +34,7 @@ export async function renderAttach(
|
||||
shell.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog">←</a>
|
||||
<span class="attach-title" title="${sessionId}">${sessionId.slice(0, 8)}</span>
|
||||
<span class="attach-title" title="${sessionId}">session <span class="sid">${sessionId.slice(0, 8)}</span></span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
</header>
|
||||
<div class="attach-body"></div>
|
||||
@@ -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,
|
||||
|
||||
+92
-29
@@ -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 = `
|
||||
<span class="status status-${status}" aria-hidden="true"></span>
|
||||
<span class="leaf-status" aria-hidden="true"></span>
|
||||
<span class="leaf-main">
|
||||
<span class="leaf-name">${escapeHtml(sessionName(s))}</span>
|
||||
<span class="leaf-meta">${escapeHtml(s.daemon_id)} · ${escapeHtml(s.command)}</span>
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
<span class="leaf-meta">
|
||||
<span class="leaf-daemon">${escapeHtml(s.daemon_id)}</span>
|
||||
<span class="leaf-cmd">${escapeHtml(s.command)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<button class="leaf-kill" type="button" title="Kill session" aria-label="Kill session">✕</button>
|
||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
||||
<button class="leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
`;
|
||||
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
|
||||
? `<span>No sessions</span>`
|
||||
: `<span><b>${total}</b> session${total === 1 ? "" : "s"}</span>` +
|
||||
`<span class="tally-live"><b>${live}</b> live</span>`;
|
||||
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 = `<span class="daemon-empty">No daemons connected</span>`;
|
||||
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 = `<span class="daemon-empty">no daemons connected</span>`;
|
||||
} 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 = `
|
||||
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
||||
<div class="empty-title">No matches</div>
|
||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
||||
return el;
|
||||
}
|
||||
if (noDaemons) {
|
||||
el.classList.add("connecting");
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">◴</div>
|
||||
<div class="empty-title">Waiting for a daemon</div>
|
||||
<p class="empty-hint">No daemons are connected yet. Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
|
||||
return el;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
<p class="empty-hint">Press <kbd>+ Spawn</kbd> to start your first session.</p>`;
|
||||
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 = `
|
||||
<h1>maestro</h1>
|
||||
<div class="brand">
|
||||
<span class="brand-dot" aria-hidden="true"></span>
|
||||
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
|
||||
</div>
|
||||
<div class="catalog-actions">
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="button" class="btn btn-primary spawn-btn">+ Spawn</button>
|
||||
</div>
|
||||
`;
|
||||
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") {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -32,8 +32,8 @@ export function openSpawnModal(daemons: Map<string, DaemonInfo>): void {
|
||||
<input name="name" type="text" placeholder="e.g. impl" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Label (optional, e.g. <code>personal/maestro</code>)
|
||||
<input name="label" type="text" placeholder="group/name" autocomplete="off" />
|
||||
Label (optional)
|
||||
<input name="label" type="text" placeholder="personal/maestro" autocomplete="off" />
|
||||
</label>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
|
||||
+844
-154
File diff suppressed because it is too large
Load Diff
@@ -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 <html> 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 = `<span class="moon" aria-hidden="true">☾</span><span class="sun" aria-hidden="true">☀</span>`;
|
||||
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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user