diff --git a/web/src/components/kit/DesignKit.tsx b/web/src/components/kit/DesignKit.tsx new file mode 100644 index 0000000..f90d113 --- /dev/null +++ b/web/src/components/kit/DesignKit.tsx @@ -0,0 +1,382 @@ +// 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 = { + 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 ( +
+

{title}

+ {note &&

{note}

} +
{children}
+
+ ); +} + +function Specimen({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {children} +
+ {label} +
+ ); +} + +function fakeSession(overrides: Partial): 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 = { + onSelect: () => {}, + onOpenMenu: () => {}, + onSpawnSimilar: () => {}, + onKill: () => {}, + onRenameCommit: () => {}, + onRenameCancel: () => {}, +}; + +const CARD_SPECIMENS: Array<{ label: string; props: Omit }> = [ + { + 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; + +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. +
+
+

+ maestro_ · design kit +

+
+ +
+
+ +
+
+ {PALETTES.map((p) => ( + + ))} +
+ +
+ {COLOR_TOKENS.map((t) => ( +
+
+ {t} +
+ ))} +
+ +
+ {FONT_SIZE_TOKENS.map(({ token, sizeClass }) => ( + + Sessions + + ))} +
+ +
+ {SPACING_TOKENS.map(({ token, widthClass }) => ( +
+
+ {token} +
+ ))} +
+ +
+ {RADIUS_TOKENS.map(({ token, radiusClass }) => ( +
+
+ {token} +
+ ))} +
+ +
+ {Z_INDEX_TOKENS.map((z) => ( +
+ {z.name} + {z.value} +
+ ))} +
+ +
+ + + + + + + + + + + + + + + + + + +
+ +
+ {ACTIVITY_STATES.map((slug) => ( + +
+ +
+
+ ))} +
+ +
+
+ {CARD_SPECIMENS.map(({ label, props }) => ( +
+ +
+ ))} +
+
+ +
+
+
+ + + + +
+
+ $ pnpm test +
+
+
+
+
+ ); +} diff --git a/web/src/index.tsx b/web/src/index.tsx index 081a4ef..614de53 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; import { App } from "./App.js"; +import { ThemeProvider } from "./contexts/ThemeContext.js"; import "./main.css"; const container = document.getElementById("root"); @@ -7,4 +8,18 @@ if (!container) { throw new Error("missing #root element"); } -createRoot(container).render(); +const root = createRoot(container); + +// Dev-only: `?kit` renders the standalone design kit instead of the app +// shell (#254). Dynamic import keeps it out of the production bundle. +if (import.meta.env.DEV && new URLSearchParams(location.search).has("kit")) { + void import("./components/kit/DesignKit.js").then(({ DesignKit }) => { + root.render( + + + , + ); + }); +} else { + root.render(); +}