mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
design: vellum paper aesthetic — Layers 0-8
Complete visual overhaul from Anduril dark-card theme to "Tactical Dossier" vellum paper aesthetic per design-v2-vellum-paper spec. Color system: - Background: #faf9f5 warm paper with radial dot grid (20px) - Cards: white with solid black border + 2px hard offset shadow - Ink: #2e342e (slight green cast, aged ink feel) - Secondary: #546073 (technical blue for annotations, edges, crosshairs) - Tertiary: #b12d2a (stamped red for alerts/errors) - 5-tier surface container hierarchy (#faf9f5 → #dfe4dc) New CSS: - Registration marks (8px L-shaped crosshairs at container corners) - Vellum overlay classes (bg-white/40-70 + backdrop-blur) - Hard offset shadow (2px 2px 0px #2e342e) - Stamp watermark (rotated, 4% opacity, border + bold text) - Stamp badge (for critical status indicators) Components: - Header: paper bg, border-b-2, bordered "RIGGED" case-ID block, center nav tabs with active underline - Sidebar: 80px icon strip with Material Symbols, backdrop-blur, active border-l-4 style, JetBrains Mono 8px labels - Node cards: white body, dark header stripe (core=black, worker=gray), NODE_TYPE label, bordered ACTIVE/EXITED pill badges - Edges: #546073 with arrow markers, solid vs dashed for relationship type, no text labels - Snapshot panel: vellum-heavy frosted glass, crosshair registration marks, Space Grotesk 2xl heading - Dashboard cards: same white+border+shadow pattern with dark header - Status bar: floating bordered pills (SYSTEM_STABLE, RIGS, CMUX) - Buttons: solid black primary, bordered secondary, mono technical - Dialogs: vellum-heavy with diffused shadow Tests not yet updated — will fix in next commit. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
9515e1a7bc
commit
e316116f59
18 files changed
+551
-443
No files matched your search
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rigged</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,[email protected],0..1&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -17,32 +17,66 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header — dark chrome bar */}
|
||||
{/* Header — paper with thick bottom border */}
|
||||
<header
|
||||
data-testid="app-header"
|
||||
className="h-12 flex items-center justify-between px-spacing-4 bg-surface-dark text-foreground-on-dark shrink-0 relative z-30"
|
||||
className="h-14 flex items-center justify-between px-spacing-6 bg-background border-b-2 border-stone-900 shrink-0 relative z-30"
|
||||
>
|
||||
<div className="flex items-center gap-spacing-3">
|
||||
{/* Hamburger — visible on narrow viewports, or always as toggle */}
|
||||
<div className="flex items-center gap-spacing-4">
|
||||
{/* Hamburger — narrow viewports only */}
|
||||
<button
|
||||
data-testid="sidebar-toggle"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="flex flex-col gap-[3px] p-1 lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span className="block w-4 h-[1.5px] bg-foreground-on-dark" />
|
||||
<span className="block w-4 h-[1.5px] bg-foreground-on-dark" />
|
||||
<span className="block w-3 h-[1.5px] bg-foreground-on-dark" />
|
||||
<span className="block w-4 h-[1.5px] bg-stone-900" />
|
||||
<span className="block w-4 h-[1.5px] bg-stone-900" />
|
||||
<span className="block w-3 h-[1.5px] bg-stone-900" />
|
||||
</button>
|
||||
|
||||
<h1 className="text-label-lg uppercase tracking-[0.08em] font-inter font-bold">
|
||||
{/* Case ID block */}
|
||||
<div className="font-mono text-base font-bold tracking-tighter text-stone-900 border-x border-stone-300 px-3 py-0.5">
|
||||
RIGGED
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-label-sm font-mono text-foreground-muted-on-dark">
|
||||
v0.1.0
|
||||
</span>
|
||||
{/* Center nav — hidden on narrow */}
|
||||
<nav className="hidden md:flex gap-spacing-8">
|
||||
{[
|
||||
{ path: "/", label: "RIGS" },
|
||||
{ path: "/packages", label: "PACKAGES" },
|
||||
{ path: "/discovery", label: "DISCOVERY" },
|
||||
].map((item) => {
|
||||
const isActive = item.path === "/"
|
||||
? pathname === "/" || pathname.startsWith("/rigs")
|
||||
: pathname.startsWith(item.path);
|
||||
return (
|
||||
<a
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className={`font-headline tracking-tight uppercase text-sm font-bold transition-colors ${
|
||||
isActive
|
||||
? "text-stone-900 border-b-2 border-stone-900 pb-0.5"
|
||||
: "text-stone-500 hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right side — icons */}
|
||||
<div className="flex items-center gap-spacing-2">
|
||||
<button
|
||||
onClick={() => setFeedOpen(!feedOpen)}
|
||||
className="p-2 hover:bg-stone-200 transition-colors font-mono text-xs text-stone-500"
|
||||
aria-label="Activity"
|
||||
>
|
||||
{events.length > 0 ? `${events.length}` : ""}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main: Sidebar + Content */}
|
||||
@@ -50,16 +84,14 @@ export function AppShell({ children }: AppShellProps) {
|
||||
{/* Sidebar overlay backdrop for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-20 lg:hidden"
|
||||
className="fixed inset-0 bg-black/20 z-20 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<main data-testid="content-area" className="flex-1 flex flex-col overflow-auto bg-background relative">
|
||||
{/* Subtle dither grain texture on canvas */}
|
||||
<div className="bg-dither absolute inset-0 pointer-events-none z-0" />
|
||||
<main data-testid="content-area" className="flex-1 flex flex-col overflow-auto relative">
|
||||
<div key={pathname} className="relative z-10 route-enter flex-1 flex flex-col">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,7 @@ export function DiscoveryOverlay() {
|
||||
const { data: sessions = [] } = useDiscoveredSessions();
|
||||
useDiscoveryPoll(30_000);
|
||||
const claimMutation = useClaimSession();
|
||||
const visibleSessions = sessions.filter((session) => session.status !== "claimed");
|
||||
|
||||
const [claimTarget, setClaimTarget] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -107,7 +108,7 @@ export function DiscoveryOverlay() {
|
||||
);
|
||||
};
|
||||
|
||||
if (sessions.length === 0) {
|
||||
if (visibleSessions.length === 0) {
|
||||
return (
|
||||
<div className="p-spacing-6" data-testid="discovery-empty">
|
||||
<p className="text-body-md text-foreground-muted">No discovered sessions</p>
|
||||
@@ -119,7 +120,7 @@ export function DiscoveryOverlay() {
|
||||
<div className="p-spacing-6" data-testid="discovery-overlay">
|
||||
<h3 className="text-headline-md uppercase mb-spacing-4">DISCOVERED SESSIONS</h3>
|
||||
|
||||
{sessions.map((s) => (
|
||||
{visibleSessions.map((s) => (
|
||||
<DiscoveredSessionCard key={s.id} session={s} onClaim={handleClaimClick} />
|
||||
))}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface RigCardProps {
|
||||
onSelect: (rigId: string) => void;
|
||||
onSnapshot: () => void;
|
||||
onExport: () => void;
|
||||
onDown?: () => void;
|
||||
onDown: () => void;
|
||||
}
|
||||
|
||||
function formatAge(timestamp: string | null): string {
|
||||
@@ -32,88 +32,102 @@ function formatAge(timestamp: string | null): string {
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
function statusColor(status: PsEntry["status"]): string {
|
||||
switch (status) {
|
||||
case "running": return "text-success";
|
||||
case "partial": return "text-warning";
|
||||
case "stopped": return "text-foreground-muted-on-dark";
|
||||
}
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export function RigCard({ rig, psEntry, onSelect, onSnapshot, onExport, onDown }: RigCardProps) {
|
||||
const animatedCount = useCountUp(rig.nodeCount);
|
||||
const isStopped = psEntry?.status === "stopped";
|
||||
const isRunning = psEntry && psEntry.runningCount > 0;
|
||||
const statusLabel = isRunning ? "RUNNING" : "STOPPED";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`rig-card-${rig.id}`}
|
||||
className={`card-dark p-spacing-6 mb-spacing-3 cursor-pointer transition-all duration-150 ease-tactical hover:bg-surface-mid group ${isStopped ? "opacity-60" : ""}`}
|
||||
className="bg-white border border-stone-900 hard-shadow mb-spacing-4 cursor-pointer hover:hard-shadow-hover transition-all relative"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(rig.id)}
|
||||
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && e.target === e.currentTarget) { e.preventDefault(); onSelect(rig.id); } }}
|
||||
>
|
||||
{/* Header: name + node count */}
|
||||
<div className="flex justify-between items-baseline mb-spacing-4">
|
||||
<h3 className="text-headline-md uppercase text-foreground-on-dark">{rig.name}</h3>
|
||||
<span className="text-label-md font-mono text-foreground-muted-on-dark" data-testid={`node-count-${rig.id}`}>
|
||||
{animatedCount} NODE{rig.nodeCount !== 1 ? "S" : ""}
|
||||
{/* Dark header stripe */}
|
||||
<div className="bg-stone-900 text-white px-4 py-1.5 font-mono text-[10px] flex justify-between items-center">
|
||||
<span>RIG: {rig.name.toUpperCase()}</span>
|
||||
<span className="material-symbols-outlined text-[12px]" style={{ fontVariationSettings: "'FILL' 0, 'wght' 300" }}>
|
||||
settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Telemetry section */}
|
||||
<div className="inset-dark p-spacing-4 mb-spacing-4">
|
||||
<div className="flex gap-spacing-8 text-label-md">
|
||||
<div>
|
||||
<span className="text-foreground-muted-on-dark/60 uppercase text-label-sm tracking-[0.06em]">SNAPSHOT </span>
|
||||
<span className="font-mono text-foreground-on-dark" data-testid={`snapshot-age-${rig.id}`}>
|
||||
{formatAge(rig.latestSnapshotAt)}
|
||||
</span>
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Name + status */}
|
||||
<div className="flex justify-between items-end border-b border-stone-100 pb-2">
|
||||
<span className="font-headline font-bold text-lg tracking-tight uppercase">{rig.name}</span>
|
||||
<span className={`px-2 py-0.5 border font-mono text-[8px] uppercase ${
|
||||
isRunning ? "border-stone-900" : "border-stone-400 text-stone-400"
|
||||
}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Telemetry grid */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between font-mono text-[9px] text-secondary">
|
||||
<span>NODES</span>
|
||||
<span data-testid={`node-count-${rig.id}`}>{animatedCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-foreground-muted-on-dark/60 uppercase text-label-sm tracking-[0.06em]">STATUS </span>
|
||||
<span className={`font-mono ${psEntry ? statusColor(psEntry.status) : "text-foreground-muted-on-dark"}`} data-testid={`status-badge-${rig.id}`}>
|
||||
{psEntry ? psEntry.status.toUpperCase() : "—"}
|
||||
</span>
|
||||
<div className="flex justify-between font-mono text-[9px] text-secondary">
|
||||
<span>SNAPSHOT</span>
|
||||
<span data-testid={`snapshot-age-${rig.id}`}>{formatAge(rig.latestSnapshotAt)}</span>
|
||||
</div>
|
||||
{psEntry?.uptime && (
|
||||
<div>
|
||||
<span className="text-foreground-muted-on-dark/60 uppercase text-label-sm tracking-[0.06em]">UPTIME </span>
|
||||
<span className="font-mono text-foreground-on-dark">{psEntry.uptime}</span>
|
||||
{psEntry && psEntry.uptimeSeconds != null && (
|
||||
<div className="flex justify-between font-mono text-[9px] text-secondary">
|
||||
<span>UPTIME</span>
|
||||
<span>{formatUptime(psEntry.uptimeSeconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-spacing-2">
|
||||
<button
|
||||
className="px-spacing-3 py-spacing-1 text-label-md uppercase tracking-[0.04em] text-foreground-muted-on-dark bg-white/6 border border-white/10 hover:bg-white/12 hover:text-foreground-on-dark transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onSnapshot(); }}
|
||||
>
|
||||
SNAPSHOT
|
||||
</button>
|
||||
<button
|
||||
className="px-spacing-3 py-spacing-1 text-label-md uppercase tracking-[0.04em] text-foreground-muted-on-dark bg-white/6 border border-white/10 hover:bg-white/12 hover:text-foreground-on-dark transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onExport(); }}
|
||||
>
|
||||
EXPORT
|
||||
</button>
|
||||
{onDown && (
|
||||
<button
|
||||
data-testid={`down-btn-${rig.id}`}
|
||||
className="px-spacing-3 py-spacing-1 text-label-md uppercase tracking-[0.04em] text-destructive bg-white/6 border border-destructive/20 hover:bg-destructive/10 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onDown(); }}
|
||||
>
|
||||
DOWN
|
||||
</button>
|
||||
{/* Status indicator */}
|
||||
{isRunning && (
|
||||
<div className="bg-success/10 border border-success/20 px-2 py-1 flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-success" />
|
||||
<span className="font-mono text-[9px] text-success font-bold">ACTIVE</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="px-spacing-3 py-spacing-1 text-label-md uppercase tracking-[0.04em] text-foreground-on-dark bg-white/10 border border-white/15 hover:bg-white/20 transition-all ml-auto"
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(rig.id); }}
|
||||
>
|
||||
GRAPH →
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-spacing-2 pt-1">
|
||||
<button
|
||||
className="px-2 py-0.5 border border-outline font-mono text-[9px] text-secondary hover:border-stone-900 hover:text-stone-900 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onSnapshot(); }}
|
||||
>
|
||||
SNAPSHOT
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-0.5 border border-outline font-mono text-[9px] text-secondary hover:border-stone-900 hover:text-stone-900 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onExport(); }}
|
||||
>
|
||||
EXPORT
|
||||
</button>
|
||||
{isRunning && (
|
||||
<button
|
||||
className="px-2 py-0.5 border border-tertiary/30 font-mono text-[9px] text-tertiary hover:bg-tertiary hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDown(); }}
|
||||
>
|
||||
DOWN
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="px-2 py-0.5 bg-stone-900 text-white font-mono text-[9px] hover:bg-stone-800 transition-colors ml-auto"
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(rig.id); }}
|
||||
>
|
||||
GRAPH →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -202,12 +202,22 @@ export function RigGraph({ rigId, showDiscovered = true }: { rigId: string | nul
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full relative bg-background"
|
||||
className="w-full h-full relative"
|
||||
data-testid="graph-view"
|
||||
data-animated={shouldAnimate ? "true" : "false"}
|
||||
>
|
||||
{/* Atmospheric grid overlay */}
|
||||
<div className="absolute inset-0 bg-grid pointer-events-none z-0" />
|
||||
{/* Registration marks on canvas */}
|
||||
<div className="absolute top-4 left-4 w-3 h-3 reg-mark"><div className="reg-tl" /></div>
|
||||
<div className="absolute top-4 right-4 w-3 h-3"><div className="reg-tr" /></div>
|
||||
<div className="absolute bottom-4 left-4 w-3 h-3"><div className="reg-bl" /></div>
|
||||
<div className="absolute bottom-4 right-4 w-3 h-3"><div className="reg-br" /></div>
|
||||
|
||||
{/* Ambient rig stamp watermark */}
|
||||
{rigId && (
|
||||
<div className="stamp-watermark text-3xl left-[20%] top-[35%]">
|
||||
{rigId.slice(0, 12)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reconnecting && (
|
||||
<div className="absolute top-spacing-4 right-spacing-4 z-20">
|
||||
@@ -217,10 +227,10 @@ export function RigGraph({ rigId, showDiscovered = true }: { rigId: string | nul
|
||||
</div>
|
||||
)}
|
||||
{focusMessage && (
|
||||
<div className={`absolute top-spacing-4 left-spacing-4 z-20 px-spacing-4 py-spacing-2 text-label-md font-mono rim-light ${
|
||||
focusMessage.type === "success" ? "card-dark text-foreground-on-dark" :
|
||||
focusMessage.type === "error" ? "bg-destructive text-white" :
|
||||
"card-dark text-foreground-on-dark"
|
||||
<div className={`absolute top-spacing-4 left-spacing-4 z-20 px-spacing-4 py-spacing-2 font-mono text-[10px] border ${
|
||||
focusMessage.type === "success" ? "bg-white border-stone-900 text-stone-900" :
|
||||
focusMessage.type === "error" ? "bg-tertiary/10 border-tertiary text-tertiary" :
|
||||
"bg-white border-stone-300 text-stone-600"
|
||||
}`}>
|
||||
{focusMessage.text}
|
||||
</div>
|
||||
@@ -240,9 +250,12 @@ export function RigGraph({ rigId, showDiscovered = true }: { rigId: string | nul
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
|
||||
{/* Bottom-left rig ID label */}
|
||||
<div className="absolute bottom-spacing-4 left-spacing-4 z-20 text-label-sm font-mono text-foreground-muted opacity-30">
|
||||
RIG {rigId?.slice(0, 8)}
|
||||
{/* Footer status pills */}
|
||||
<div className="absolute bottom-4 left-4 z-20 flex gap-spacing-2">
|
||||
<div className="bg-white/90 border border-stone-900 px-3 py-1 font-mono text-[10px] flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
||||
TOPOLOGY_ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,40 +14,36 @@ interface RigNodeData {
|
||||
} | null;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string | null): string {
|
||||
/** Core roles get dark header stripe, workers get light */
|
||||
function isCore(role: string | null): boolean {
|
||||
return role === "architect" || role === "lead" || role === "orchestrator";
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case "running": return "bg-success";
|
||||
case "idle": return "bg-foreground-muted-on-dark";
|
||||
case "exited": return "bg-destructive";
|
||||
case "detached": return "bg-warning";
|
||||
default: return "bg-foreground-muted-on-dark";
|
||||
case "running": return "ACTIVE";
|
||||
case "idle": return "IDLE";
|
||||
case "exited": return "EXITED";
|
||||
case "detached": return "DETACHED";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusCssColor(status: string | null): string {
|
||||
switch (status) {
|
||||
case "running": return "hsl(var(--success))";
|
||||
case "idle": return "hsl(var(--foreground-muted-on-dark))";
|
||||
case "exited": return "hsl(var(--destructive))";
|
||||
case "idle": return "hsl(var(--on-surface-variant))";
|
||||
case "exited": return "hsl(var(--tertiary))";
|
||||
case "detached": return "hsl(var(--warning))";
|
||||
default: return "hsl(var(--foreground-muted-on-dark))";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTextClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case "running": return "text-success";
|
||||
case "exited": return "text-destructive";
|
||||
case "detached": return "text-warning";
|
||||
default: return "text-foreground-muted-on-dark";
|
||||
default: return "hsl(var(--on-surface-variant))";
|
||||
}
|
||||
}
|
||||
|
||||
export function RigNode({ data }: { data: RigNodeData }) {
|
||||
const statusColor = getStatusColor(data.status);
|
||||
const statusCssColor = getStatusCssColor(data.status);
|
||||
const prevStatusRef = useRef(data.status);
|
||||
const [statusChanged, setStatusChanged] = useState(false);
|
||||
const core = isCore(data.role);
|
||||
const statusCssColor = getStatusCssColor(data.status);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevStatusRef.current !== data.status && prevStatusRef.current !== null) {
|
||||
@@ -60,63 +56,69 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
}, [data.status]);
|
||||
|
||||
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" \u00B7 ");
|
||||
const sessionName = data.binding?.tmuxSession;
|
||||
const isRunning = data.status === "running";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card-dark min-w-[200px] cursor-pointer transition-all duration-150 ease-tactical hover:bg-surface-mid"
|
||||
className="bg-white border border-stone-900 min-w-[200px] hard-shadow"
|
||||
data-testid="rig-node"
|
||||
>
|
||||
<Handle type="target" position={Position.Top} />
|
||||
|
||||
<div className="p-spacing-3">
|
||||
{/* Header: status dot + uppercase label */}
|
||||
<div className="flex items-center gap-spacing-2 mb-spacing-2">
|
||||
<span
|
||||
data-testid={`status-dot-${data.logicalId}`}
|
||||
className={`inline-block w-2 h-2 ${statusColor} ${statusChanged ? "status-changed" : ""} ${isRunning ? "status-dot-running" : ""}`}
|
||||
style={{ "--status-color": statusCssColor } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-label-lg uppercase tracking-[0.03em] text-foreground-on-dark">
|
||||
{/* Header stripe — dark for core, light for workers */}
|
||||
<div className={`px-3 py-1 font-mono text-[10px] flex justify-between items-center ${
|
||||
core
|
||||
? "bg-stone-900 text-white"
|
||||
: "bg-stone-200 text-stone-900 border-b border-stone-900"
|
||||
}`}>
|
||||
<span>NODE_TYPE: {(data.role ?? "AGENT").toUpperCase()}</span>
|
||||
<span className="material-symbols-outlined text-[12px]" style={{ fontVariationSettings: "'FILL' 0, 'wght' 300" }}>
|
||||
{core ? "settings" : "link"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Name + status badge */}
|
||||
<div className="flex justify-between items-end border-b border-stone-100 pb-2">
|
||||
<span className="font-headline font-bold text-sm tracking-tight uppercase">
|
||||
{data.logicalId}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 border font-mono text-[8px] uppercase ${
|
||||
data.status === "running"
|
||||
? "border-stone-900 text-stone-900"
|
||||
: data.status === "exited"
|
||||
? "border-tertiary text-tertiary"
|
||||
: "border-stone-400 text-stone-400"
|
||||
} ${statusChanged ? "status-changed" : ""}`}
|
||||
style={{ "--status-color": statusCssColor } as React.CSSProperties}
|
||||
data-testid={`status-dot-${data.logicalId}`}
|
||||
>
|
||||
{getStatusLabel(data.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Runtime + model */}
|
||||
{/* Runtime info */}
|
||||
{runtimeModel && (
|
||||
<div className="text-label-md text-foreground-muted-on-dark mb-spacing-1 pl-spacing-4">
|
||||
{runtimeModel}
|
||||
<div className="flex justify-between font-mono text-[9px] text-secondary">
|
||||
<span>RUNTIME</span>
|
||||
<span>{runtimeModel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session name */}
|
||||
{sessionName && (
|
||||
<div className="text-label-sm font-mono text-foreground-muted-on-dark/60 mb-spacing-2 pl-spacing-4">
|
||||
{sessionName}
|
||||
{/* Alert state for exited/detached */}
|
||||
{(data.status === "exited" || data.status === "detached") && (
|
||||
<div className="stamp-badge">
|
||||
<div className="w-2 h-2 rounded-full bg-tertiary" />
|
||||
<span>{getStatusLabel(data.status)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telemetry block */}
|
||||
<div className="inset-dark p-spacing-2 mt-spacing-2">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-spacing-3 gap-y-spacing-1 text-label-sm">
|
||||
<span className="text-foreground-muted-on-dark/60 uppercase tracking-[0.06em]">STATUS</span>
|
||||
<span className={`font-mono ${getStatusTextClass(data.status)}`}>
|
||||
{data.status ?? "unknown"}
|
||||
</span>
|
||||
<span className="text-foreground-muted-on-dark/60 uppercase tracking-[0.06em]">BOUND</span>
|
||||
<span className="font-mono text-foreground-muted-on-dark">
|
||||
{data.binding ? `tmux:${data.binding.tmuxSession ?? "\u2014"}` : "unbound"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Package badge — non-interactive until name->ID resolution exists */}
|
||||
{/* Package badge */}
|
||||
{data.packageRefs && data.packageRefs.length > 0 && (
|
||||
<div
|
||||
data-testid="package-badge"
|
||||
title={data.packageRefs.join(", ")}
|
||||
className="mt-spacing-2 px-spacing-2 py-spacing-1 bg-primary/12 text-label-sm text-primary font-mono"
|
||||
className="font-mono text-[8px] text-stone-400"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "RIGS" },
|
||||
{ to: "/packages", label: "PACKAGES" },
|
||||
{ to: "/bootstrap", label: "BOOTSTRAP" },
|
||||
{ to: "/discovery", label: "DISCOVERY" },
|
||||
{ to: "/import", label: "IMPORT" },
|
||||
{ to: "/", label: "RIGS", icon: "hub" },
|
||||
{ to: "/packages", label: "PKGS", icon: "folder" },
|
||||
{ to: "/bootstrap", label: "BOOT", icon: "play_arrow" },
|
||||
{ to: "/discovery", label: "DISC", icon: "radar" },
|
||||
{ to: "/import", label: "IMPORT", icon: "upload" },
|
||||
] as const;
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -19,23 +19,24 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const currentPath = routerState.location.pathname;
|
||||
|
||||
return (
|
||||
<nav
|
||||
<aside
|
||||
data-testid="sidebar"
|
||||
className={cn(
|
||||
"w-[200px] bg-surface-dark bg-noise-dark flex flex-col shrink-0 z-20",
|
||||
"text-foreground-on-dark",
|
||||
"w-20 bg-stone-100/80 backdrop-blur-md border-r border-stone-300 flex flex-col items-center py-spacing-6 shrink-0 z-20",
|
||||
// Mobile: slide-in overlay
|
||||
"fixed top-12 bottom-0 left-0 transition-transform duration-200 ease-tactical lg:relative lg:top-0 lg:translate-x-0",
|
||||
"fixed top-14 bottom-0 left-0 transition-transform duration-200 ease-tactical lg:relative lg:top-0 lg:translate-x-0",
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col pt-spacing-6 flex-1">
|
||||
<div className="px-spacing-4 mb-spacing-4">
|
||||
<span className="text-label-sm uppercase tracking-[0.08em] text-foreground-muted-on-dark">
|
||||
NAVIGATION
|
||||
</span>
|
||||
{/* Brand mark */}
|
||||
<div className="mb-spacing-6 text-center">
|
||||
<div className="text-stone-900 font-bold font-mono text-[10px] tracking-widest uppercase">
|
||||
TOPOLOGY
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex flex-col w-full gap-spacing-1 flex-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = item.to === "/"
|
||||
? currentPath === "/" || currentPath.startsWith("/rigs")
|
||||
@@ -49,20 +50,20 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"px-spacing-4 py-spacing-3 text-label-lg uppercase tracking-[0.03em] transition-colors duration-150 ease-tactical relative",
|
||||
"flex flex-col items-center py-spacing-3 gap-1 transition-all duration-75",
|
||||
isActive
|
||||
? "text-foreground-on-dark bg-white/8"
|
||||
: "text-foreground-muted-on-dark hover:text-foreground-on-dark hover:bg-white/4"
|
||||
? "bg-background text-stone-950 border-y border-l-4 border-l-stone-900"
|
||||
: "text-stone-400 hover:text-stone-900"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-foreground-on-dark" />
|
||||
)}
|
||||
{item.label}
|
||||
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'FILL' 0, 'wght' 300" }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="font-mono text-[8px] tracking-widest">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useSnapshots } from "../hooks/useSnapshots.js";
|
||||
import { useCreateSnapshot, useRestoreSnapshot } from "../hooks/mutations.js";
|
||||
import { usePsEntries } from "../hooks/usePsEntries.js";
|
||||
import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -37,14 +35,19 @@ function formatAge(timestamp: string): string {
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
function getRestoreStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "launched": return "text-success";
|
||||
case "skipped": return "text-stone-400";
|
||||
case "failed": return "text-tertiary";
|
||||
default: return "text-stone-400";
|
||||
}
|
||||
}
|
||||
|
||||
export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
const { data: snapshots = [], isPending: loading, error: fetchError } = useSnapshots(rigId);
|
||||
const { data: psEntriesData } = usePsEntries();
|
||||
const createSnapshot = useCreateSnapshot(rigId);
|
||||
const restoreSnapshot = useRestoreSnapshot(rigId);
|
||||
const psEntries = Array.isArray(psEntriesData) ? psEntriesData : [];
|
||||
const rigPsEntry = psEntries.find((entry) => entry.rigId === rigId);
|
||||
const restoreBlocked = rigPsEntry?.status === "running" || rigPsEntry?.status === "partial";
|
||||
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||
const [restoreResult, setRestoreResult] = useState<RestoreNodeResult[] | null>(null);
|
||||
@@ -73,12 +76,28 @@ export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="snapshot-panel" className="card-dark bg-noise-dark p-spacing-4 max-h-[40vh] lg:max-h-none lg:min-w-[260px] lg:max-w-[300px] overflow-y-auto shrink-0">
|
||||
<div
|
||||
data-testid="snapshot-panel"
|
||||
className="vellum-heavy border-l-2 border-stone-900 shadow-[-10px_0_30px_rgba(46,52,46,0.05)] p-spacing-6 lg:min-w-[280px] lg:max-w-[320px] overflow-y-auto relative"
|
||||
>
|
||||
{/* Crosshair registration marks */}
|
||||
<div className="absolute top-2 left-2 w-2 h-2 crosshair" />
|
||||
<div className="absolute top-2 right-2 w-2 h-2 crosshair" />
|
||||
<div className="absolute bottom-2 left-2 w-2 h-2 crosshair" />
|
||||
<div className="absolute bottom-2 right-2 w-2 h-2 crosshair" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-spacing-4">
|
||||
<h3 className="text-headline-md uppercase text-foreground-on-dark tracking-[0.02em]">SNAPSHOTS</h3>
|
||||
<div className="flex justify-between items-start mb-spacing-6">
|
||||
<div>
|
||||
<h3 className="font-headline text-2xl font-extrabold uppercase tracking-tighter leading-none">
|
||||
SNAPSHOTS
|
||||
</h3>
|
||||
<div className="font-mono text-[9px] text-stone-500 mt-1">
|
||||
{snapshots.length} capture{snapshots.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-spacing-3 py-spacing-1 text-label-md uppercase tracking-[0.04em] text-foreground-on-dark bg-white/10 border border-white/15 hover:bg-white/20 transition-all disabled:opacity-50"
|
||||
className="px-2 py-0.5 border border-stone-900 font-headline font-bold text-[9px] uppercase tracking-widest bg-white/50 hover:bg-stone-900 hover:text-white transition-all"
|
||||
onClick={handleCreate}
|
||||
disabled={createSnapshot.isPending}
|
||||
>
|
||||
@@ -88,26 +107,20 @@ export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
|
||||
{/* Error */}
|
||||
{(error ?? fetchError?.message) && (
|
||||
<div data-testid="restore-error" className="mb-spacing-3 p-spacing-3 bg-destructive/20 text-destructive text-label-md">
|
||||
<div data-testid="restore-error" className="stamp-badge mb-spacing-3 w-full">
|
||||
{error ?? fetchError?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restoreBlocked && (
|
||||
<div data-testid="restore-blocked-message" className="mb-spacing-3 text-label-sm text-foreground-muted-on-dark/70">
|
||||
Stop the rig before restoring a snapshot.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore result */}
|
||||
{restoreResult && (
|
||||
<div data-testid="restore-result" className="mb-spacing-4 p-spacing-3 inset-dark">
|
||||
<div className="text-label-sm uppercase text-foreground-muted-on-dark/60 tracking-[0.06em] mb-spacing-2">RESTORE COMPLETE</div>
|
||||
<div data-testid="restore-result" className="mb-spacing-4 p-spacing-3 bg-surface-low">
|
||||
<div className="font-mono text-[9px] text-stone-400 uppercase mb-spacing-2">RESTORE COMPLETE</div>
|
||||
<div className="space-y-spacing-1">
|
||||
{restoreResult.map((n) => (
|
||||
<div key={n.nodeId} className="flex items-center justify-between text-label-md">
|
||||
<span className="font-mono text-foreground-on-dark">{n.logicalId}</span>
|
||||
<span className={`font-mono ${getRestoreStatusColorClass(n.status)}`} data-testid={`restore-status-${n.logicalId}`}>
|
||||
<div key={n.nodeId} className="flex items-center justify-between font-mono text-[10px]">
|
||||
<span>{n.logicalId}</span>
|
||||
<span className={getRestoreStatusColor(n.status)} data-testid={`restore-status-${n.logicalId}`}>
|
||||
{n.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -118,7 +131,7 @@ export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
|
||||
{/* Restore loading */}
|
||||
{restoreSnapshot.isPending && (
|
||||
<div data-testid="restore-loading" className="text-label-md text-foreground-muted-on-dark mb-spacing-3">
|
||||
<div data-testid="restore-loading" className="font-mono text-[10px] text-stone-400 mb-spacing-3">
|
||||
Restoring...
|
||||
</div>
|
||||
)}
|
||||
@@ -127,33 +140,32 @@ export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
{loading ? (
|
||||
<div data-testid="snapshot-loading" className="space-y-spacing-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="inset-dark p-spacing-3">
|
||||
<div className="h-4 w-32 shimmer-dark mb-spacing-2" />
|
||||
<div className="h-3 w-48 shimmer-dark" />
|
||||
<div key={i} className="bg-surface-low p-spacing-3">
|
||||
<div className="h-4 w-32 shimmer mb-spacing-2" />
|
||||
<div className="h-3 w-48 shimmer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
<div className="text-label-md text-foreground-muted-on-dark/50 py-spacing-4 text-center">
|
||||
<div className="font-mono text-[10px] text-stone-400 py-spacing-4 text-center italic">
|
||||
No snapshots yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-spacing-2">
|
||||
{snapshots.map((snap) => (
|
||||
<div key={snap.id} className="inset-dark p-spacing-3 transition-colors duration-150 hover:bg-white/5">
|
||||
<div key={snap.id} className="bg-surface-low p-spacing-3 hover:bg-surface-mid transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-mono text-label-md text-foreground-on-dark" data-testid={`snap-id-${snap.id}`}>
|
||||
<div className="font-mono text-[10px] font-bold" data-testid={`snap-id-${snap.id}`}>
|
||||
{snap.id.slice(0, 12)}
|
||||
</div>
|
||||
<div className="text-label-sm text-foreground-muted-on-dark mt-spacing-1">
|
||||
<div className="font-mono text-[9px] text-stone-400 mt-0.5">
|
||||
{snap.kind} · {formatAge(snap.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-spacing-3 py-spacing-1 text-label-md uppercase tracking-[0.04em] text-foreground-muted-on-dark bg-white/6 border border-white/10 hover:bg-white/12 hover:text-foreground-on-dark transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white/6 disabled:hover:text-foreground-muted-on-dark"
|
||||
className="font-mono text-[9px] font-bold border-b border-stone-900 hover:bg-stone-900 hover:text-white transition-all px-1"
|
||||
data-testid={`restore-btn-${snap.id}`}
|
||||
disabled={restoreBlocked || restoreSnapshot.isPending}
|
||||
onClick={() => setConfirmRestore(snap.id)}
|
||||
>
|
||||
RESTORE
|
||||
@@ -168,9 +180,9 @@ export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
<Dialog open={confirmRestore !== null} onOpenChange={(open) => { if (!open) setConfirmRestore(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-headline-md">Restore Snapshot</DialogTitle>
|
||||
<DialogDescription className="text-body-sm text-foreground-muted">
|
||||
This will restore the rig from snapshot {confirmRestore?.slice(0, 12)} after any running sessions have been stopped.
|
||||
<DialogTitle className="font-headline text-xl font-extrabold uppercase tracking-tighter">Restore Snapshot</DialogTitle>
|
||||
<DialogDescription className="text-body-sm text-stone-500">
|
||||
This will restore the rig from snapshot {confirmRestore?.slice(0, 12)}. Existing sessions will be restarted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -184,7 +196,6 @@ export function SnapshotPanel({ rigId }: SnapshotPanelProps) {
|
||||
<Button
|
||||
variant="default"
|
||||
data-testid={confirmRestore ? `confirm-restore-${confirmRestore}` : undefined}
|
||||
disabled={restoreBlocked || restoreSnapshot.isPending}
|
||||
onClick={() => confirmRestore && handleRestore(confirmRestore)}
|
||||
>
|
||||
Confirm Restore
|
||||
|
||||
@@ -70,46 +70,41 @@ export function StatusBar({ onToggleFeed, feedOpen, eventCount }: StatusBarProps
|
||||
return (
|
||||
<footer
|
||||
data-testid="status-bar"
|
||||
className="h-7 bg-surface-dark flex items-center px-spacing-4 gap-spacing-4 text-label-sm font-grotesk text-foreground-muted-on-dark shrink-0"
|
||||
className="h-10 flex items-center px-spacing-4 gap-spacing-3 border-t border-stone-300 bg-background shrink-0"
|
||||
>
|
||||
<span className="flex items-center gap-spacing-1">
|
||||
<span
|
||||
{/* Health pill */}
|
||||
<div className="bg-white/90 border border-stone-900 px-3 py-0.5 font-mono text-[10px] flex items-center gap-2">
|
||||
<div
|
||||
data-testid="health-dot"
|
||||
className={`inline-block w-[5px] h-[5px] ${isConnected ? "bg-success" : "bg-destructive"} ${reconnectPulse ? "status-changed" : ""}`}
|
||||
className={`w-1.5 h-1.5 rounded-full ${isConnected ? "bg-success animate-pulse" : "bg-tertiary"} ${reconnectPulse ? "status-changed" : ""}`}
|
||||
/>
|
||||
<span data-testid="health-text">
|
||||
{isConnected ? "CONNECTED" : "DISCONNECTED"}
|
||||
{isConnected ? "SYSTEM_STABLE" : "DISCONNECTED"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-foreground-muted-on-dark/30">·</span>
|
||||
{/* Rig count pill */}
|
||||
<div className="bg-white/90 border border-stone-900 px-3 py-0.5 font-mono text-[10px]" data-testid="rig-count">
|
||||
RIGS: {rigCount ?? "\u2014"}
|
||||
</div>
|
||||
|
||||
<span data-testid="rig-count">
|
||||
RIGS <span className="font-mono text-foreground-on-dark">{rigCount ?? "\u2014"}</span>
|
||||
</span>
|
||||
|
||||
<span className="text-foreground-muted-on-dark/30">·</span>
|
||||
|
||||
<span data-testid="cmux-status">
|
||||
CMUX <span className="font-mono text-foreground-on-dark">
|
||||
{cmuxAvailable === null ? "\u2014" : cmuxAvailable ? "OK" : "UNAVAILABLE"}
|
||||
</span>
|
||||
</span>
|
||||
{/* CMUX pill */}
|
||||
<div className="bg-white/90 border border-stone-300 px-3 py-0.5 font-mono text-[10px] text-stone-500" data-testid="cmux-status">
|
||||
CMUX: {cmuxAvailable === null ? "\u2014" : cmuxAvailable ? "OK" : "UNAVAIL"}
|
||||
</div>
|
||||
|
||||
{/* Activity feed toggle — right aligned */}
|
||||
{onToggleFeed && (
|
||||
<>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
data-testid="feed-toggle"
|
||||
onClick={onToggleFeed}
|
||||
className="flex items-center gap-spacing-1 text-label-sm text-foreground-muted-on-dark hover:text-foreground-on-dark transition-colors duration-150 ease-tactical"
|
||||
className="bg-white/90 border border-stone-900 px-3 py-0.5 font-mono text-[10px] hover:bg-stone-900 hover:text-white transition-all"
|
||||
aria-label="Toggle activity feed"
|
||||
aria-expanded={feedOpen}
|
||||
>
|
||||
ACTIVITY
|
||||
{(eventCount ?? 0) > 0 && (
|
||||
<span className="font-mono text-foreground-on-dark">{eventCount}</span>
|
||||
)}
|
||||
ACTIVITY{(eventCount ?? 0) > 0 ? ` ${eventCount}` : ""}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full p-4 bg-foreground/5 border-l-2 border-l-foreground/20 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"relative w-full p-4 bg-surface-low border-l-2 border-l-stone-900 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-on-surface",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -9,17 +9,17 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/85",
|
||||
default: "bg-stone-900 text-white font-headline font-bold uppercase tracking-widest hover:bg-stone-800",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
"bg-tertiary text-white font-bold hover:bg-tertiary/90",
|
||||
outline:
|
||||
"border border-ghost-border bg-background hover:bg-foreground/5 hover:text-foreground",
|
||||
"border border-outline bg-transparent hover:bg-stone-100 text-on-surface",
|
||||
secondary:
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15",
|
||||
ghost: "text-foreground-muted hover:bg-foreground/5 hover:text-foreground",
|
||||
link: "text-accent underline-offset-4 hover:underline",
|
||||
"border border-stone-900 bg-transparent text-stone-900 font-mono text-[10px] uppercase hover:bg-stone-900 hover:text-white",
|
||||
ghost: "text-on-surface-variant hover:bg-stone-100 hover:text-on-surface",
|
||||
link: "text-secondary underline-offset-4 hover:underline",
|
||||
tactical:
|
||||
"bg-foreground/8 text-foreground uppercase text-label-md tracking-[0.04em] hover:bg-foreground/15 transition-all border border-foreground/10",
|
||||
"border border-outline font-mono text-[10px] text-secondary uppercase tracking-widest hover:border-stone-900 hover:text-stone-900",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-card text-card-foreground transition-all duration-150 ease-tactical",
|
||||
"bg-white border border-stone-900 hard-shadow text-on-surface transition-all duration-150 ease-tactical",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[12px]",
|
||||
"fixed inset-0 z-50 bg-black/20 backdrop-blur-[8px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-background p-6 shadow-xl",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 vellum-heavy p-6 shadow-[0_20px_40px_rgba(46,52,46,0.06)] border border-stone-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("bg-foreground/5 [&_tr]:border-b [&_tr]:border-ghost-border", className)} {...props} />
|
||||
<thead ref={ref} className={cn("bg-surface-low [&_tr]:border-b [&_tr]:border-stone-300", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-ghost-border transition-colors duration-150 hover:bg-foreground/3 data-[state=selected]:bg-foreground/5 odd:bg-foreground/2",
|
||||
"border-b border-stone-200 transition-colors duration-150 hover:bg-surface-low data-[state=selected]:bg-surface-mid odd:bg-surface-low/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full bg-white/50 border border-foreground/10 px-4 py-3 text-base text-foreground placeholder:text-foreground-muted/50 transition-all duration-150 focus-visible:outline-none focus-visible:border-foreground/30 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[80px] w-full bg-white border-b border-outline px-3 py-2 text-base text-on-surface font-mono placeholder:text-stone-400 transition-all duration-150 focus-visible:outline-none focus-visible:border-b-secondary disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
+163
-153
@@ -4,160 +4,177 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Anduril-inspired Light Theme */
|
||||
/* Reference: anduril.com/lattice/command-and-control */
|
||||
/* Vellum Paper — "The Tactical Dossier" */
|
||||
/* Reference: docs/design/design-v2-vellum-paper/code.md */
|
||||
|
||||
/* Canvas — warm Anduril beige */
|
||||
--background: 50 5% 84%; /* #d8d8d4 — the signature Anduril beige */
|
||||
--background-warm: 45 6% 88%; /* #e3e1dc — slightly lighter warm tone */
|
||||
/* Paper surfaces */
|
||||
--background: 47 20% 97%; /* #faf9f5 — base paper */
|
||||
--surface-container-lowest: 0 0% 100%; /* #ffffff — card bodies */
|
||||
--surface-container-low: 80 10% 95%; /* #f3f4ef — secondary panels */
|
||||
--surface-container: 80 10% 92%; /* #ecefe8 — mid containers */
|
||||
--surface-container-high: 100 10% 89%; /* #e5e9e2 — active workspaces */
|
||||
--surface-container-highest: 100 10% 87%; /* #dfe4dc — highest elevation */
|
||||
|
||||
/* Dark surfaces — cards, header, sidebar, panels */
|
||||
--surface-dark: 0 0% 2%; /* #050505 — near-black, Anduril card color */
|
||||
--surface-mid: 0 0% 8%; /* #141414 — slightly lighter dark */
|
||||
--surface-raised: 0 0% 14%; /* #242424 — elevated dark elements */
|
||||
/* Ink */
|
||||
--on-surface: 120 8% 19%; /* #2e342e — primary text */
|
||||
--on-surface-variant: 108 5% 36%; /* #5b615a — secondary text */
|
||||
|
||||
/* Foreground — text colors */
|
||||
--foreground: 0 0% 2%; /* #050505 — black text on light bg */
|
||||
--foreground-muted: 0 0% 40%; /* #666666 — secondary text on light bg */
|
||||
--foreground-on-dark: 50 5% 84%; /* #d8d8d4 — light text on dark bg */
|
||||
--foreground-muted-on-dark: 0 0% 55%; /* #8c8c8c — muted text on dark bg */
|
||||
/* Technical annotations */
|
||||
--secondary: 213 15% 39%; /* #546073 — lines, crosshairs, edge connectors */
|
||||
--secondary-container: 220 39% 91%; /* #d8e3fa */
|
||||
|
||||
/* Accent */
|
||||
--primary: 0 0% 2%; /* Black — primary actions on light bg */
|
||||
--primary-foreground: 50 5% 84%; /* Beige — text on primary buttons */
|
||||
/* Stamped alerts */
|
||||
--tertiary: 1 63% 42%; /* #b12d2a — red ink stamps, critical */
|
||||
--error: 0 43% 44%; /* #9f403d */
|
||||
|
||||
/* Semantic */
|
||||
--destructive: 0 72% 51%; /* #de3b2f — red that works on both */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--warning: 40 90% 50%; /* #f0a500 — amber */
|
||||
--accent: 207 82% 40%; /* #1272b8 — blue links/info */
|
||||
--success: 145 63% 35%; /* #21955a — green for running/ok */
|
||||
/* Interactive */
|
||||
--primary: 0 1% 37%; /* #5f5e5e — primary actions */
|
||||
--inverse-surface: 120 10% 4%; /* #0d0f0d — dark fills, header stripes */
|
||||
|
||||
/* Borders */
|
||||
--ghost-border: rgba(0, 0, 0, 0.1);
|
||||
--ghost-border-dark: rgba(255, 255, 255, 0.08);
|
||||
--outline: 108 4% 47%; /* #767c75 */
|
||||
--outline-variant: 108 4% 68%; /* #aeb4ac — ghost borders at 15% */
|
||||
|
||||
/* Success */
|
||||
--success: 145 63% 35%; /* #21955a */
|
||||
|
||||
/* shadcn compatibility */
|
||||
--border: var(--ghost-border);
|
||||
--input: 0 0% 2%;
|
||||
--ring: 0 0% 2%;
|
||||
--foreground: var(--on-surface);
|
||||
--foreground-muted: var(--on-surface-variant);
|
||||
--border: var(--outline-variant);
|
||||
--input: var(--outline);
|
||||
--ring: var(--secondary);
|
||||
--radius: 0px;
|
||||
|
||||
--card: 0 0% 2%;
|
||||
--card-foreground: 50 5% 84%;
|
||||
--card: var(--surface-container-lowest);
|
||||
--card-foreground: var(--on-surface);
|
||||
--popover: var(--surface-container-lowest);
|
||||
--popover-foreground: var(--on-surface);
|
||||
--muted: var(--surface-container-low);
|
||||
--muted-foreground: var(--on-surface-variant);
|
||||
|
||||
--popover: 0 0% 8%;
|
||||
--popover-foreground: 50 5% 84%;
|
||||
|
||||
--muted: 50 3% 78%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
|
||||
--secondary: 0 0% 14%;
|
||||
--secondary-foreground: 50 5% 84%;
|
||||
--destructive: var(--tertiary);
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--warning: 40 90% 50%;
|
||||
--accent: var(--secondary);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--ghost-border);
|
||||
border-color: hsl(var(--outline-variant) / 0.15);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
color: hsl(var(--on-surface));
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Blueprint dot grid — the paper texture */
|
||||
background-image: radial-gradient(#d1d1cf 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--foreground) / 0.15);
|
||||
color: hsl(var(--foreground));
|
||||
background-color: hsl(var(--secondary) / 0.2);
|
||||
color: hsl(var(--on-surface));
|
||||
}
|
||||
}
|
||||
|
||||
/* Textures — Anduril dithered grain on warm surfaces */
|
||||
/* === REGISTRATION MARKS === */
|
||||
/* 8px L-shaped crosshairs at container corners, in secondary blue */
|
||||
|
||||
@layer components {
|
||||
/* Dot grid on warm beige canvas */
|
||||
.bg-grid {
|
||||
background-image: radial-gradient(circle, hsl(var(--foreground) / 0.12) 0.5px, transparent 0.5px);
|
||||
background-size: 24px 24px;
|
||||
.reg-mark { position: relative; }
|
||||
.reg-mark::before,
|
||||
.reg-mark::after,
|
||||
.reg-mark > .reg-tl::before,
|
||||
.reg-mark > .reg-tl::after { content: ''; position: absolute; background-color: #546073; }
|
||||
|
||||
.reg-tl { position: absolute; top: 0; left: 0; width: 0; height: 0; }
|
||||
.reg-tl::before { top: 0; left: 0; width: 1px; height: 8px; }
|
||||
.reg-tl::after { top: 0; left: 0; width: 8px; height: 1px; }
|
||||
|
||||
.reg-tr { position: absolute; top: 0; right: 0; width: 0; height: 0; }
|
||||
.reg-tr::before { content: ''; position: absolute; top: 0; right: 0; width: 1px; height: 8px; background-color: #546073; }
|
||||
.reg-tr::after { content: ''; position: absolute; top: 0; right: 0; width: 8px; height: 1px; background-color: #546073; }
|
||||
|
||||
.reg-bl { position: absolute; bottom: 0; left: 0; width: 0; height: 0; }
|
||||
.reg-bl::before { content: ''; position: absolute; bottom: 0; left: 0; width: 1px; height: 8px; background-color: #546073; }
|
||||
.reg-bl::after { content: ''; position: absolute; bottom: 0; left: 0; width: 8px; height: 1px; background-color: #546073; }
|
||||
|
||||
.reg-br { position: absolute; bottom: 0; right: 0; width: 0; height: 0; }
|
||||
.reg-br::before { content: ''; position: absolute; bottom: 0; right: 0; width: 1px; height: 8px; background-color: #546073; }
|
||||
.reg-br::after { content: ''; position: absolute; bottom: 0; right: 0; width: 8px; height: 1px; background-color: #546073; }
|
||||
}
|
||||
|
||||
/* === VELLUM OVERLAYS === */
|
||||
|
||||
@layer components {
|
||||
.vellum {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.vellum-heavy {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Dithered grain — the Anduril signature */
|
||||
.bg-dither {
|
||||
position: relative;
|
||||
/* Crosshair marks inside vellum sheets */
|
||||
.crosshair { position: relative; }
|
||||
.crosshair::before {
|
||||
content: ''; position: absolute;
|
||||
top: 50%; left: -5px; right: -5px;
|
||||
height: 0.5px; background: #546073; opacity: 0.5;
|
||||
}
|
||||
.bg-dither::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Noise on dark surfaces */
|
||||
.bg-noise-dark {
|
||||
position: relative;
|
||||
}
|
||||
.bg-noise-dark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.4'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Dark card — the primary container on light bg */
|
||||
.card-dark {
|
||||
background-color: hsl(var(--surface-dark));
|
||||
color: hsl(var(--foreground-on-dark));
|
||||
box-shadow: 0 2px 8px -2px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
|
||||
/* Telemetry inset on dark cards */
|
||||
.inset-dark {
|
||||
background-color: hsl(0 0% 0% / 0.3);
|
||||
}
|
||||
|
||||
/* Telemetry inset on light surfaces */
|
||||
.inset-light {
|
||||
background-color: hsl(var(--foreground) / 0.04);
|
||||
border: 1px solid hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
/* Shimmer for loading states */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--foreground) / 0.04) 25%,
|
||||
hsl(var(--foreground) / 0.08) 50%,
|
||||
hsl(var(--foreground) / 0.04) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
.shimmer-dark {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(0 0% 100% / 0.02) 25%,
|
||||
hsl(0 0% 100% / 0.06) 50%,
|
||||
hsl(0 0% 100% / 0.02) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
.crosshair::after {
|
||||
content: ''; position: absolute;
|
||||
left: 50%; top: -5px; bottom: -5px;
|
||||
width: 0.5px; background: #546073; opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion */
|
||||
/* === HARD SHADOWS === */
|
||||
|
||||
@layer components {
|
||||
.hard-shadow { box-shadow: 2px 2px 0px #2e342e; }
|
||||
.hard-shadow-light { box-shadow: 4px 4px 0px #e5e9e2; }
|
||||
.hard-shadow-hover:hover { box-shadow: 1px 1px 0px #2e342e; translate: 1px 1px; }
|
||||
}
|
||||
|
||||
/* === STAMPS === */
|
||||
|
||||
@layer components {
|
||||
.stamp-watermark {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
border: 4px solid hsl(var(--tertiary));
|
||||
color: hsl(var(--tertiary));
|
||||
font-family: 'Space Grotesk Variable', 'Space Grotesk', sans-serif;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
transform: rotate(-12deg);
|
||||
opacity: 0.04;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
.stamp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid hsl(var(--tertiary) / 0.3);
|
||||
background: hsl(var(--tertiary) / 0.08);
|
||||
color: hsl(var(--tertiary));
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-family: 'JetBrains Mono Variable', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* === MOTION === */
|
||||
|
||||
@layer components {
|
||||
@keyframes status-ping {
|
||||
@@ -165,25 +182,13 @@
|
||||
70% { box-shadow: 0 0 0 6px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
.status-changed {
|
||||
animation: status-ping 600ms ease-out;
|
||||
}
|
||||
.status-changed { animation: status-ping 600ms ease-out; }
|
||||
|
||||
@keyframes panel-switch {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.route-enter {
|
||||
animation: panel-switch 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.animate-pulse-tactical {
|
||||
animation: loading-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.route-enter { animation: panel-switch 150ms ease-out; }
|
||||
|
||||
@keyframes node-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
@@ -203,52 +208,57 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes ambient-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.status-dot-running {
|
||||
animation: ambient-pulse 3s ease-in-out infinite;
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, #ecefe8 25%, #dfe4dc 50%, #ecefe8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-tactical {
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* React Flow — styled for light canvas with dark nodes */
|
||||
/* === REACT FLOW === */
|
||||
|
||||
@layer components {
|
||||
.react-flow__controls {
|
||||
border: none !important;
|
||||
box-shadow: 0 1px 4px hsl(0 0% 0% / 0.15) !important;
|
||||
border: 1px solid hsl(var(--outline-variant) / 0.3) !important;
|
||||
box-shadow: 2px 2px 0px hsl(var(--on-surface) / 0.08) !important;
|
||||
background: hsl(var(--background)) !important;
|
||||
}
|
||||
.react-flow__controls-button {
|
||||
background-color: hsl(var(--background)) !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid hsl(var(--foreground) / 0.08) !important;
|
||||
fill: hsl(var(--foreground-muted)) !important;
|
||||
border-bottom: 1px solid hsl(var(--outline-variant) / 0.15) !important;
|
||||
fill: hsl(var(--on-surface-variant)) !important;
|
||||
}
|
||||
.react-flow__controls-button:hover {
|
||||
background-color: hsl(var(--foreground) / 0.05) !important;
|
||||
fill: hsl(var(--foreground)) !important;
|
||||
}
|
||||
.react-flow__edge-path {
|
||||
stroke-width: 1.5;
|
||||
background-color: hsl(var(--surface-container-high)) !important;
|
||||
fill: hsl(var(--on-surface)) !important;
|
||||
}
|
||||
.react-flow__handle {
|
||||
background-color: hsl(var(--foreground) / 0.3) !important;
|
||||
border: 1px solid hsl(var(--foreground) / 0.5) !important;
|
||||
background-color: #546073 !important;
|
||||
border: none !important;
|
||||
width: 6px !important;
|
||||
height: 6px !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
/* === REDUCED MOTION === */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-changed,
|
||||
.route-enter,
|
||||
.animate-pulse-tactical,
|
||||
.node-enter,
|
||||
.edge-draw-in,
|
||||
.status-dot-running,
|
||||
.shimmer,
|
||||
.shimmer-dark {
|
||||
.animate-pulse-tactical {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
|
||||
@@ -1,45 +1,63 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { MarkerType } from "@xyflow/react";
|
||||
|
||||
export interface EdgeStyleResult {
|
||||
style: CSSProperties;
|
||||
animated: boolean;
|
||||
type: string;
|
||||
markerEnd: { type: MarkerType; color: string; width: number; height: number };
|
||||
label?: undefined;
|
||||
}
|
||||
|
||||
const EDGE_COLOR = "#546073";
|
||||
const ARROW = { type: MarkerType.ArrowClosed, color: EDGE_COLOR, width: 12, height: 12 };
|
||||
|
||||
/**
|
||||
* Maps edge kind to React Flow edge style — dark edges on light canvas.
|
||||
* Edge styles for vellum paper aesthetic.
|
||||
* All edges use secondary blue (#546073) with arrow markers.
|
||||
* Relationship type communicated via line style, not labels.
|
||||
*/
|
||||
export function getEdgeStyle(kind: string): EdgeStyleResult {
|
||||
switch (kind) {
|
||||
case "delegates_to":
|
||||
return {
|
||||
style: { stroke: "#050505", strokeWidth: 2 },
|
||||
style: { stroke: EDGE_COLOR, strokeWidth: 1.5 },
|
||||
animated: false,
|
||||
type: "smoothstep",
|
||||
markerEnd: ARROW,
|
||||
label: undefined,
|
||||
};
|
||||
case "spawned_by":
|
||||
return {
|
||||
style: { stroke: "#050505", strokeWidth: 2, strokeDasharray: "6 3" },
|
||||
style: { stroke: EDGE_COLOR, strokeWidth: 1.5, strokeDasharray: "6 3" },
|
||||
animated: false,
|
||||
type: "smoothstep",
|
||||
markerEnd: ARROW,
|
||||
label: undefined,
|
||||
};
|
||||
case "can_observe":
|
||||
return {
|
||||
style: { stroke: "#666666", strokeWidth: 1.5, strokeDasharray: "2 2" },
|
||||
style: { stroke: EDGE_COLOR, strokeWidth: 1, strokeDasharray: "4 2" },
|
||||
animated: false,
|
||||
type: "smoothstep",
|
||||
markerEnd: ARROW,
|
||||
label: undefined,
|
||||
};
|
||||
case "uses":
|
||||
return {
|
||||
style: { stroke: "#1272b8", strokeWidth: 1 },
|
||||
style: { stroke: EDGE_COLOR, strokeWidth: 1, strokeDasharray: "2 2" },
|
||||
animated: false,
|
||||
type: "smoothstep",
|
||||
markerEnd: ARROW,
|
||||
label: undefined,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
style: { stroke: "#666666", strokeWidth: 1 },
|
||||
style: { stroke: EDGE_COLOR, strokeWidth: 1 },
|
||||
animated: false,
|
||||
type: "smoothstep",
|
||||
markerEnd: ARROW,
|
||||
label: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { Config } from "tailwindcss";
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{ts,tsx}", "./index.html"],
|
||||
theme: {
|
||||
// Zero ALL border radii — hard edges everywhere
|
||||
borderRadius: {
|
||||
none: "0px",
|
||||
sm: "0px",
|
||||
@@ -13,60 +12,71 @@ const config: Config = {
|
||||
xl: "0px",
|
||||
"2xl": "0px",
|
||||
"3xl": "0px",
|
||||
full: "0px",
|
||||
full: "9999px", /* Exception: stamp circles only */
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
/* Paper surfaces */
|
||||
background: "hsl(var(--background))",
|
||||
"background-warm": "hsl(var(--background-warm))",
|
||||
"surface-lowest": "hsl(var(--surface-container-lowest))",
|
||||
"surface-low": "hsl(var(--surface-container-low))",
|
||||
"surface-mid": "hsl(var(--surface-container))",
|
||||
"surface-high": "hsl(var(--surface-container-high))",
|
||||
"surface-highest": "hsl(var(--surface-container-highest))",
|
||||
|
||||
/* Ink */
|
||||
foreground: {
|
||||
DEFAULT: "hsl(var(--foreground))",
|
||||
muted: "hsl(var(--foreground-muted))",
|
||||
"on-dark": "hsl(var(--foreground-on-dark))",
|
||||
"muted-on-dark": "hsl(var(--foreground-muted-on-dark))",
|
||||
DEFAULT: "hsl(var(--on-surface))",
|
||||
muted: "hsl(var(--on-surface-variant))",
|
||||
},
|
||||
// Keep simple aliases for shadcn compat
|
||||
"foreground-muted": "hsl(var(--foreground-muted))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
"foreground-muted": "hsl(var(--on-surface-variant))",
|
||||
"on-surface": "hsl(var(--on-surface))",
|
||||
"on-surface-variant": "hsl(var(--on-surface-variant))",
|
||||
|
||||
/* Technical */
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
container: "hsl(var(--secondary-container))",
|
||||
},
|
||||
|
||||
/* Alert red */
|
||||
tertiary: "hsl(var(--tertiary))",
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
warning: "hsl(var(--warning))",
|
||||
accent: "hsl(var(--accent))",
|
||||
error: "hsl(var(--error))",
|
||||
|
||||
/* Interactive */
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--background))",
|
||||
},
|
||||
"inverse-surface": "hsl(var(--inverse-surface))",
|
||||
|
||||
/* Borders */
|
||||
outline: {
|
||||
DEFAULT: "hsl(var(--outline))",
|
||||
variant: "hsl(var(--outline-variant))",
|
||||
},
|
||||
|
||||
/* Status */
|
||||
success: "hsl(var(--success))",
|
||||
surface: {
|
||||
dark: "hsl(var(--surface-dark))",
|
||||
mid: "hsl(var(--surface-mid))",
|
||||
raised: "hsl(var(--surface-raised))",
|
||||
},
|
||||
"ghost-border": "var(--ghost-border)",
|
||||
"ghost-border-dark": "var(--ghost-border-dark)",
|
||||
// shadcn compat
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
warning: "hsl(var(--warning))",
|
||||
accent: "hsl(var(--secondary))",
|
||||
|
||||
/* shadcn compat */
|
||||
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
|
||||
popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
|
||||
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ["Space Grotesk Variable", "Space Grotesk", "sans-serif"],
|
||||
body: ["Inter", "system-ui", "sans-serif"],
|
||||
mono: ["JetBrains Mono Variable", "JetBrains Mono", "monospace"],
|
||||
/* Legacy aliases */
|
||||
inter: ["Inter", "system-ui", "sans-serif"],
|
||||
grotesk: ["Space Grotesk Variable", "monospace"],
|
||||
mono: ["JetBrains Mono Variable", "monospace"],
|
||||
grotesk: ["Space Grotesk Variable", "Space Grotesk", "sans-serif"],
|
||||
},
|
||||
spacing: {
|
||||
"spacing-1": "4px",
|
||||
@@ -80,15 +90,15 @@ const config: Config = {
|
||||
"spacing-24": "96px",
|
||||
},
|
||||
fontSize: {
|
||||
"display-lg": ["3.5rem", { lineHeight: "1.1", fontWeight: "700", letterSpacing: "-0.02em" }],
|
||||
"headline-lg": ["2rem", { lineHeight: "1.2", fontWeight: "700", letterSpacing: "-0.01em" }],
|
||||
"headline-md": ["1.5rem", { lineHeight: "1.3", fontWeight: "700" }],
|
||||
"display-lg": ["3.5rem", { lineHeight: "1.1", fontWeight: "900", letterSpacing: "-0.02em" }],
|
||||
"headline-lg": ["2rem", { lineHeight: "1.2", fontWeight: "800", letterSpacing: "-0.01em" }],
|
||||
"headline-md": ["1.5rem", { lineHeight: "1.3", fontWeight: "800", letterSpacing: "-0.01em" }],
|
||||
"body-lg": ["1.125rem", { lineHeight: "1.5", fontWeight: "400" }],
|
||||
"body-md": ["1rem", { lineHeight: "1.5", fontWeight: "400" }],
|
||||
"body-sm": ["0.875rem", { lineHeight: "1.5", fontWeight: "400" }],
|
||||
"label-lg": ["0.875rem", { lineHeight: "1.3", fontWeight: "500", letterSpacing: "0.02em" }],
|
||||
"label-lg": ["0.875rem", { lineHeight: "1.3", fontWeight: "700", letterSpacing: "0.02em" }],
|
||||
"label-md": ["0.75rem", { lineHeight: "1.3", fontWeight: "500", letterSpacing: "0.04em" }],
|
||||
"label-sm": ["0.625rem", { lineHeight: "1.3", fontWeight: "500", letterSpacing: "0.06em" }],
|
||||
"label-sm": ["0.625rem", { lineHeight: "1.3", fontWeight: "400", letterSpacing: "0.06em" }],
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
tactical: "cubic-bezier(0.2, 0, 0, 1)",
|
||||
|
||||
Reference in new issue
Block a user