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" ? ( - - ) : ( - - )} -

- {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" ? ( - ); - }) - )} -
+ ) : null} +

+ {zoomLevel === "galaxy" + ? "Communities" + : activeCommunity ?? "Cluster"} +

+ {loading && ( + + )} + +
- {/* Back to galaxy (visible in cluster/neighborhood view) */} - {zoomLevel !== "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 ( + + ); + }) + )} +
+ + {/* Back to galaxy (visible in cluster/neighborhood view) */} + {zoomLevel !== "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)} />
{/* Content */}
{detailError ? ( - /* Error state */ -
- -

Failed to load memory

-

{detailError}

- -
+ 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 */}
- - +
) : ( - /* Loading state */ -
- -

Loading memory...

-
+ )}
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) => ( + /> )} - + 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 ( + + ); + }, +); + +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 ( +
+ +

{message}

+
+ ); +} + +interface ErrorStateProps { + message: string; + detail?: string; + onRetry?: () => void; + children?: ReactNode; +} + +export function ErrorState({ message, detail, onRetry, children }: ErrorStateProps) { + return ( +
+ +

{message}

+ {detail &&

{detail}

} + {onRetry && ( + + )} + {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 ( + + ); +} 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