310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
// Dev-only design kit. Lazily imported behind `import.meta.env.DEV` from
|
|
// main.ts (`?kit`), so it never ships in a production bundle. Renders every
|
|
// component specimen on one scrollable page so the visual system can be
|
|
// critiqued as a whole — buttons (including terminal-native variants for
|
|
// review), inputs, chips, badges, session rows, and empty states.
|
|
import "./kit.css";
|
|
import { createThemeToggle } from "./theme.js";
|
|
|
|
function h(tag: string, className?: string, html?: string): HTMLElement {
|
|
const el = document.createElement(tag);
|
|
if (className) el.className = className;
|
|
if (html != null) el.innerHTML = html;
|
|
return el;
|
|
}
|
|
|
|
/** A titled section with an optional note and a content host. */
|
|
function section(title: string, note?: string): { el: HTMLElement; body: HTMLElement } {
|
|
const el = h("section", "kit-section");
|
|
const head = h("div", "kit-section-head");
|
|
head.appendChild(h("h2", "kit-section-title", title));
|
|
if (note) head.appendChild(h("p", "kit-section-note", note));
|
|
el.appendChild(head);
|
|
const body = h("div", "kit-row");
|
|
el.appendChild(body);
|
|
return { el, body };
|
|
}
|
|
|
|
/** A labelled specimen cell so each variant is named for critique. */
|
|
function specimen(label: string, node: Node): HTMLElement {
|
|
const cell = h("div", "kit-cell");
|
|
const slot = h("div", "kit-slot");
|
|
slot.appendChild(node);
|
|
cell.append(slot, h("span", "kit-label", label));
|
|
return cell;
|
|
}
|
|
|
|
function btn(className: string, label: string, disabled = false): HTMLButtonElement {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = className;
|
|
b.textContent = label;
|
|
b.disabled = disabled;
|
|
return b;
|
|
}
|
|
|
|
/** A collapsible folder header, matching the real sidebar tree markup. */
|
|
function folderHeader(name: string, count: number, depth: number): HTMLElement {
|
|
const b = h("button", "tree-folder-header");
|
|
(b as HTMLButtonElement).type = "button";
|
|
b.style.paddingLeft = `${depth * 16}px`;
|
|
b.setAttribute("aria-expanded", "true");
|
|
b.innerHTML = `<span class="chevron">▾</span><span class="folder-name">${name}</span><span class="folder-count">${count}</span>`;
|
|
return b;
|
|
}
|
|
|
|
function leafRow(opts: {
|
|
name: string;
|
|
cmd: string;
|
|
status: "running" | "dead";
|
|
active?: boolean;
|
|
indent?: number;
|
|
activity?: "active" | "idle" | "awaiting-input" | "error";
|
|
duration?: string;
|
|
}): HTMLElement {
|
|
const row = h("a", "tree-leaf session-card");
|
|
(row as HTMLAnchorElement).href = "#/kit";
|
|
row.dataset.status = opts.status;
|
|
if (opts.active) row.classList.add("is-active");
|
|
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
|
const flags = [
|
|
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
|
|
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
|
|
].join("");
|
|
row.innerHTML = `
|
|
<span class="card-line1">
|
|
<span class="leaf-name">${opts.name}</span>
|
|
<span class="leaf-actions">
|
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
|
|
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
|
|
</span>
|
|
</span>
|
|
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
|
`;
|
|
return row;
|
|
}
|
|
|
|
export function renderKit(): HTMLElement {
|
|
const page = h("div", "kit");
|
|
|
|
// --- header ---
|
|
const header = h("header", "kit-header");
|
|
header.appendChild(
|
|
h(
|
|
"div",
|
|
"kit-brand",
|
|
`<span class="brand-dot" aria-hidden="true"></span>
|
|
<h1>maestro<span class="blink" aria-hidden="true">_</span> · design kit</h1>`,
|
|
),
|
|
);
|
|
const headerActions = h("div", "kit-header-actions");
|
|
const back = document.createElement("a");
|
|
back.className = "btn btn-ghost";
|
|
back.href = "?mock";
|
|
back.textContent = "← back to app";
|
|
headerActions.append(back, createThemeToggle());
|
|
header.appendChild(headerActions);
|
|
page.appendChild(header);
|
|
|
|
const main = h("div", "kit-main");
|
|
page.appendChild(main);
|
|
|
|
// --- color tokens ---
|
|
{
|
|
const { el, body } = section(
|
|
"Color tokens",
|
|
"Slate neutrals (hue ~256) + signal-green = alive. Swap by theme toggle.",
|
|
);
|
|
body.classList.add("kit-swatches");
|
|
const tokens = [
|
|
"--bg",
|
|
"--surface",
|
|
"--surface-2",
|
|
"--surface-3",
|
|
"--border",
|
|
"--ink",
|
|
"--muted",
|
|
"--faint",
|
|
"--accent",
|
|
"--danger",
|
|
"--warn",
|
|
];
|
|
for (const t of tokens) {
|
|
const sw = h("div", "kit-swatch");
|
|
const chip = h("div", "kit-swatch-chip");
|
|
chip.style.background = `var(${t})`;
|
|
sw.append(chip, h("span", "kit-label", t));
|
|
body.appendChild(sw);
|
|
}
|
|
main.appendChild(el);
|
|
}
|
|
|
|
// --- buttons: current system ---
|
|
{
|
|
const { el, body } = section(
|
|
"Buttons — current",
|
|
"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")),
|
|
specimen("btn (default)", btn("btn", "Cancel")),
|
|
specimen("btn-ghost", btn("btn btn-ghost", "Dismiss")),
|
|
specimen("btn-icon", btn("btn btn-icon", "✕")),
|
|
specimen("btn:disabled", btn("btn btn-primary", "+ Spawn", true)),
|
|
);
|
|
main.appendChild(el);
|
|
}
|
|
|
|
// --- buttons: terminal-native variants for critique ---
|
|
{
|
|
const { el, body } = section(
|
|
"Buttons — other explorations",
|
|
"The bracket variant was adopted as the app style (above). These remain as alternates — not used in the app.",
|
|
);
|
|
body.append(
|
|
specimen("bracket", btn("kit-btn-bracket", "+ Spawn")),
|
|
specimen("bracket primary", btn("kit-btn-bracket is-primary", "+ Spawn")),
|
|
specimen("prompt caret", btn("kit-btn-prompt", "spawn")),
|
|
specimen("block", btn("kit-btn-block", "SPAWN")),
|
|
specimen("underline", btn("kit-btn-underline", "spawn")),
|
|
);
|
|
main.appendChild(el);
|
|
}
|
|
|
|
// --- inputs ---
|
|
{
|
|
const { el, body } = section("Inputs", "Mono inputs with a green focus ring.");
|
|
const search = h("input") as HTMLInputElement;
|
|
search.type = "search";
|
|
search.className = "search";
|
|
search.placeholder = "Search sessions…";
|
|
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", "spawn-form");
|
|
const s = document.createElement("select");
|
|
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
|
sel.appendChild(s);
|
|
body.append(
|
|
specimen("search", search),
|
|
specimen("text input", text),
|
|
specimen("select", sel),
|
|
);
|
|
main.appendChild(el);
|
|
}
|
|
|
|
// --- chips & badges ---
|
|
{
|
|
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
|
|
const activeLine = h(
|
|
"span",
|
|
"card-line2",
|
|
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
|
|
);
|
|
activeLine.setAttribute("data-activity", "active");
|
|
body.append(
|
|
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
|
specimen("folder-count", h("span", "folder-count", "4")),
|
|
specimen("card-line2 · active", activeLine),
|
|
specimen("attach-state · open", h("span", "attach-state", "live")),
|
|
);
|
|
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
|
"data-state",
|
|
"open",
|
|
);
|
|
const connecting = specimen("attach-state · connecting", h("span", "attach-state", "connecting"));
|
|
(connecting.querySelector(".attach-state") as HTMLElement).setAttribute("data-state", "connecting");
|
|
const closed = specimen("attach-state · closed", h("span", "attach-state", "closed"));
|
|
(closed.querySelector(".attach-state") as HTMLElement).setAttribute("data-state", "closed");
|
|
body.append(connecting, closed);
|
|
main.appendChild(el);
|
|
}
|
|
|
|
// --- session rows, nested by working directory ---
|
|
{
|
|
const { el, body } = section(
|
|
"Session rows — nested by host, then directory",
|
|
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
|
|
);
|
|
const list = h("div", "kit-tree");
|
|
list.append(
|
|
folderHeader("~/git/maestro", 3, 0),
|
|
folderHeader("web", 2, 1),
|
|
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
|
|
leafRow({
|
|
name: "dev-server",
|
|
cmd: "vite dev",
|
|
status: "running",
|
|
indent: 2,
|
|
active: true,
|
|
activity: "active",
|
|
duration: "4m",
|
|
}),
|
|
folderHeader("crates/daemon", 1, 1),
|
|
leafRow({
|
|
name: "daemon-work",
|
|
cmd: "claude",
|
|
status: "running",
|
|
indent: 2,
|
|
activity: "awaiting-input",
|
|
duration: "12s",
|
|
}),
|
|
leafRow({
|
|
name: "flaky-agent",
|
|
cmd: "claude",
|
|
status: "running",
|
|
indent: 2,
|
|
activity: "error",
|
|
duration: "2h",
|
|
}),
|
|
folderHeader("~/work/acme", 2, 0),
|
|
leafRow({
|
|
name: "billing-api",
|
|
cmd: "claude",
|
|
status: "running",
|
|
indent: 1,
|
|
activity: "idle",
|
|
duration: "26m",
|
|
}),
|
|
leafRow({
|
|
name: "scratch",
|
|
cmd: "bash",
|
|
status: "dead",
|
|
indent: 1,
|
|
activity: "idle",
|
|
duration: "5h",
|
|
}),
|
|
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
|
|
);
|
|
body.classList.add("kit-tree-host");
|
|
body.appendChild(list);
|
|
main.appendChild(el);
|
|
}
|
|
|
|
// --- empty states ---
|
|
{
|
|
const { el, body } = section("Empty states", "Each one teaches the next action.");
|
|
body.classList.add("kit-empties");
|
|
const welcome = h("div", "empty");
|
|
welcome.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">⌘</div>
|
|
<div class="empty-title">No session open</div>
|
|
<p class="empty-hint">Pick a session from the list, or start a new one.</p>
|
|
<div class="empty-actions"></div>`;
|
|
welcome.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
|
|
const noSessions = h("div", "empty");
|
|
noSessions.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">+</div>
|
|
<div class="empty-title">No sessions yet</div>
|
|
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
|
<div class="empty-actions"></div>`;
|
|
noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
|
|
body.append(welcome, noSessions);
|
|
main.appendChild(el);
|
|
}
|
|
|
|
return page;
|
|
}
|