Replaces the deleted kit.ts + kit.css with DesignKit.tsx, gated behind import.meta.env.DEV and loaded on ?kit. Shows the full tailwind.config.ts token mapping (colours across all 6 palettes, typography, spacing, radii, z-index) plus buttons, ActivityRail states, SessionCard states, and terminal chrome, all switchable live through ThemeContext. Closes #254
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
// Dev-only design kit (#254), React port of the deleted kit.ts + kit.css.
|
|
// Specimen page for the Tailwind token mapping (tailwind.config.ts ->
|
|
// src/styles.css) so the visual system can be reviewed as a whole. Loads on
|
|
// `?kit` (see index.tsx) and never ships to a production bundle.
|
|
|
|
import { useState, type ReactNode } from "react";
|
|
|
|
import { ActivityRail, STATE_WORDS } from "../sidebar/ActivityRail.js";
|
|
import { SessionCard, type SessionCardProps } from "../sidebar/SessionCard.js";
|
|
import { PaneTitle } from "../layout/PaneTitle.js";
|
|
import { PALETTES, useTheme, type Palette } from "../../contexts/ThemeContext.js";
|
|
import type { Session } from "../../types.js";
|
|
|
|
/** The accent each palette lands on, read from styles.css — shown so all six
|
|
* are comparable at a glance without switching the live one one at a time. */
|
|
const PALETTE_ACCENT: Record<Palette, string> = {
|
|
graphite: "oklch(0.92 0.005 256)",
|
|
"ice-cyan": "oklch(0.85 0.115 205)",
|
|
periwinkle: "oklch(0.75 0.125 275)",
|
|
"amber-phosphor": "oklch(0.85 0.16 85)",
|
|
ember: "oklch(0.78 0.16 40)",
|
|
orchid: "oklch(0.78 0.13 320)",
|
|
};
|
|
|
|
const COLOR_TOKENS = [
|
|
"--bg",
|
|
"--bg-grid",
|
|
"--surface",
|
|
"--surface-2",
|
|
"--surface-3",
|
|
"--border",
|
|
"--border-strong",
|
|
"--ink",
|
|
"--muted",
|
|
"--faint",
|
|
"--accent",
|
|
"--accent-strong",
|
|
"--accent-ink",
|
|
"--accent-tint",
|
|
"--accent-fill",
|
|
"--live",
|
|
"--danger",
|
|
"--danger-tint",
|
|
"--warn",
|
|
"--term-bg",
|
|
] as const;
|
|
|
|
// Tailwind's content scanner needs each utility to appear as a literal,
|
|
// contiguous string in source — a template-built `w-${token}` is invisible
|
|
// to it, so every scale below pairs the token label with its whole class name.
|
|
const SPACING_TOKENS = [
|
|
{ token: "sp-1", widthClass: "w-sp-1" },
|
|
{ token: "sp-2", widthClass: "w-sp-2" },
|
|
{ token: "sp-3", widthClass: "w-sp-3" },
|
|
{ token: "sp-4", widthClass: "w-sp-4" },
|
|
{ token: "sp-5", widthClass: "w-sp-5" },
|
|
{ token: "sp-6", widthClass: "w-sp-6" },
|
|
{ token: "sp-8", widthClass: "w-sp-8" },
|
|
] as const;
|
|
const RADIUS_TOKENS = [
|
|
{ token: "r-1", radiusClass: "rounded-r-1" },
|
|
{ token: "r-2", radiusClass: "rounded-r-2" },
|
|
{ token: "r-3", radiusClass: "rounded-r-3" },
|
|
{ token: "r-pill", radiusClass: "rounded-r-pill" },
|
|
] as const;
|
|
const FONT_SIZE_TOKENS = [
|
|
{ token: "fs-11", sizeClass: "text-fs-11" },
|
|
{ token: "fs-12", sizeClass: "text-fs-12" },
|
|
{ token: "fs-13", sizeClass: "text-fs-13" },
|
|
{ token: "fs-14", sizeClass: "text-fs-14" },
|
|
{ token: "fs-15", sizeClass: "text-fs-15" },
|
|
{ token: "fs-17", sizeClass: "text-fs-17" },
|
|
{ token: "fs-20", sizeClass: "text-fs-20" },
|
|
] as const;
|
|
const Z_INDEX_TOKENS = [
|
|
{ name: "z-sticky", value: 10 },
|
|
{ name: "z-backdrop", value: 40 },
|
|
{ name: "z-modal", value: 50 },
|
|
{ name: "z-toast", value: 60 },
|
|
] as const;
|
|
|
|
function Section({
|
|
title,
|
|
note,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
note?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="border-t border-border py-sp-6 first:border-t-0 first:pt-0">
|
|
<h2 className="text-fs-17 font-semibold text-ink">{title}</h2>
|
|
{note && <p className="mt-sp-1 max-w-[70ch] text-fs-12 text-muted">{note}</p>}
|
|
<div className="mt-sp-4 flex flex-wrap gap-sp-4">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col items-start gap-sp-2">
|
|
<div className="flex min-h-[48px] items-center rounded-r-2 border border-border bg-surface p-sp-3">
|
|
{children}
|
|
</div>
|
|
<span className="font-mono text-fs-11 text-faint">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function fakeSession(overrides: Partial<Session>): Session {
|
|
return {
|
|
id: "00000000-0000-0000-0000-000000000000",
|
|
cwd: "/home/dev/maestro",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: new Date().toISOString(),
|
|
activity_since: new Date(Date.now() - 4 * 60_000).toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const NOOP_CARD_HANDLERS: Omit<SessionCardProps, "session" | "depth" | "now" | "highlighted" | "active" | "open" | "previewing" | "renaming"> = {
|
|
onSelect: () => {},
|
|
onOpenMenu: () => {},
|
|
onSpawnSimilar: () => {},
|
|
onKill: () => {},
|
|
onRenameCommit: () => {},
|
|
onRenameCancel: () => {},
|
|
};
|
|
|
|
const CARD_SPECIMENS: Array<{ label: string; props: Omit<SessionCardProps, keyof typeof NOOP_CARD_HANDLERS> }> = [
|
|
{
|
|
label: "idle",
|
|
props: {
|
|
session: fakeSession({ command: "bash" }),
|
|
depth: 0,
|
|
now: Date.now(),
|
|
highlighted: false,
|
|
active: false,
|
|
open: false,
|
|
previewing: false,
|
|
renaming: false,
|
|
},
|
|
},
|
|
{
|
|
label: "active, focused",
|
|
props: {
|
|
session: fakeSession({ activity: "Active", command: "vite dev" }),
|
|
depth: 0,
|
|
now: Date.now(),
|
|
highlighted: false,
|
|
active: true,
|
|
open: true,
|
|
previewing: false,
|
|
renaming: false,
|
|
},
|
|
},
|
|
{
|
|
label: "awaiting-input",
|
|
props: {
|
|
session: fakeSession({ activity: "AwaitingInput", activity_since: new Date(Date.now() - 12_000).toISOString() }),
|
|
depth: 0,
|
|
now: Date.now(),
|
|
highlighted: false,
|
|
active: false,
|
|
open: false,
|
|
previewing: false,
|
|
renaming: false,
|
|
},
|
|
},
|
|
{
|
|
label: "error",
|
|
props: {
|
|
session: fakeSession({ activity: "Error", activity_detail: "tests failing", activity_since: new Date(Date.now() - 7_200_000).toISOString() }),
|
|
depth: 0,
|
|
now: Date.now(),
|
|
highlighted: false,
|
|
active: false,
|
|
open: false,
|
|
previewing: false,
|
|
renaming: false,
|
|
},
|
|
},
|
|
{
|
|
label: "previewing",
|
|
props: {
|
|
session: fakeSession({ command: "opencode" }),
|
|
depth: 0,
|
|
now: Date.now(),
|
|
highlighted: false,
|
|
active: false,
|
|
open: false,
|
|
previewing: true,
|
|
renaming: false,
|
|
},
|
|
},
|
|
{
|
|
label: "exited",
|
|
props: {
|
|
session: fakeSession({ status: "Dead", command: "btop" }),
|
|
depth: 0,
|
|
now: Date.now(),
|
|
highlighted: false,
|
|
active: false,
|
|
open: false,
|
|
previewing: false,
|
|
renaming: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
const ACTIVITY_STATES = Object.keys(STATE_WORDS) as Array<keyof typeof STATE_WORDS>;
|
|
|
|
export function DesignKit() {
|
|
const { theme, palette, toggleTheme, setPalette } = useTheme();
|
|
// "open" is deliberately display:none in the real app (.mv-pane's rule,
|
|
// styles.css) — a streaming pane already shows it's connected — so start on
|
|
// a state the kit page can actually show.
|
|
const [attachState, setAttachState] = useState<"connecting" | "open" | "closed">("connecting");
|
|
|
|
return (
|
|
// #root is a fixed-height, overflow-hidden flex column (main.css) built for
|
|
// the app shell's own internal scrolling — this page needs its own scroll
|
|
// container to match, or everything past the first viewport is clipped.
|
|
<div className="h-full overflow-y-auto bg-bg text-ink">
|
|
<header className="flex items-center justify-between border-b border-border px-sp-4 py-sp-3">
|
|
<h1 className="text-fs-20 font-semibold">
|
|
maestro<span className="text-accent">_</span> · design kit
|
|
</h1>
|
|
<div className="flex items-center gap-sp-2">
|
|
<button type="button" className="btn btn-ghost" onClick={toggleTheme}>
|
|
{theme === "dark" ? "☀ light" : "☾ dark"}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto max-w-5xl px-sp-4 py-sp-4">
|
|
<Section
|
|
title="Colour palettes"
|
|
note="6 palettes, picked via data-palette on <html> through ThemeContext. Switching one recolours every token below live."
|
|
>
|
|
{PALETTES.map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => setPalette(p)}
|
|
className={`flex flex-col items-center gap-sp-1 rounded-r-2 border p-sp-2 ${
|
|
p === palette ? "border-accent" : "border-border"
|
|
}`}
|
|
>
|
|
<span
|
|
className="h-sp-6 w-sp-8 rounded-r-1"
|
|
style={{ background: PALETTE_ACCENT[p] }}
|
|
/>
|
|
<span className="font-mono text-fs-11 text-muted">{p}</span>
|
|
</button>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Colour tokens" note="Current palette + theme, applied through var(--token).">
|
|
{COLOR_TOKENS.map((t) => (
|
|
<div key={t} className="flex flex-col items-start gap-sp-1">
|
|
<div
|
|
className="h-sp-8 w-sp-8 rounded-r-2 border border-border"
|
|
style={{ background: `var(${t})` }}
|
|
/>
|
|
<span className="font-mono text-fs-11 text-faint">{t}</span>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Typography" note="fontSize -> var(--fs-N), from tailwind.config.ts.">
|
|
{FONT_SIZE_TOKENS.map(({ token, sizeClass }) => (
|
|
<Specimen key={token} label={token}>
|
|
<span className={`font-mono ${sizeClass}`}>Sessions</span>
|
|
</Specimen>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Spacing scale" note="spacing -> var(--sp-N).">
|
|
{SPACING_TOKENS.map(({ token, widthClass }) => (
|
|
<div key={token} className="flex flex-col items-start gap-sp-1">
|
|
<div className={`h-sp-3 bg-accent ${widthClass}`} />
|
|
<span className="font-mono text-fs-11 text-faint">{token}</span>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Border radii" note="borderRadius -> var(--r-N).">
|
|
{RADIUS_TOKENS.map(({ token, radiusClass }) => (
|
|
<div key={token} className="flex flex-col items-start gap-sp-1">
|
|
<div className={`h-sp-6 w-sp-8 border-2 border-accent ${radiusClass}`} />
|
|
<span className="font-mono text-fs-11 text-faint">{token}</span>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Z-index scale" note="zIndex -> var(--z-*).">
|
|
{Z_INDEX_TOKENS.map((z) => (
|
|
<div key={z.name} className="flex items-baseline gap-sp-2 font-mono text-fs-12">
|
|
<span className="text-muted">{z.name}</span>
|
|
<span className="text-ink">{z.value}</span>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Buttons" note="Terminal-native bracket chrome — .btn / .btn-primary / .btn-ghost / .btn-icon / .btn-danger.">
|
|
<Specimen label="btn">
|
|
<button type="button" className="btn">Cancel</button>
|
|
</Specimen>
|
|
<Specimen label="btn-primary">
|
|
<button type="button" className="btn btn-primary">+ Spawn</button>
|
|
</Specimen>
|
|
<Specimen label="btn-ghost">
|
|
<button type="button" className="btn btn-ghost">Dismiss</button>
|
|
</Specimen>
|
|
<Specimen label="btn-icon">
|
|
<button type="button" className="btn btn-icon">✕</button>
|
|
</Specimen>
|
|
<Specimen label="btn-danger">
|
|
<button type="button" className="btn btn-danger">Kill</button>
|
|
</Specimen>
|
|
<Specimen label="btn:disabled">
|
|
<button type="button" className="btn btn-primary" disabled>+ Spawn</button>
|
|
</Specimen>
|
|
</Section>
|
|
|
|
<Section title="Activity rail" note="ActivityRail.tsx — one state per session card, colour-coded and never colour-only (title/aria-label carry the word).">
|
|
{ACTIVITY_STATES.map((slug) => (
|
|
<Specimen key={slug} label={`${slug} · ${STATE_WORDS[slug]}`}>
|
|
<div className="relative h-[32px] w-[24px] overflow-hidden rounded-r-1 bg-surface-2">
|
|
<ActivityRail activity={slug} />
|
|
</div>
|
|
</Specimen>
|
|
))}
|
|
</Section>
|
|
|
|
<Section title="Session cards" note="SessionCard.tsx, docs/SIDEBAR.md states.">
|
|
<div className="w-full max-w-sm divide-y divide-border rounded-r-2 border border-border bg-surface-2">
|
|
{CARD_SPECIMENS.map(({ label, props }) => (
|
|
<div key={label} className="relative">
|
|
<SessionCard {...props} {...NOOP_CARD_HANDLERS} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Terminal chrome" note="attach-header + attach-body, as worn by TerminalPane/StructuredPane.">
|
|
<div className="w-full max-w-lg overflow-hidden rounded-r-2 border border-border">
|
|
<div className="attach-header">
|
|
<PaneTitle
|
|
sessionId="11111111-2222-3333-4444-555555555555"
|
|
session={fakeSession({ command: "claude" })}
|
|
/>
|
|
<span className="flex-1" />
|
|
<button
|
|
type="button"
|
|
className="attach-state"
|
|
data-state={attachState}
|
|
onClick={() =>
|
|
setAttachState((s) => (s === "open" ? "connecting" : s === "connecting" ? "closed" : "open"))
|
|
}
|
|
title="Click to cycle"
|
|
>
|
|
{attachState}
|
|
</button>
|
|
<button type="button" className="btn btn-icon disconnect" title="Disconnect">
|
|
⏻
|
|
</button>
|
|
</div>
|
|
<div className="attach-body h-[120px] items-start">
|
|
<span className="font-mono text-fs-13 text-muted">$ pnpm test</span>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|