// The kit: what the playground draws with, and what playground files compose. Small and // quiet on purpose: thin rings, 12px names, 10px detail, colour only where it says // something. Kumo tokens only, so light and dark both work. import { cn } from "@cloudflare/kumo"; import { TicketIcon, XIcon } from "@phosphor-icons/react"; import { createContext, useContext, type CSSProperties, type ReactNode } from "react"; import type { Icon as IconName, Mood, Row, Shape } from "../spec/index.ts"; import { iconFor, labelInside, moodColor, telling } from "./display.tsx"; // Context ------------------------------------------------------------------------------------------ /** What the playground tells the kit: whose colour an instance wears. */ export interface PlayContextValue { /** The identity colour of an instance, by id or label. */ colorOf: (ref: string | null | undefined) => string | undefined; } const PlayContext = createContext({ colorOf: () => undefined }); export const PlayProvider = PlayContext.Provider; export const usePlay = () => useContext(PlayContext); // Icons -------------------------------------------------------------------------------------------- export function Icon({ name, size = 16, className, style }: { name: IconName | null | undefined; size?: number; className?: string; style?: CSSProperties }) { const Glyph = iconFor(name ?? null); return ; } // Nodes -------------------------------------------------------------------------------------------- export interface NodeBadge { /** One or two characters: an initial. */ text: string; color: string; title?: string; } export interface NodeVisualProps { shape: Shape; icon?: IconName | null; label: string; status?: string | null; /** Colours the ring when it says something (ok, busy, warn, bad); otherwise the ring is neutral. */ mood?: Mood | null; /** A ring colour that wins over the mood's. */ ring?: string; /** Services in a region wear the region's accent on their ring and icon. */ accent?: string; /** Whose it is: a small initial in their colour. */ badge?: NodeBadge | null; /** The name inside the shape; by default for services and gates. */ inside?: boolean; className?: string; /** Classes for the shape itself, such as an animation that should follow its outline. */ shapeClassName?: string; } const SHAPE_SIZE: Record = { circle: "size-[60px] rounded-full", device: "size-[54px] rounded-[16px]", gate: "h-[84px] w-[60px] rounded-[14px]", card: "h-[48px] w-[132px] rounded-xl", }; /** A node as the playground draws it: a shape with an icon, its name, and a few words. */ export function Node({ shape, icon, label, status, mood, ring, accent, badge, inside, className, shapeClassName }: NodeVisualProps) { const within = inside ?? labelInside(shape, icon, label); const edge = ring ?? (telling(mood) ? moodColor(mood) : (accent ?? "var(--color-kumo-line)")); const body = ( {within && ( {label} {shape !== "circle" && status && {status}} )} {badge && ( {badge.text} )} ); const below = within ? (shape === "circle" ? status : null) : null; return ( {body} {!within && ( {label} {status && {status}} )} {below && {below}} ); } // Rows --------------------------------------------------------------------------------------------- /** * State as structure: rows boxed by group. A group belonging to someone wears their colour. * `anchor` marks each row so live links can end at it. */ export function Rows({ rows, anchor, compact, limit, className }: { rows: Row[]; anchor?: string; compact?: boolean; limit?: number; className?: string }) { const { colorOf } = usePlay(); const max = limit ?? (compact ? 3 : 6); const dot = (row: Row) => (row.mood ? moodColor(row.mood) : (colorOf(row.owner) ?? moodColor(null))); const groups = new Map(); for (const row of rows) groups.set(row.group ?? "", [...(groups.get(row.group ?? "") ?? []), row]); if (!rows.length) return null; return (
{[...groups].map(([group, items]) => { const owner = colorOf(items.find((r) => r.owner)?.owner); return ( // A border, not an outer ring: the list clips what is outside its boxes, which cut a ring's edges.
{group && (
{group}
)}
{items.slice(0, max).map((row, i) => (
{row.label} {row.detail && {row.detail}}
))} {items.length > max &&
+{items.length - max} more
}
); })}
); } /** A small titled box: a host_link record, a Settings pane. */ export function Card({ title, children, className }: { title?: ReactNode; children: ReactNode; className?: string }) { return (
{title &&
{title}
}
{children}
); } export function Badge({ children, mood }: { children: ReactNode; mood?: Mood }) { return ( {children} ); } // Tokens ------------------------------------------------------------------------------------------- /** Something to hand over: a code on a ticket, struck through once it no longer works. */ export function Ticket({ code, expired = false, label }: { code: string; expired?: boolean; label?: string }) { return ( {code} ); } // Panels ------------------------------------------------------------------------------------------- /** A terminal beside a node: what came back, and commands to click. */ export function Terminal({ title, lines, commands = [], onRun, onClose, busy, }: { title: string; lines: string[]; commands?: string[]; onRun?: (command: string) => void; onClose?: () => void; /** Waiting for an answer. */ busy?: boolean; }) { return ( // A terminal is dark in either mode: Kumo's dark tokens, scoped to it.
e.stopPropagation()}>
{title} {onClose && ( )}
        {lines.slice(-4).join("\n") || " "}
        {busy && ▍}
      
{commands.length > 0 && (
{commands.map((command) => ( ))}
)}
); } /** A plain panel beside a node, for anything else. */ export function Panel({ title, children, onClose }: { title?: ReactNode; children: ReactNode; onClose?: () => void }) { return (
e.stopPropagation()}> {(title || onClose) && (
{title} {onClose && ( )}
)}
{children}
); }