A preview was distinguished only by the chip and a dashed ring, both easy to miss, and the sidebar's hover reused the neutral surface — so a hovered row said nothing about what the content pane was showing. Adds a `--preview` token family on a hue of its own, deliberately not the accent: the accent means "this is the one you picked", which a preview is not. The previewed pane's titlebar, the chip, the throwaway tab, the previewed sidebar row and hover all carry it. Palettes whose accent is already violet (periwinkle, orchid) move the hue via `--preview-h`.
317 lines
11 KiB
TypeScript
317 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 { createPaletteSelect, 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");
|
|
const depth = opts.indent ?? 0;
|
|
row.style.paddingLeft = `${depth * 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("");
|
|
const activity = opts.activity ?? "idle";
|
|
row.innerHTML = `
|
|
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
|
|
<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-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(), createPaletteSelect());
|
|
header.appendChild(headerActions);
|
|
page.appendChild(header);
|
|
|
|
const main = h("div", "kit-main");
|
|
page.appendChild(main);
|
|
|
|
// --- color tokens ---
|
|
{
|
|
const { el, body } = section(
|
|
"Color tokens",
|
|
"Graphite neutrals; activity colours are the only hue. Swap by theme toggle + palette picker.",
|
|
);
|
|
body.classList.add("kit-swatches");
|
|
const tokens = [
|
|
"--bg",
|
|
"--surface",
|
|
"--surface-2",
|
|
"--surface-3",
|
|
"--border",
|
|
"--ink",
|
|
"--muted",
|
|
"--faint",
|
|
"--accent",
|
|
"--preview",
|
|
"--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-duration">4m</span>`,
|
|
);
|
|
activeLine.setAttribute("data-activity", "active");
|
|
const rail = h("span", "activity-rail");
|
|
rail.dataset.activity = "active";
|
|
rail.style.cssText = "position:static;display:inline-block;height:14px;";
|
|
body.append(
|
|
specimen("folder-count", h("span", "folder-count", "4")),
|
|
specimen("activity-rail · active", rail),
|
|
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); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.",
|
|
);
|
|
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;
|
|
}
|