diff --git a/crates/shrimpk-viz/web/package-lock.json b/crates/shrimpk-viz/web/package-lock.json
index 0027818..f1ba21e 100644
--- a/crates/shrimpk-viz/web/package-lock.json
+++ b/crates/shrimpk-viz/web/package-lock.json
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@react-sigma/core": "^4.0.3",
+ "@sigma/utils": "^3.0.0",
+ "d3-scale-chromatic": "^3.1.0",
"graphology": "^0.25.4",
"graphology-communities-louvain": "^2.0.1",
"graphology-layout-forceatlas2": "^0.10.1",
@@ -20,6 +22,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
+ "@types/d3-scale-chromatic": "^3.1.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -1170,6 +1173,15 @@
"win32"
]
},
+ "node_modules/@sigma/utils": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@sigma/utils/-/utils-3.0.0.tgz",
+ "integrity": "sha512-JCMjj3CHpA8yh8HcWm1YBs4Zdo2s0YUR2YKZ2NUlM2Purkdd8odLuaGQ/R/guSrBz/y+tOLSaQX7hKF6fkUG1g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "sigma": ">=3.0.0-beta.29"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1487,6 +1499,13 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1625,6 +1644,40 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
diff --git a/crates/shrimpk-viz/web/package.json b/crates/shrimpk-viz/web/package.json
index 2dceefb..e2f0fb6 100644
--- a/crates/shrimpk-viz/web/package.json
+++ b/crates/shrimpk-viz/web/package.json
@@ -9,23 +9,26 @@
"preview": "vite preview"
},
"dependencies": {
+ "@react-sigma/core": "^4.0.3",
+ "@sigma/utils": "^3.0.0",
+ "d3-scale-chromatic": "^3.1.0",
+ "graphology": "^0.25.4",
+ "graphology-communities-louvain": "^2.0.1",
+ "graphology-layout-forceatlas2": "^0.10.1",
+ "lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sigma": "^3.0.0",
- "graphology": "^0.25.4",
- "graphology-layout-forceatlas2": "^0.10.1",
- "graphology-communities-louvain": "^2.0.1",
- "@react-sigma/core": "^4.0.3",
- "zustand": "^5.0.0",
- "lucide-react": "^0.475.0"
+ "zustand": "^5.0.0"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/d3-scale-chromatic": "^3.1.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
- "vite": "^6.0.0",
- "@tailwindcss/vite": "^4.0.0",
- "tailwindcss": "^4.0.0"
+ "vite": "^6.0.0"
}
}
diff --git a/crates/shrimpk-viz/web/src/App.tsx b/crates/shrimpk-viz/web/src/App.tsx
index ec17b43..2e42693 100644
--- a/crates/shrimpk-viz/web/src/App.tsx
+++ b/crates/shrimpk-viz/web/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { AppLayout } from "./layouts/AppLayout";
import { useGraphStore } from "./stores/graphStore";
import { checkHealth } from "./api/client";
+import { LoadingState, ErrorState } from "@/components/ui/StateDisplay";
export function App() {
const loadOverview = useGraphStore((s) => s.loadOverview);
@@ -35,37 +36,23 @@ export function App() {
if (checking) {
return (
-
-
-
-
- Connecting to ShrimPK daemon...
-
-
+
+
);
}
if (!daemonOnline) {
return (
-
-
-
- !
-
-
- Daemon Offline
-
-
- ShrimPK daemon is not running on localhost:11435.
-
-
+
+
+
shrimpk-daemon
-
+
Retrying automatically...
-
+
);
}
diff --git a/crates/shrimpk-viz/web/src/components/CommunityLegend.tsx b/crates/shrimpk-viz/web/src/components/CommunityLegend.tsx
new file mode 100644
index 0000000..e322baa
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/CommunityLegend.tsx
@@ -0,0 +1,50 @@
+import { useGraphStore } from "@/stores/graphStore";
+import { Panel } from "@/components/ui/Panel";
+import { communityColor } from "@/lib/categoryColors";
+
+export function CommunityLegend() {
+ const communityMap = useGraphStore((s) => s.communityMap);
+ const zoomLevel = useGraphStore((s) => s.zoomLevel);
+ const clusters = useGraphStore((s) => s.clusters);
+ const visible = zoomLevel !== "galaxy" && communityMap.size > 0;
+
+ const entries = Array.from(communityMap.entries())
+ .sort((a, b) => b[1].length - a[1].length)
+ .slice(0, 10);
+
+ /** Try to match a community's members to a cluster with a summary. */
+ function labelForCommunity(members: string[], index: number): string {
+ for (const cluster of clusters) {
+ if (!cluster.summary) continue;
+ const memberSet = new Set(members);
+ const hasOverlap = cluster.top_members.some((m) => memberSet.has(m.id));
+ if (hasOverlap) {
+ return cluster.summary.length > 20
+ ? cluster.summary.slice(0, 20) + "\u2026"
+ : cluster.summary;
+ }
+ }
+ return `Group ${String.fromCharCode(65 + index)}`;
+ }
+
+ return (
+
+ {entries.map(([id, members], index) => (
+
+
+ {labelForCommunity(members, index)} ({members.length})
+
+ ))}
+
+ );
+}
diff --git a/crates/shrimpk-viz/web/src/components/CommunityPanel.tsx b/crates/shrimpk-viz/web/src/components/CommunityPanel.tsx
index 20571de..9ba7733 100644
--- a/crates/shrimpk-viz/web/src/components/CommunityPanel.tsx
+++ b/crates/shrimpk-viz/web/src/components/CommunityPanel.tsx
@@ -1,5 +1,9 @@
-import { Network, ChevronLeft, Layers, Loader2 } from "lucide-react";
+import { Network, ChevronLeft, Layers, PanelLeftClose, Loader2 } from "lucide-react";
import { useGraphStore } from "../stores/graphStore";
+import { Panel } from "@/components/ui/Panel";
+import { Badge } from "@/components/ui/Badge";
+import { IconButton } from "@/components/ui/IconButton";
+import { SIDEBAR_WIDTH, SIDEBAR_COLLAPSED } from "@/lib/layout";
export function CommunityPanel() {
const clusters = useGraphStore((s) => s.clusters);
@@ -8,6 +12,8 @@ export function CommunityPanel() {
const drillIntoCommunity = useGraphStore((s) => s.drillIntoCommunity);
const backToGalaxy = useGraphStore((s) => s.backToGalaxy);
const loading = useGraphStore((s) => s.loading);
+ const collapsed = useGraphStore((s) => s.sidebarCollapsed);
+ const toggleSidebar = useGraphStore((s) => s.toggleSidebar);
const handleDrill = (label: string) => {
if (loading) return;
@@ -15,90 +21,118 @@ export function CommunityPanel() {
};
return (
-
- {/* Header */}
-
- {zoomLevel !== "galaxy" ? (
- { if (!loading) backToGalaxy(); }}
- disabled={loading}
- className="p-1 hover:bg-zinc-800 rounded cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
- >
-
-
- ) : (
-
- )}
-
- {zoomLevel === "galaxy"
- ? "Communities"
- : activeCommunity ?? "Cluster"}
-
- {loading && (
-
- )}
-
-
- {/* Cluster list */}
-
- {clusters.length === 0 ? (
-
- No communities found. Store some memories first.
-
- ) : (
- clusters.map((cluster) => {
- const isActive = activeCommunity === cluster.label;
- return (
+
+ {collapsed ? (
+
+
+
+ ) : (
+ <>
+ {/* Header */}
+
+ {zoomLevel !== "galaxy" ? (
handleDrill(cluster.label)}
+ onClick={() => { if (!loading) backToGalaxy(); }}
disabled={loading}
- className={[
- "w-full text-left px-4 py-3 transition-colors cursor-pointer",
- "border-b border-zinc-800/50",
- "hover:bg-zinc-800/50",
- "disabled:opacity-50 disabled:cursor-not-allowed",
- isActive
- ? "bg-indigo-500/10 border-l-2 !border-l-indigo-500"
- : "",
- ].join(" ")}
+ className="p-1 hover:bg-overlay rounded cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas"
>
-
-
- {cluster.label}
-
-
-
- {cluster.member_count}
-
-
- {cluster.summary && (
-
- {cluster.summary}
-
- )}
+
- );
- })
- )}
-
+ ) : null}
+
+ {zoomLevel === "galaxy"
+ ? "Communities"
+ : activeCommunity ?? "Cluster"}
+
+ {loading && (
+
+ )}
+
+
- {/* Back to galaxy (visible in cluster/neighborhood view) */}
- {zoomLevel !== "galaxy" && (
-
{ if (!loading) backToGalaxy(); }}
- disabled={loading}
- className="flex items-center justify-center gap-2 px-4 py-2.5 border-t border-zinc-800 text-xs text-indigo-400 hover:bg-indigo-500/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
- >
-
- Back to Galaxy
-
- )}
+ {/* Cluster list */}
+
+ {clusters.length === 0 && loading ? (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+ ) : clusters.length === 0 ? (
+
+ No communities found. Store some memories first.
+
+ ) : (
+ clusters.map((cluster) => {
+ const isActive = activeCommunity === cluster.label;
+ return (
+
handleDrill(cluster.label)}
+ disabled={loading}
+ className={[
+ "w-full text-left px-4 py-3 transition-colors duration-micro cursor-pointer",
+ "border-b border-border-subtle",
+ "hover:bg-overlay/50",
+ "disabled:opacity-50 disabled:cursor-not-allowed",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas",
+ isActive
+ ? "bg-accent/10 border-l-2 !border-l-accent"
+ : "",
+ ].join(" ")}
+ >
+
+
+ {cluster.label}
+
+
+
+
+
+
+ {cluster.summary && (
+
+ {cluster.summary}
+
+ )}
+
+ );
+ })
+ )}
+
+
+ {/* Back to galaxy (visible in cluster/neighborhood view) */}
+ {zoomLevel !== "galaxy" && (
+
{ if (!loading) backToGalaxy(); }}
+ disabled={loading}
+ className="flex items-center justify-center gap-2 px-4 py-2.5 border-t border-border text-xs text-accent hover:bg-accent/10 transition-colors duration-micro cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas"
+ >
+
+ Back to Galaxy
+
+ )}
- {/* Footer stats */}
-
- {clusters.length} communities
-
-
+ {/* Footer stats */}
+
+ {clusters.length} communities
+
+ >
+ )}
+
);
}
diff --git a/crates/shrimpk-viz/web/src/components/GraphCanvas.tsx b/crates/shrimpk-viz/web/src/components/GraphCanvas.tsx
index ecb3a75..d6e7c23 100644
--- a/crates/shrimpk-viz/web/src/components/GraphCanvas.tsx
+++ b/crates/shrimpk-viz/web/src/components/GraphCanvas.tsx
@@ -7,7 +7,18 @@ import {
} from "@react-sigma/core";
import "@react-sigma/core/lib/react-sigma.min.css";
import forceAtlas2 from "graphology-layout-forceatlas2";
+import { ZoomIn, ZoomOut, Network } from "lucide-react";
import { useGraphStore } from "../stores/graphStore";
+import { CATEGORY_HEX } from "@/lib/categoryColors";
+import { useCameraTransition } from "@/hooks/useCameraTransition";
+import { IconButton } from "@/components/ui/IconButton";
+import { EmptyState } from "@/components/ui/StateDisplay";
+import { SizeLegend } from "./SizeLegend";
+import { CommunityLegend } from "./CommunityLegend";
+
+/** Named constants for WebGL reducer colors (hex required, not CSS vars) */
+const DIMMED_NODE_COLOR = "#27272a";
+const DIMMED_EDGE_COLOR = "#1c1c1e";
/** Inner component that wires sigma events + loads graph data. */
function GraphEvents() {
@@ -21,6 +32,8 @@ function GraphEvents() {
const drillIntoCommunity = useGraphStore((s) => s.drillIntoCommunity);
const setHoveredNode = useGraphStore((s) => s.setHoveredNode);
const hoveredNode = useGraphStore((s) => s.hoveredNode);
+ const selectedNode = useGraphStore((s) => s.selectedNode);
+ const { animateToNode } = useCameraTransition();
// Keep refs to current zoom level and store graph so event handlers
// always read the latest values without needing to re-register.
@@ -59,6 +72,13 @@ function GraphEvents() {
});
}, [registerEvents, sigma, selectNode, expandNode, drillIntoCommunity, setHoveredNode]);
+ // Animate camera to center on the selected node.
+ useEffect(() => {
+ if (selectedNode) {
+ animateToNode(selectedNode);
+ }
+ }, [selectedNode, animateToNode]);
+
// Dim non-neighbor nodes on hover using sigma's nodeReducer, which
// applies a visual override without mutating the graph data.
useEffect(() => {
@@ -67,7 +87,7 @@ function GraphEvents() {
const sigGraph = sigma.getGraph();
const isNeighbor =
node === hoveredNode || sigGraph.areNeighbors(node, hoveredNode);
- return isNeighbor ? attrs : { ...attrs, color: "#27272a" };
+ return isNeighbor ? attrs : { ...attrs, color: DIMMED_NODE_COLOR };
});
sigma.setSetting("edgeReducer", (edge, attrs) => {
if (!hoveredNode) return attrs;
@@ -75,7 +95,7 @@ function GraphEvents() {
const src = sigGraph.source(edge);
const tgt = sigGraph.target(edge);
const connected = src === hoveredNode || tgt === hoveredNode;
- return connected ? attrs : { ...attrs, color: "#1c1c1e" };
+ return connected ? attrs : { ...attrs, color: DIMMED_EDGE_COLOR };
});
}, [hoveredNode, sigma]);
@@ -86,6 +106,7 @@ function GraphEvents() {
function LayoutRunner() {
const sigma = useSigma();
const graph = useGraphStore((s) => s.graph);
+ const { animateToNodes } = useCameraTransition();
const animFrame = useRef
();
const iterRef = useRef(0);
@@ -108,6 +129,8 @@ function LayoutRunner() {
const step = () => {
if (iterRef.current >= totalIterations) {
animFrame.current = undefined;
+ // Fit camera to all nodes after layout stabilizes
+ animateToNodes(sigGraph.nodes());
return;
}
@@ -132,18 +155,50 @@ function LayoutRunner() {
animFrame.current = requestAnimationFrame(step);
return stopLayout;
- }, [graph, sigma, stopLayout]);
+ }, [graph, sigma, stopLayout, animateToNodes]);
return null;
}
+/** Zoom buttons rendered inside SigmaContainer so they can use useSigma(). */
+function ZoomControls() {
+ const sigma = useSigma();
+ return (
+
+ {
+ const camera = sigma.getCamera();
+ camera.animate({ ratio: camera.ratio / 1.5 }, { duration: 250 });
+ }}
+ />
+ {
+ const camera = sigma.getCamera();
+ camera.animate({ ratio: camera.ratio * 1.5 }, { duration: 250 });
+ }}
+ />
+
+ );
+}
+
export function GraphCanvas() {
+ const clusters = useGraphStore((s) => s.clusters);
+ const loading = useGraphStore((s) => s.loading);
+ const error = useGraphStore((s) => s.error);
+ const daemonOnline = useGraphStore((s) => s.daemonOnline);
+
+ const showEmpty = clusters.length === 0 && !loading && !error && daemonOnline;
+
return (
+
+ {showEmpty && (
+
+
+
+ )}
+
+
);
}
diff --git a/crates/shrimpk-viz/web/src/components/NodeDetail.tsx b/crates/shrimpk-viz/web/src/components/NodeDetail.tsx
index 5649ee5..ff733ba 100644
--- a/crates/shrimpk-viz/web/src/components/NodeDetail.tsx
+++ b/crates/shrimpk-viz/web/src/components/NodeDetail.tsx
@@ -1,26 +1,23 @@
-import { X, Expand, Link, AlertCircle, Loader2 } from "lucide-react";
+import { useEffect } from "react";
+import { X, Expand, Link } from "lucide-react";
import { useGraphStore } from "../stores/graphStore";
-
-const CATEGORY_COLORS: Record = {
- Identity: "bg-blue-500/20 text-blue-400",
- Fact: "bg-green-500/20 text-green-400",
- Preference: "bg-purple-500/20 text-purple-400",
- ActiveProject: "bg-orange-500/20 text-orange-400",
- Conversation: "bg-slate-500/20 text-slate-400",
- Default: "bg-zinc-500/20 text-zinc-400",
-};
+import { Badge } from "@/components/ui/Badge";
+import { Button } from "@/components/ui/Button";
+import { IconButton } from "@/components/ui/IconButton";
+import { ErrorState, LoadingState } from "@/components/ui/StateDisplay";
+import { DETAIL_PANEL_WIDTH } from "@/lib/layout";
function Bar({ value, label }: { value: number; label: string }) {
return (
-
{label}
-
+
{label}
+
-
+
{(value * 100).toFixed(0)}%
@@ -34,50 +31,47 @@ export function NodeDetail() {
const selectNode = useGraphStore((s) => s.selectNode);
const expandNode = useGraphStore((s) => s.expandNode);
+ useEffect(() => {
+ if (!selectedNode) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") selectNode(null);
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [selectedNode, selectNode]);
+
if (!selectedNode) return null;
return (
-
+
{/* Header */}
-
-
+
+
Memory Detail
- selectNode(null)}
- className="p-1 hover:bg-zinc-800 rounded transition-colors"
- aria-label="Close detail panel"
- >
-
-
+ selectNode(null)} />
{/* Content */}
{detailError ? (
- /* Error state */
-
-
-
Failed to load memory
-
{detailError}
-
selectNode(selectedNode)}
- className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs rounded transition-colors"
- >
- Retry
-
-
+
selectNode(selectedNode)}
+ />
) : detail ? (
<>
{/* Category badge */}
-
+
{detail.category}
-
+
{/* Full content */}
-
+
{detail.content}
@@ -85,12 +79,9 @@ export function NodeDetail() {
{detail.labels && detail.labels.length > 0 && (
{detail.labels.map((label) => (
-
+
{label}
-
+
))}
)}
@@ -99,22 +90,22 @@ export function NodeDetail() {
- Echo count
- {detail.echo_count ?? 0}
+ Echo count
+ {detail.echo_count ?? 0}
- Source
- {detail.source ?? "unknown"}
+ Source
+ {detail.source ?? "unknown"}
{detail.modality && (
- Modality
- {detail.modality}
+ Modality
+ {detail.modality}
)}
- Created
-
+ Created
+
{detail.created_at
? new Date(detail.created_at).toLocaleDateString()
: "unknown"}
@@ -123,36 +114,34 @@ export function NodeDetail() {
{/* ID (truncated) */}
-
+
{detail.memory_id}
{/* Actions */}
- expandNode(selectedNode)}
- className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded transition-colors"
>
Expand neighbors
-
-
+ {
navigator.clipboard.writeText(detail.memory_id);
}}
- className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs rounded transition-colors"
>
Copy ID
-
+
>
) : (
- /* Loading state */
-
+
)}
diff --git a/crates/shrimpk-viz/web/src/components/SearchBar.tsx b/crates/shrimpk-viz/web/src/components/SearchBar.tsx
index ecec564..26d6247 100644
--- a/crates/shrimpk-viz/web/src/components/SearchBar.tsx
+++ b/crates/shrimpk-viz/web/src/components/SearchBar.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useRef, useEffect } from "react";
import { Search } from "lucide-react";
import { searchMemories, type EchoResult } from "../api/client";
import { useGraphStore } from "../stores/graphStore";
@@ -8,6 +8,30 @@ export function SearchBar() {
const [results, setResults] = useState([]);
const [open, setOpen] = useState(false);
const expandNode = useGraphStore((s) => s.expandNode);
+ const wrapperRef = useRef(null);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ const handleCtrlK = (e: KeyboardEvent) => {
+ if (e.ctrlKey && e.key === "k") {
+ e.preventDefault();
+ inputRef.current?.focus();
+ }
+ };
+ document.addEventListener("keydown", handleCtrlK);
+ return () => document.removeEventListener("keydown", handleCtrlK);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ const handleMouseDown = (e: MouseEvent) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handleMouseDown);
+ return () => document.removeEventListener("mousedown", handleMouseDown);
+ }, [open]);
const handleSearch = useCallback(async () => {
if (!query.trim()) {
@@ -30,22 +54,23 @@ export function SearchBar() {
};
return (
-
-
-
+
+
+
setQuery(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder="Search memories..."
- className="bg-transparent text-sm text-zinc-200 placeholder:text-zinc-600 outline-none w-64"
+ placeholder="Search memories... (Ctrl+K)"
+ className="bg-transparent text-sm text-text-primary placeholder:text-text-disabled outline-none w-64"
/>
{/* Dropdown results */}
{open && results.length > 0 && (
-
+
{results.map((r) => (
- {r.content}
+ {r.content}
-
+
{(r.similarity * 100).toFixed(0)}% match
{r.labels.slice(0, 2).map((l) => (
{l}
diff --git a/crates/shrimpk-viz/web/src/components/SizeLegend.tsx b/crates/shrimpk-viz/web/src/components/SizeLegend.tsx
new file mode 100644
index 0000000..16b61a9
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/SizeLegend.tsx
@@ -0,0 +1,33 @@
+import { useGraphStore } from "@/stores/graphStore";
+import { Panel } from "@/components/ui/Panel";
+
+const CIRCLES = [
+ { size: 10, label: "Low" },
+ { size: 25, label: "Med" },
+ { size: 40, label: "High" },
+] as const;
+
+export function SizeLegend() {
+ const zoomLevel = useGraphStore((s) => s.zoomLevel);
+ const visible = zoomLevel !== "galaxy";
+
+ return (
+
+ {CIRCLES.map(({ size, label }) => (
+
+ ))}
+
+ );
+}
diff --git a/crates/shrimpk-viz/web/src/components/Toolbar.tsx b/crates/shrimpk-viz/web/src/components/Toolbar.tsx
index 5d1b3be..7de6a07 100644
--- a/crates/shrimpk-viz/web/src/components/Toolbar.tsx
+++ b/crates/shrimpk-viz/web/src/components/Toolbar.tsx
@@ -1,62 +1,64 @@
-import { RotateCcw, ZoomIn, ZoomOut, Home } from "lucide-react";
+import { RotateCcw, Home } from "lucide-react";
import { useGraphStore } from "../stores/graphStore";
+import { IconButton } from "@/components/ui/IconButton";
export function Toolbar() {
const zoomLevel = useGraphStore((s) => s.zoomLevel);
const backToGalaxy = useGraphStore((s) => s.backToGalaxy);
+ const loadOverview = useGraphStore((s) => s.loadOverview);
+ const drillIntoCommunity = useGraphStore((s) => s.drillIntoCommunity);
+ const activeCommunity = useGraphStore((s) => s.activeCommunity);
const loading = useGraphStore((s) => s.loading);
+ const graph = useGraphStore((s) => s.graph);
+
+ const handleRefresh = () => {
+ if (zoomLevel === "cluster" && activeCommunity) {
+ drillIntoCommunity(activeCommunity);
+ } else {
+ loadOverview();
+ }
+ };
return (
{/* Zoom level indicator */}
-
+
-
{zoomLevel}
+
{zoomLevel}
+ {zoomLevel !== "galaxy" && (
+
+ {graph.order} nodes, {graph.size} edges
+
+ )}
-
+
{/* Navigation */}
{zoomLevel !== "galaxy" && (
-
-
-
+ />
)}
-
-
-
+ className={loading ? "[&_svg]:animate-spin" : ""}
+ />
- {/* Placeholder for future zoom controls (sigma handles zoom via scroll) */}
-
-
-
-
-
-
);
}
diff --git a/crates/shrimpk-viz/web/src/components/ui/Badge.tsx b/crates/shrimpk-viz/web/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..55d5a0e
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/ui/Badge.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+import { getCategoryBadge } from "@/lib/categoryColors";
+
+type BadgeProps =
+ | { variant: "label"; children: ReactNode; className?: string }
+ | { variant: "category"; category: string; children: ReactNode; className?: string }
+ | { variant: "count"; count: number; className?: string };
+
+export function Badge(props: BadgeProps) {
+ const base = "inline-flex items-center justify-center rounded text-caption font-medium";
+
+ if (props.variant === "label") {
+ return (
+
+ {props.children}
+
+ );
+ }
+
+ if (props.variant === "category") {
+ return (
+
+ {props.children}
+
+ );
+ }
+
+ // count variant
+ return (
+
+ {props.count}
+
+ );
+}
diff --git a/crates/shrimpk-viz/web/src/components/ui/Button.tsx b/crates/shrimpk-viz/web/src/components/ui/Button.tsx
new file mode 100644
index 0000000..015abd1
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/ui/Button.tsx
@@ -0,0 +1,51 @@
+import { type ButtonHTMLAttributes, forwardRef } from "react";
+
+const FOCUS_RING =
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
+
+const variants = {
+ primary: "bg-accent hover:bg-accent-hover active:bg-accent-active text-text-primary",
+ secondary: "bg-overlay hover:bg-elevated text-text-primary",
+ ghost: "bg-transparent hover:bg-overlay text-text-primary",
+ danger: "bg-error/20 hover:bg-error/30 text-error",
+} as const;
+
+const sizes = {
+ sm: "h-7 px-2 text-caption",
+ md: "h-8 px-3 text-body",
+ lg: "h-9 px-4 text-body",
+} as const;
+
+export type ButtonVariant = keyof typeof variants;
+export type ButtonSize = keyof typeof sizes;
+
+interface ButtonProps extends ButtonHTMLAttributes
{
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+}
+
+export const Button = forwardRef(
+ ({ variant = "primary", size = "md", className = "", children, disabled, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+Button.displayName = "Button";
diff --git a/crates/shrimpk-viz/web/src/components/ui/IconButton.tsx b/crates/shrimpk-viz/web/src/components/ui/IconButton.tsx
new file mode 100644
index 0000000..54f14ce
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/ui/IconButton.tsx
@@ -0,0 +1,48 @@
+import { type ButtonHTMLAttributes, forwardRef } from "react";
+import type { LucideIcon } from "lucide-react";
+
+const FOCUS_RING =
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
+
+const sizes = {
+ sm: { button: "w-7 h-7", icon: 14 },
+ md: { button: "w-8 h-8", icon: 16 },
+} as const;
+
+interface IconButtonProps extends Omit, "children"> {
+ icon: LucideIcon;
+ size?: keyof typeof sizes;
+ tooltip: string;
+ active?: boolean;
+}
+
+export const IconButton = forwardRef(
+ ({ icon: Icon, size = "sm", tooltip, active = false, disabled, className = "", ...props }, ref) => {
+ const s = sizes[size];
+ return (
+
+
+
+ );
+ },
+);
+
+IconButton.displayName = "IconButton";
diff --git a/crates/shrimpk-viz/web/src/components/ui/Panel.tsx b/crates/shrimpk-viz/web/src/components/ui/Panel.tsx
new file mode 100644
index 0000000..a4d28a7
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/ui/Panel.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode, CSSProperties } from "react";
+import { SIDEBAR_WIDTH } from "@/lib/layout";
+
+interface PanelProps {
+ variant: "sidebar" | "overlay" | "legend";
+ className?: string;
+ children: ReactNode;
+ style?: CSSProperties;
+ width?: number;
+ "aria-hidden"?: boolean;
+}
+
+const variantClasses = {
+ sidebar: "bg-base border-r border-border flex flex-col overflow-hidden",
+ overlay: "absolute bg-elevated rounded-lg shadow-xl z-overlays",
+ legend: "absolute bg-base/80 backdrop-blur-sm rounded px-3 py-2 z-panels",
+} as const;
+
+export function Panel({ variant, className = "", children, style, width, "aria-hidden": ariaHidden }: PanelProps) {
+ const resolvedStyle: CSSProperties = { ...style };
+ if (variant === "sidebar") {
+ resolvedStyle.width = width ?? SIDEBAR_WIDTH;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/crates/shrimpk-viz/web/src/components/ui/StateDisplay.tsx b/crates/shrimpk-viz/web/src/components/ui/StateDisplay.tsx
new file mode 100644
index 0000000..bae535e
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/ui/StateDisplay.tsx
@@ -0,0 +1,57 @@
+import type { ReactNode } from "react";
+import { AlertCircle, Loader2, type LucideIcon } from "lucide-react";
+import { Button } from "./Button";
+
+interface EmptyStateProps {
+ icon: LucideIcon;
+ heading: string;
+ description?: string;
+}
+
+export function EmptyState({ icon: Icon, heading, description }: EmptyStateProps) {
+ return (
+
+
+
{heading}
+ {description && (
+
{description}
+ )}
+
+ );
+}
+
+interface LoadingStateProps {
+ message?: string;
+}
+
+export function LoadingState({ message = "Loading..." }: LoadingStateProps) {
+ return (
+
+ );
+}
+
+interface ErrorStateProps {
+ message: string;
+ detail?: string;
+ onRetry?: () => void;
+ children?: ReactNode;
+}
+
+export function ErrorState({ message, detail, onRetry, children }: ErrorStateProps) {
+ return (
+
+
+
{message}
+ {detail &&
{detail}
}
+ {onRetry && (
+
+ Retry
+
+ )}
+ {children}
+
+ );
+}
diff --git a/crates/shrimpk-viz/web/src/components/ui/Toggle.tsx b/crates/shrimpk-viz/web/src/components/ui/Toggle.tsx
new file mode 100644
index 0000000..b88acb1
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/components/ui/Toggle.tsx
@@ -0,0 +1,40 @@
+const FOCUS_RING =
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
+
+interface ToggleProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ label: string;
+ disabled?: boolean;
+}
+
+export function Toggle({ checked, onChange, label, disabled = false }: ToggleProps) {
+ return (
+ onChange(!checked)}
+ className={[
+ "relative inline-flex h-5 w-9 shrink-0 rounded-full",
+ "transition-colors duration-micro",
+ "motion-reduce:transition-none",
+ checked ? "bg-accent" : "bg-overlay",
+ FOCUS_RING,
+ disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
+ ].join(" ")}
+ >
+ {label}
+
+
+ );
+}
diff --git a/crates/shrimpk-viz/web/src/hooks/useCameraTransition.ts b/crates/shrimpk-viz/web/src/hooks/useCameraTransition.ts
new file mode 100644
index 0000000..ec4a36a
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/hooks/useCameraTransition.ts
@@ -0,0 +1,47 @@
+import { useSigma } from "@react-sigma/core";
+import { useCallback } from "react";
+import { getCameraStateToFitViewportToNodes } from "@sigma/utils";
+
+const DURATION = 250;
+const EASING = "quadraticOut" as const;
+
+export function useCameraTransition() {
+ const sigma = useSigma();
+
+ const animateToNodes = useCallback(
+ (nodeIds: string[]) => {
+ const graph = sigma.getGraph();
+ const validIds = nodeIds.filter((id) => graph.hasNode(id));
+
+ if (validIds.length === 0) return;
+
+ if (validIds.length === 1) {
+ const attrs = graph.getNodeAttributes(validIds[0]);
+ sigma.getCamera().animate(
+ { x: attrs.x, y: attrs.y, ratio: 0.5 },
+ { duration: DURATION, easing: EASING },
+ );
+ return;
+ }
+
+ // Multiple nodes — use @sigma/utils to compute the target camera state,
+ // then animate with our own duration/easing for consistency.
+ const targetState = getCameraStateToFitViewportToNodes(
+ sigma,
+ validIds,
+ );
+ sigma.getCamera().animate(targetState, {
+ duration: DURATION,
+ easing: EASING,
+ });
+ },
+ [sigma],
+ );
+
+ const animateToNode = useCallback(
+ (nodeId: string) => animateToNodes([nodeId]),
+ [animateToNodes],
+ );
+
+ return { animateToNodes, animateToNode };
+}
diff --git a/crates/shrimpk-viz/web/src/layouts/AppLayout.tsx b/crates/shrimpk-viz/web/src/layouts/AppLayout.tsx
index 1aed81d..6215261 100644
--- a/crates/shrimpk-viz/web/src/layouts/AppLayout.tsx
+++ b/crates/shrimpk-viz/web/src/layouts/AppLayout.tsx
@@ -9,14 +9,14 @@ export function AppLayout() {
const error = useGraphStore((s) => s.error);
return (
-
+
{/* Top bar */}
-
+
-
+
ShrimPK
- Knowledge Graph
+ Knowledge Graph
@@ -24,7 +24,7 @@ export function AppLayout() {
{/* Error banner */}
{error && (
-
+
{error}
)}
diff --git a/crates/shrimpk-viz/web/src/lib/categoryColors.ts b/crates/shrimpk-viz/web/src/lib/categoryColors.ts
new file mode 100644
index 0000000..38142da
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/lib/categoryColors.ts
@@ -0,0 +1,60 @@
+/**
+ * Single source of truth for node category colors.
+ * Non-graph design tokens live in globals.css @theme block.
+ */
+
+import { schemeTableau10 } from "d3-scale-chromatic";
+
+/** Hex colors for Sigma WebGL rendering (CSS vars can't be read at paint time) */
+export const CATEGORY_HEX: Record
= {
+ Identity: "#3b82f6",
+ Fact: "#22c55e",
+ Preference: "#a855f7",
+ ActiveProject: "#f97316",
+ Conversation: "#64748b",
+ Default: "#71717a",
+};
+
+/** Tailwind class strings for UI badge styling */
+export const CATEGORY_BADGE: Record = {
+ Identity: "bg-blue-500/20 text-blue-400",
+ Fact: "bg-green-500/20 text-green-400",
+ Preference: "bg-purple-500/20 text-purple-400",
+ ActiveProject: "bg-orange-500/20 text-orange-400",
+ Conversation: "bg-slate-500/20 text-slate-400",
+ Default: "bg-zinc-500/20 text-zinc-400",
+};
+
+/** Cluster super-node color */
+export const CLUSTER_COLOR = "#6366f1";
+
+/** Isolated/unconnected node color */
+export const ISOLATED_COLOR = "#94a3b8";
+
+/** Look up hex color for a category, falling back to Default */
+export function getCategoryHex(category: string): string {
+ return CATEGORY_HEX[category] ?? CATEGORY_HEX.Default;
+}
+
+/** Look up badge classes for a category, falling back to Default */
+export function getCategoryBadge(category: string): string {
+ return CATEGORY_BADGE[category] ?? CATEGORY_BADGE.Default;
+}
+
+// ---------------------------------------------------------------------------
+// Community colors (Louvain detection → Tableau10 palette)
+// ---------------------------------------------------------------------------
+
+/** Deterministic hash for stable community→color mapping */
+function hashCode(str: string): number {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
+ }
+ return Math.abs(hash);
+}
+
+/** Map a community ID to a Tableau10 color (deterministic) */
+export function communityColor(communityId: string | number): string {
+ return schemeTableau10[hashCode(String(communityId)) % 10];
+}
diff --git a/crates/shrimpk-viz/web/src/lib/layout.ts b/crates/shrimpk-viz/web/src/lib/layout.ts
new file mode 100644
index 0000000..eb8bdbc
--- /dev/null
+++ b/crates/shrimpk-viz/web/src/lib/layout.ts
@@ -0,0 +1,5 @@
+/** Panel width constants (px) */
+export const SIDEBAR_WIDTH = 250;
+export const SIDEBAR_COLLAPSED = 48;
+export const DETAIL_PANEL_WIDTH = 350;
+export const MIN_CANVAS_WIDTH = 400;
diff --git a/crates/shrimpk-viz/web/src/stores/graphStore.ts b/crates/shrimpk-viz/web/src/stores/graphStore.ts
index 18123e6..aec2faf 100644
--- a/crates/shrimpk-viz/web/src/stores/graphStore.ts
+++ b/crates/shrimpk-viz/web/src/stores/graphStore.ts
@@ -1,31 +1,49 @@
import { create } from "zustand";
import Graph from "graphology";
+import louvain from "graphology-communities-louvain";
import {
fetchOverview,
- fetchNeighbors,
fetchMemoryGet,
- fetchRelated,
+ searchMemories,
type GraphCluster,
type GraphInterEdge,
type MemoryDetail,
} from "../api/client";
+import { getCategoryHex, CLUSTER_COLOR, ISOLATED_COLOR, communityColor } from "@/lib/categoryColors";
-// ---------------------------------------------------------------------------
-// Category → color mapping (matches TUI palette)
-// ---------------------------------------------------------------------------
-
-const CATEGORY_COLORS: Record = {
- Identity: "#3b82f6",
- Fact: "#22c55e",
- Preference: "#a855f7",
- ActiveProject: "#f97316",
- Conversation: "#64748b",
- Default: "#71717a",
-};
+export type ZoomLevel = "galaxy" | "cluster" | "neighborhood";
-const CLUSTER_COLOR = "#6366f1";
+/** Run Louvain and assign colors. Returns community→nodeIDs map. */
+function assignCommunityColors(graph: Graph): Map {
+ const communityMap = new Map();
+ if (graph.order < 2 || graph.size === 0) {
+ // No edges — mark all nodes with ISOLATED_COLOR
+ graph.forEachNode((node) => {
+ graph.setNodeAttribute(node, "color", ISOLATED_COLOR);
+ });
+ return communityMap;
+ }
+ louvain.assign(graph);
+ graph.forEachNode((node, attrs) => {
+ const community = attrs.community;
+ if (community != null) {
+ const key = String(community);
+ graph.setNodeAttribute(node, "color", communityColor(community));
+ const list = communityMap.get(key);
+ if (list) list.push(node);
+ else communityMap.set(key, [node]);
+ } else {
+ graph.setNodeAttribute(node, "color", ISOLATED_COLOR);
+ }
+ });
+ return communityMap;
+}
-export type ZoomLevel = "galaxy" | "cluster" | "neighborhood";
+/** Log-scale node sizing: 10px (low importance) → 40px (high importance). */
+function importanceToSize(importance: number, maxImportance: number): number {
+ if (maxImportance <= 0) return 10;
+ return 10 + 30 * (Math.log(importance + 1) / Math.log(maxImportance + 1));
+}
interface GraphState {
// Graph data
@@ -42,11 +60,15 @@ interface GraphState {
interEdges: GraphInterEdge[];
activeCommunity: string | null;
+ // Louvain community data
+ communityMap: Map;
+
// UI state
loading: boolean;
error: string | null;
detailError: string | null;
daemonOnline: boolean;
+ sidebarCollapsed: boolean;
// Actions
loadOverview: () => Promise;
@@ -56,6 +78,7 @@ interface GraphState {
setHoveredNode: (id: string | null) => void;
backToGalaxy: () => Promise;
setDaemonOnline: (online: boolean) => void;
+ toggleSidebar: () => void;
}
export const useGraphStore = create((set, get) => ({
@@ -67,12 +90,15 @@ export const useGraphStore = create((set, get) => ({
clusters: [],
interEdges: [],
activeCommunity: null,
+ communityMap: new Map(),
loading: false,
error: null,
detailError: null,
daemonOnline: false,
+ sidebarCollapsed: false,
setDaemonOnline: (online) => set({ daemonOnline: online }),
+ toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setHoveredNode: (id) => set({ hoveredNode: id }),
loadOverview: async () => {
@@ -115,6 +141,7 @@ export const useGraphStore = create((set, get) => ({
interEdges: data.inter_edges,
zoomLevel: "galaxy",
activeCommunity: null,
+ communityMap: new Map(),
selectedNode: null,
selectedDetail: null,
loading: false,
@@ -129,24 +156,21 @@ export const useGraphStore = create((set, get) => ({
if (get().loading) return;
set({ loading: true, error: null });
try {
- // Find a member ID to anchor the query
const cluster = get().clusters.find((c) => c.label === label);
- if (!cluster) {
- set({ loading: false, error: `Cluster "${label}" not found` });
- return;
- }
- if (cluster.top_members.length === 0) {
- set({ loading: false, error: `Cluster "${label}" has no members` });
+ if (!cluster || cluster.top_members.length === 0) {
+ set({ loading: false, error: `Cluster "${label}" not found or empty` });
return;
}
- const anchorId = cluster.top_members[0].id;
- const data = await fetchRelated(anchorId, label, 100);
+ // Use top member's content as semantic search query
+ // (Hebbian graph endpoints deadlock, label string search returns 0)
+ const query = cluster.top_members[0].content_preview;
+ const data = await searchMemories(query, 100);
if (!data.results || data.results.length === 0) {
set({
loading: false,
- error: `No related memories found for cluster "${label}"`,
+ error: `No memories found for cluster "${label}"`,
});
return;
}
@@ -155,6 +179,7 @@ export const useGraphStore = create((set, get) => ({
// Add member nodes with initial positions
const count = data.results.length;
+ const maxImportance = Math.max(...data.results.map((r) => r.final_score));
for (let i = 0; i < count; i++) {
const result = data.results[i];
if (!graph.hasNode(result.memory_id)) {
@@ -164,8 +189,8 @@ export const useGraphStore = create((set, get) => ({
label: result.content.slice(0, 40),
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
- size: 6 + result.final_score * 8,
- color: "#71717a",
+ size: importanceToSize(result.final_score, maxImportance),
+ color: getCategoryHex("Default"),
nodeType: "memory",
content: result.content,
similarity: result.similarity,
@@ -173,10 +198,33 @@ export const useGraphStore = create((set, get) => ({
}
}
+ // Create edges based on similarity proximity for community detection
+ const nodeIds = graph.nodes();
+ let edgeCount = 0;
+ for (let i = 0; i < nodeIds.length && edgeCount < 200; i++) {
+ for (let j = i + 1; j < nodeIds.length && edgeCount < 200; j++) {
+ const a = graph.getNodeAttributes(nodeIds[i]);
+ const b = graph.getNodeAttributes(nodeIds[j]);
+ const weight = Math.min(a.similarity ?? 0, b.similarity ?? 0);
+ if (weight > 0.3) {
+ graph.addEdge(nodeIds[i], nodeIds[j], {
+ weight,
+ size: 0.5 + weight * 2,
+ color: "#3f3f46",
+ });
+ edgeCount++;
+ }
+ }
+ }
+
+ // Assign community colors via Louvain (needs edges; falls back to isolated)
+ const communityMap = assignCommunityColors(graph);
+
set({
graph,
zoomLevel: "cluster",
activeCommunity: label,
+ communityMap,
selectedNode: null,
selectedDetail: null,
loading: false,
@@ -189,55 +237,72 @@ export const useGraphStore = create((set, get) => ({
expandNode: async (id) => {
set({ loading: true, error: null });
try {
- const data = await fetchNeighbors(id);
+ // Get center node details (memory_get works, neighbors deadlocks)
+ const center = await fetchMemoryGet(id);
+
+ // Find semantic neighbors via echo search
+ const neighbors = await searchMemories(center.content.slice(0, 200), 20);
const graph = new Graph();
+ // Filter out the center node from results
+ const neighborResults = neighbors.results.filter(r => r.memory_id !== id);
+
+ // Compute maxImportance
+ const maxImportance = Math.max(
+ center.novelty_score ?? 0,
+ ...neighborResults.map((n) => n.final_score),
+ );
+
// Add center node
- graph.addNode(data.node.id, {
- label: data.node.content_preview.slice(0, 40),
+ graph.addNode(center.memory_id, {
+ label: center.content.slice(0, 40),
x: 0,
y: 0,
- size: 14,
- color: CATEGORY_COLORS[data.node.category] ?? "#71717a",
+ size: importanceToSize(center.novelty_score ?? 0, maxImportance),
+ color: getCategoryHex(center.category),
nodeType: "memory",
- content: data.node.content_preview,
- importance: data.node.importance,
- category: data.node.category,
+ content: center.content,
+ importance: center.novelty_score,
+ category: center.category,
isCenter: true,
});
- // Add neighbors
- for (let i = 0; i < data.neighbors.length; i++) {
- const neighbor = data.neighbors[i];
- if (!graph.hasNode(neighbor.id)) {
- const angle = (2 * Math.PI * i) / data.neighbors.length;
- const radius = 100 + neighbor.weight * 200;
- graph.addNode(neighbor.id, {
- label: neighbor.content_preview.slice(0, 40),
+ // Add neighbors from echo results
+ for (let i = 0; i < neighborResults.length; i++) {
+ const result = neighborResults[i];
+ if (!graph.hasNode(result.memory_id)) {
+ const angle = (2 * Math.PI * i) / neighborResults.length;
+ const radius = 100 + result.similarity * 200;
+ graph.addNode(result.memory_id, {
+ label: result.content.slice(0, 40),
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
- size: 4 + neighbor.weight * 10,
- color: "#71717a",
+ size: importanceToSize(result.final_score, maxImportance),
+ color: getCategoryHex("Default"),
nodeType: "memory",
- content: neighbor.content_preview,
- weight: neighbor.weight,
+ content: result.content,
+ weight: result.similarity,
});
}
- if (!graph.hasEdge(data.node.id, neighbor.id)) {
- graph.addEdge(data.node.id, neighbor.id, {
- weight: neighbor.weight,
- size: 0.5 + neighbor.weight * 2.5,
- color: neighbor.relationship ? "#6366f1" : "#3f3f46",
- relationship: neighbor.relationship,
+ // Add edge from center to neighbor
+ if (!graph.hasEdge(center.memory_id, result.memory_id)) {
+ graph.addEdge(center.memory_id, result.memory_id, {
+ weight: result.similarity,
+ size: 0.5 + result.similarity * 2.5,
+ color: result.similarity > 0.5 ? "#6366f1" : "#3f3f46",
});
}
}
+ // Assign community colors via Louvain
+ const communityMap = assignCommunityColors(graph);
+
set({
graph,
zoomLevel: "neighborhood",
- selectedNode: null,
- selectedDetail: null,
+ communityMap,
+ selectedNode: id,
+ selectedDetail: center,
loading: false,
});
} catch (e) {
diff --git a/crates/shrimpk-viz/web/src/styles/globals.css b/crates/shrimpk-viz/web/src/styles/globals.css
index 7e41d82..45e99d0 100644
--- a/crates/shrimpk-viz/web/src/styles/globals.css
+++ b/crates/shrimpk-viz/web/src/styles/globals.css
@@ -1,37 +1,88 @@
@import "tailwindcss";
-:root {
- --color-graph-bg: #09090b;
- --color-panel-bg: #18181b;
- --color-border: #27272a;
+@theme {
+ /* Surfaces */
+ --color-canvas: #09090b;
+ --color-base: #18181b;
+ --color-elevated: #1f1f22;
+ --color-overlay: #27272a;
+
+ /* Text */
+ --color-text-primary: #f4f4f5;
+ --color-text-secondary: #a1a1aa;
+ --color-text-muted: #71717a;
+ --color-text-disabled: #52525b;
+
+ /* Accent */
--color-accent: #6366f1;
+ --color-accent-hover: #818cf8;
+ --color-accent-active: #4f46e5;
+
+ /* Status */
+ --color-error: #f87171;
+ --color-success: #4ade80;
+ --color-warning: #fb923c;
+
+ /* Border */
+ --color-border: #27272a;
+ --color-border-subtle: #1f1f22;
- /* Category node colors */
- --node-identity: #3b82f6;
- --node-fact: #22c55e;
- --node-preference: #a855f7;
- --node-active-project: #f97316;
- --node-conversation: #64748b;
- --node-default: #71717a;
+ /* Ring (focus) */
+ --color-ring: #6366f1;
- /* Cluster super-node */
- --node-cluster: #6366f1;
+ /* Typography scale */
+ --font-size-title: 16px;
+ --font-size-heading: 14px;
+ --font-size-body: 13px;
+ --font-size-label: 12px;
+ --font-size-caption: 11px;
+ --font-size-micro: 10px;
+
+ /* Animation */
+ --duration-micro: 150ms;
+ --duration-transition: 200ms;
+ --duration-panel: 300ms;
+ --duration-camera: 250ms;
+ --ease-micro: ease-out;
+ --ease-transition: ease-in-out;
+ --ease-panel: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-camera: ease-out;
+
+ /* Z-index */
+ --z-index-canvas: 0;
+ --z-index-panels: 10;
+ --z-index-top-bar: 20;
+ --z-index-overlays: 30;
+ --z-index-dropdowns: 40;
+ --z-index-tooltips: 50;
+ --z-index-modals: 60;
+
+ /* Spacing (4px grid) */
+ --spacing-1: 4px;
+ --spacing-2: 8px;
+ --spacing-3: 12px;
+ --spacing-4: 16px;
+ --spacing-5: 20px;
+ --spacing-6: 24px;
+ --spacing-8: 32px;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
+ background-color: var(--color-canvas);
+ color: var(--color-text-primary);
}
/* react-sigma wrapper: override its default white background */
div.react-sigma {
- background: var(--color-graph-bg);
+ background: var(--color-canvas);
}
/* Sigma container */
.sigma-container {
width: 100%;
height: 100%;
- background: var(--color-graph-bg);
+ background: var(--color-canvas);
}
/* Node detail panel slide-in */
@@ -46,7 +97,7 @@ div.react-sigma {
}
}
.animate-slide-in {
- animation: slide-in-right 0.15s ease-out;
+ animation: slide-in-right var(--duration-micro) var(--ease-micro);
}
/* Custom scrollbar */
@@ -57,6 +108,20 @@ div.react-sigma {
background: transparent;
}
::-webkit-scrollbar-thumb {
- background: #3f3f46;
+ background: var(--color-overlay);
border-radius: 3px;
}
+
+/* Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ --duration-micro: 1ms !important;
+ --duration-transition: 1ms !important;
+ --duration-panel: 1ms !important;
+ --duration-camera: 1ms !important;
+ animation-duration: 1ms !important;
+ transition-duration: 1ms !important;
+ }
+}
diff --git a/crates/shrimpk-viz/web/test/STD-KS77.md b/crates/shrimpk-viz/web/test/STD-KS77.md
new file mode 100644
index 0000000..cc29065
--- /dev/null
+++ b/crates/shrimpk-viz/web/test/STD-KS77.md
@@ -0,0 +1,276 @@
+# STD-KS77: Software Test Description — Test Cases
+
+## Preflight (GATE)
+
+### PF-01: Daemon health
+- **Precondition:** None
+- **Step:** `curl -s http://localhost:11435/health --max-time 5`
+- **Expected:** 200 OK, JSON with `version: "0.7.0"`, `memories >= 10`
+- **Fail:** ABORT all testing. Report daemon version and memory count.
+
+### PF-02: Overview endpoint
+- **Precondition:** PF-01 passed
+- **Step:** `curl -s -X POST http://localhost:11435/api/graph/overview -H "Content-Type: application/json" -d '{"min_members":3,"max_clusters":5}' --max-time 10`
+- **Expected:** 200 OK, JSON with `clusters` array (length >= 1), each has `label`, `member_count`, `top_members`
+- **Fail:** ABORT. Report HTTP status and response body.
+
+### PF-03: Echo endpoint
+- **Precondition:** PF-01 passed
+- **Step:** `curl -s -X POST http://localhost:11435/api/echo -H "Content-Type: application/json" -d '{"query":"test","max_results":3}' --max-time 10`
+- **Expected:** 200 OK, JSON with `results` array, `elapsed_ms` field
+- **Fail:** ABORT. Report HTTP status.
+
+### PF-04: Memory get endpoint
+- **Precondition:** PF-02 passed (need a memory ID)
+- **Step:** Extract a `memory_id` from PF-02's top_members, then `POST /api/memory_get {"memory_id":"..."}`
+- **Expected:** 200 OK, JSON with `memory_id`, `content`, `category`, `labels`
+- **Fail:** ABORT. Report HTTP status.
+
+### PF-05: Vite dev server
+- **Precondition:** None
+- **Step:** `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173`
+- **Expected:** 200
+- **Fail:** ABORT.
+
+### PF-06: Playwright browser connect
+- **Precondition:** PF-05 passed
+- **Step:** `browser_navigate` to `http://localhost:5173`, then `browser_snapshot`
+- **Expected:** Page loads, snapshot contains "ShrimPK" text
+- **Fail:** ABORT.
+
+---
+
+## Galaxy View
+
+### GV-01: Graph loads with clusters (GATE)
+- **Precondition:** PF-06 passed
+- **Step:** Wait 5 seconds after navigation for graph to load. Take screenshot. Snapshot DOM.
+- **Expected:**
+ - Left sidebar shows "Communities" heading
+ - Sidebar has cluster buttons with labels and member counts
+ - Center canvas has rendered nodes (WebGL — may not appear in DOM snapshot)
+ - Top bar shows "ShrimPK", search input, zoom level "Galaxy"
+ - No error banner visible
+- **Fail:** BLOCK all DI, NB, DP, SR, NV tests.
+
+### GV-02: Cluster list populated
+- **Precondition:** GV-01 passed
+- **Step:** Count cluster buttons in sidebar via DOM snapshot
+- **Expected:** >= 3 cluster buttons, each with a label and member count badge
+- **Fail:** Log count and continue.
+
+### GV-03: Zoom controls visible
+- **Precondition:** GV-01 passed
+- **Step:** Look for ZoomIn/ZoomOut buttons in DOM snapshot (canvas overlay area)
+- **Expected:** Two buttons with "Zoom in" and "Zoom out" tooltips present
+- **Fail:** Log and continue.
+
+### GV-04: No error banner
+- **Precondition:** GV-01 passed
+- **Step:** Check DOM for error banner element
+- **Expected:** No error banner visible
+- **Fail:** Log error text and continue.
+
+---
+
+## Drill-In
+
+### DI-01: Click cluster drills into community (GATE)
+- **Precondition:** GV-01 passed
+- **Step:**
+ 1. Identify first cluster button in sidebar
+ 2. Click it
+ 3. Wait 5 seconds for graph to update
+ 4. Take screenshot
+ 5. Snapshot DOM
+- **Expected:**
+ - Zoom level changes from "Galaxy" to "Cluster"
+ - Sidebar header shows cluster name (not "Communities")
+ - "Back to Galaxy" button appears at bottom of sidebar
+ - No error banner
+ - Daemon still responds to `/health` (no crash)
+- **Fail:** Check daemon health. If daemon crashed, report as CRITICAL.
+
+### DI-02: Community legend appears
+- **Precondition:** DI-01 passed
+- **Step:** Look for community legend overlay (bottom-left of canvas)
+- **Expected:** Legend with colored dots and "Cluster N (count)" labels
+- **Fail:** Log and continue.
+
+### DI-03: Size legend appears
+- **Precondition:** DI-01 passed
+- **Step:** Look for size legend overlay (bottom-right of canvas)
+- **Expected:** Three circles (small/medium/large) with "Low"/"Med"/"High" labels
+- **Fail:** Log and continue.
+
+---
+
+## Neighborhood
+
+### NB-01: Expand node shows neighbors
+- **Precondition:** DI-01 passed (need a cluster view with nodes)
+- **Step:**
+ 1. From cluster view, click a node on the canvas (or use Search to find a node)
+ 2. In detail panel, click "Expand neighbors"
+ 3. Wait 5 seconds
+ 4. Take screenshot
+- **Expected:**
+ - Zoom level changes to "Neighborhood"
+ - Graph shows center node + connected neighbors
+ - Edges visible between center and neighbors
+ - Daemon still healthy (no crash)
+- **Fail:** Check daemon health. If crashed, report CRITICAL.
+
+### NB-02: Center node highlighted
+- **Precondition:** NB-01 passed
+- **Step:** Inspect graph visually (screenshot)
+- **Expected:** Center node is larger/differently colored than neighbors
+- **Fail:** Log and continue.
+
+### NB-03: Hover dims non-neighbors
+- **Precondition:** NB-01 passed
+- **Step:** Hover over a node (if possible via Playwright)
+- **Expected:** Non-connected nodes dim
+- **Fail:** Log (may not be testable via Playwright on WebGL canvas).
+
+---
+
+## Detail Panel
+
+### DP-01: Click node opens detail panel
+- **Precondition:** DI-01 passed
+- **Step:**
+ 1. Use search to find a memory (type query, press Enter, click result)
+ 2. After expand, look for detail panel on the right
+ 3. Take screenshot
+ 4. Snapshot DOM
+- **Expected:**
+ - Right panel slides in with memory content
+ - Category badge visible (colored pill)
+ - Content text visible
+ - Metrics section (novelty bar, echo count, source, created date)
+ - Action buttons: "Expand neighbors", "Copy ID"
+- **Fail:** Log what's missing.
+
+### DP-02: Close detail panel
+- **Precondition:** DP-01 passed
+- **Step:** Click the X (close) button in detail panel header
+- **Expected:** Panel slides out, detail disappears
+- **Fail:** Log.
+
+### DP-03: Expand neighbors button
+- **Precondition:** DP-01 passed
+- **Step:** Click "Expand neighbors" button
+- **Expected:** Graph transitions to neighborhood view (see NB-01 expected)
+- **Fail:** Log.
+
+### DP-04: Copy ID button
+- **Precondition:** DP-01 passed
+- **Step:** Click "Copy ID" button
+- **Expected:** No error, button responds to click (clipboard write may not be verifiable)
+- **Fail:** Log.
+
+---
+
+## Search
+
+### SR-01: Search returns results
+- **Precondition:** GV-01 passed
+- **Step:**
+ 1. Click search input
+ 2. Type "project" (or any common word)
+ 3. Press Enter
+ 4. Wait 3 seconds
+ 5. Snapshot DOM
+- **Expected:**
+ - Dropdown appears below search input
+ - Contains 1+ result items with content preview, similarity %, labels
+- **Fail:** Log.
+
+### SR-02: Click search result navigates
+- **Precondition:** SR-01 passed
+- **Step:** Click first search result
+- **Expected:**
+ - Dropdown closes
+ - Search input clears
+ - Graph transitions to neighborhood view for that memory
+ - Daemon healthy
+- **Fail:** Log.
+
+### SR-03: Escape closes dropdown
+- **Precondition:** SR-01 passed
+- **Step:** Press Escape while dropdown is open
+- **Expected:** Dropdown closes, search input retains text
+- **Fail:** Log.
+
+---
+
+## Navigation
+
+### NV-01: Back to Galaxy from cluster
+- **Precondition:** DI-01 passed (in cluster view)
+- **Step:** Click "Back to Galaxy" button at bottom of sidebar
+- **Expected:**
+ - Zoom level returns to "Galaxy"
+ - Sidebar shows "Communities" heading
+ - Cluster list visible
+- **Fail:** Log.
+
+### NV-02: Home button from non-galaxy
+- **Precondition:** DI-01 passed
+- **Step:** Click Home icon button in toolbar
+- **Expected:** Returns to galaxy view (same as NV-01)
+- **Fail:** Log.
+
+### NV-03: Refresh reloads current view
+- **Precondition:** GV-01 passed
+- **Step:** Click Refresh (rotate) button in toolbar
+- **Expected:** Graph reloads without changing zoom level. Loading spinner appears briefly.
+- **Fail:** Log.
+
+### NV-04: Zoom in/out controls
+- **Precondition:** GV-01 passed
+- **Step:** Click Zoom In button, take screenshot. Click Zoom Out button, take screenshot.
+- **Expected:** Visible change in graph zoom level between screenshots
+- **Fail:** Log.
+
+---
+
+## Visual Polish
+
+### VP-01: No pure white
+- **Precondition:** GV-01 passed
+- **Step:** Grep all rendered CSS for `#fff`, `#ffffff`, `rgb(255,255,255)`, `white`
+- **Expected:** Zero occurrences
+- **Fail:** Report element + property.
+
+### VP-02: Dark theme consistency
+- **Precondition:** GV-01 passed
+- **Step:** Take full-page screenshot. Visual inspection.
+- **Expected:**
+ - Canvas is darkest (#09090b)
+ - Sidebars slightly lighter (#18181b)
+ - Top bar slightly lighter (#1f1f22)
+ - Text is readable (no low-contrast issues)
+- **Fail:** Log.
+
+### VP-03: Focus rings on interactive elements
+- **Precondition:** GV-01 passed
+- **Step:** Tab through interactive elements (toolbar buttons, sidebar items, search input)
+- **Expected:** Visible indigo focus ring on each focused element
+- **Fail:** Report which elements lack focus ring.
+
+### VP-04: Legends correctly positioned
+- **Precondition:** DI-01 passed (legends visible in cluster/neighborhood)
+- **Step:** Screenshot. Check legend positions.
+- **Expected:**
+ - Size legend: bottom-right corner
+ - Community legend: bottom-left corner
+ - Neither overlaps sidebar or detail panel
+- **Fail:** Log position issue.
+
+### VP-05: Reduced motion respected
+- **Precondition:** GV-01 passed
+- **Step:** Check CSS for `@media (prefers-reduced-motion: reduce)` rule
+- **Expected:** All duration tokens set to 1ms in reduced-motion media query
+- **Fail:** Log.
diff --git a/crates/shrimpk-viz/web/test/STP-KS77.md b/crates/shrimpk-viz/web/test/STP-KS77.md
new file mode 100644
index 0000000..6c3d2ac
--- /dev/null
+++ b/crates/shrimpk-viz/web/test/STP-KS77.md
@@ -0,0 +1,66 @@
+# STP-KS77: Software Test Plan — Design System + P0 Graph Polish
+
+## 1. Scope
+
+Test the KS77 viz sprint deliverables:
+- Design system foundation (tokens, components, migration)
+- P0: Node size by importance
+- P0: Louvain community colors
+- P0: Camera transitions
+- Bugfixes (zoom controls, focus rings, a11y, drill-in/expand workarounds)
+
+## 2. Environment Requirements
+
+| Requirement | Check | Fail Action |
+|-------------|-------|-------------|
+| Daemon v0.7.0 on localhost:11435 | `GET /health` returns `version: "0.7.0"` | Abort — wrong daemon |
+| Daemon has >=10 memories | `GET /health` returns `memories >= 10` | Abort — no test data |
+| Overview endpoint available | `POST /api/graph/overview` returns 200 | Abort — API mismatch |
+| Echo endpoint available | `POST /api/echo` returns 200 | Abort — API mismatch |
+| Vite dev server on localhost:5173 | `GET /` returns 200 | Abort — frontend down |
+| Chrome accessible via Playwright | `browser_navigate` succeeds | Abort — no browser |
+
+**HARD RULE:** All 6 checks MUST pass before any test case runs. If ANY check fails, stop and report the failure — do NOT proceed to test cases.
+
+## 3. Test Categories
+
+| Category | ID Prefix | Description |
+|----------|-----------|-------------|
+| Preflight | PF- | Environment validation |
+| Galaxy View | GV- | Initial load, cluster display |
+| Drill-In | DI- | Community drill-in navigation |
+| Neighborhood | NB- | Node expand, neighbor view |
+| Detail Panel | DP- | Node selection, detail display |
+| Search | SR- | Search input, results, navigation |
+| Navigation | NV- | Back/home, refresh, zoom |
+| Visual Polish | VP- | Design tokens, legends, a11y |
+
+## 4. Execution Order
+
+1. **Preflight (PF-01 to PF-06)** — GATE: all must pass
+2. **Galaxy View (GV-01 to GV-04)** — GATE: GV-01 must pass
+3. **Drill-In (DI-01 to DI-03)** — GATE: DI-01 must pass
+4. **Neighborhood (NB-01 to NB-03)**
+5. **Detail Panel (DP-01 to DP-04)**
+6. **Search (SR-01 to SR-03)**
+7. **Navigation (NV-01 to NV-04)**
+8. **Visual Polish (VP-01 to VP-05)**
+
+Each GATE test blocks all subsequent tests in that category if it fails.
+
+## 5. Reporting
+
+After each test case:
+```
+[TC-ID] [PASS|FAIL|BLOCKED] — one-line summary
+ Expected: ...
+ Actual: ...
+ Screenshot: (if applicable)
+```
+
+Final summary:
+```
+PASSED: N/M
+FAILED: N (list IDs)
+BLOCKED: N (list IDs + blocker)
+```
diff --git a/crates/shrimpk-viz/web/test/preflight.sh b/crates/shrimpk-viz/web/test/preflight.sh
new file mode 100644
index 0000000..a4cc819
--- /dev/null
+++ b/crates/shrimpk-viz/web/test/preflight.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+# KS77 QA Preflight — run BEFORE any Playwright tests
+# Exits non-zero if any check fails.
+
+DAEMON_URL="http://localhost:11435"
+VITE_URL="http://localhost:5173"
+REQUIRED_VERSION="0.7.0"
+MIN_MEMORIES=10
+TIMEOUT=5
+
+pass=0
+fail=0
+
+ok() {
+ echo "[PASS] $1: $2"
+ ((pass++)) || true
+}
+
+ko() {
+ echo "[FAIL] $1: $2 — $3"
+ ((fail++)) || true
+}
+
+echo "=== KS77 QA Preflight ==="
+echo ""
+
+# PF-01: Daemon health
+HEALTH=$(curl -s "$DAEMON_URL/health" --max-time $TIMEOUT 2>/dev/null || echo '{}')
+VERSION=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))" 2>/dev/null || echo "")
+MEMORIES=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('memories',0))" 2>/dev/null || echo "0")
+
+if [ -z "$VERSION" ]; then
+ ko "PF-01a" "Daemon reachable" "not responding at $DAEMON_URL"
+ echo "ABORT: Daemon is down."; exit 1
+fi
+ok "PF-01a" "Daemon reachable"
+
+if [ "$VERSION" != "$REQUIRED_VERSION" ]; then
+ ko "PF-01b" "Daemon version" "got $VERSION, need $REQUIRED_VERSION"
+ echo "ABORT: Wrong daemon version."; exit 1
+fi
+ok "PF-01b" "Daemon version $REQUIRED_VERSION"
+
+if [ "$MEMORIES" -lt "$MIN_MEMORIES" ]; then
+ ko "PF-01c" "Memory count" "got $MEMORIES, need >=$MIN_MEMORIES"
+ echo "ABORT: Not enough memories."; exit 1
+fi
+ok "PF-01c" "Daemon has $MEMORIES memories (>=$MIN_MEMORIES)"
+
+# PF-02: Overview endpoint
+OV_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$DAEMON_URL/api/graph/overview" \
+ -H "Content-Type: application/json" \
+ -d '{"min_members":3,"max_clusters":5}' \
+ --max-time $TIMEOUT 2>/dev/null || echo "000")
+if [ "$OV_STATUS" = "200" ]; then
+ ok "PF-02" "Overview endpoint"
+else
+ ko "PF-02" "Overview endpoint" "HTTP $OV_STATUS"
+ echo "ABORT: Graph API unavailable."; exit 1
+fi
+
+# PF-03: Echo endpoint
+ECHO_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$DAEMON_URL/api/echo" \
+ -H "Content-Type: application/json" \
+ -d '{"query":"test","max_results":1}' \
+ --max-time $TIMEOUT 2>/dev/null || echo "000")
+if [ "$ECHO_STATUS" = "200" ]; then
+ ok "PF-03" "Echo endpoint"
+else
+ ko "PF-03" "Echo endpoint" "HTTP $ECHO_STATUS"
+ echo "ABORT: Echo API unavailable."; exit 1
+fi
+
+# PF-04: Memory get endpoint
+MEM_ID=$(curl -s -X POST "$DAEMON_URL/api/graph/overview" \
+ -H "Content-Type: application/json" \
+ -d '{"min_members":3,"max_clusters":1}' \
+ --max-time $TIMEOUT 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin)['clusters'][0]['top_members'][0]['id'])" 2>/dev/null || echo "")
+if [ -n "$MEM_ID" ]; then
+ MG_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$DAEMON_URL/api/memory_get" \
+ -H "Content-Type: application/json" \
+ -d "{\"memory_id\":\"$MEM_ID\"}" \
+ --max-time $TIMEOUT 2>/dev/null || echo "000")
+ if [ "$MG_STATUS" = "200" ]; then
+ ok "PF-04" "Memory get endpoint"
+ else
+ ko "PF-04" "Memory get endpoint" "HTTP $MG_STATUS"
+ fi
+else
+ ko "PF-04" "Memory get endpoint" "Could not extract memory ID"
+fi
+
+# PF-05: Vite dev server
+VITE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$VITE_URL" --max-time $TIMEOUT 2>/dev/null || echo "000")
+if [ "$VITE_STATUS" = "200" ]; then
+ ok "PF-05" "Vite dev server"
+else
+ ko "PF-05" "Vite dev server" "HTTP $VITE_STATUS"
+ echo "ABORT: Frontend not running."; exit 1
+fi
+
+echo ""
+echo "=== Preflight: $pass passed, $fail failed ==="
+
+if [ "$fail" -gt 0 ]; then
+ echo "WARNING: Some non-critical checks failed. Review before proceeding."
+ exit 1
+fi
+
+echo "ALL CLEAR: Proceed with Playwright tests."
+exit 0