{group.items.map((item) => {
- const Icon = iconMap[item.icon] || Box
+ const Icon = BUILDER_ICONS[item.icon] || Box
return (
[group.category, group.color])
+)
+
+export const DEFAULT_NODE_COLOR = '#818cf8'
+
+export function categoryColor(category) {
+ return CATEGORY_COLORS[category] || DEFAULT_NODE_COLOR
+}
+
+/** Persisted board node โ React Flow node. */
+export function toFlowNodes(boardNodes = []) {
+ return boardNodes.map((n) => ({
+ id: n.id,
+ type: 'component',
+ position: { x: n.x || 0, y: n.y || 0 },
+ data: { name: n.name, icon: n.icon, category: n.category },
+ }))
+}
+
+/** React Flow node โ persisted board node. */
+export function toBoardNodes(flowNodes = []) {
+ return flowNodes.map((n) => ({
+ id: n.id,
+ name: n.data?.name,
+ icon: n.data?.icon,
+ category: n.data?.category,
+ x: Math.round(n.position?.x || 0),
+ y: Math.round(n.position?.y || 0),
+ }))
+}
+
+/** Style one flow edge from its source node's category color. */
+export function styleFlowEdge(edge, sourceCategory) {
+ const color = categoryColor(sourceCategory)
+ return {
+ ...edge,
+ type: 'default',
+ style: { stroke: color, strokeWidth: 2 },
+ markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color },
+ }
+}
+
+/** Persisted board edge โ React Flow edge (colored by source category). */
+export function toFlowEdges(boardEdges = [], boardNodes = []) {
+ const categoryById = new Map(boardNodes.map((n) => [n.id, n.category]))
+ return boardEdges.map((e) =>
+ styleFlowEdge(
+ {
+ id: e.id,
+ source: e.from,
+ sourceHandle: e.fromAnchor || 'bottom',
+ target: e.to,
+ targetHandle: e.toAnchor || 'top',
+ },
+ categoryById.get(e.from)
+ )
+ )
+}
+
+/** React Flow edge โ persisted board edge. */
+export function toBoardEdges(flowEdges = []) {
+ return flowEdges.map((e) => ({
+ id: e.id,
+ from: e.source,
+ fromAnchor: e.sourceHandle || 'bottom',
+ to: e.target,
+ toAnchor: e.targetHandle || 'top',
+ }))
+}
+
+/** Full board payload for persistence / export / AI verification. */
+export function toBoardData(flowNodes, flowEdges) {
+ return { nodes: toBoardNodes(flowNodes), edges: toBoardEdges(flowEdges) }
+}
diff --git a/src/components/graph/GraphCanvas.jsx b/src/components/graph/GraphCanvas.jsx
index cdc0a92..5173f87 100644
--- a/src/components/graph/GraphCanvas.jsx
+++ b/src/components/graph/GraphCanvas.jsx
@@ -1,14 +1,53 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ZoomIn, ZoomOut, Maximize } from 'lucide-react'
+import { useMemo, useState, useCallback } from 'react'
+import {
+ ReactFlow,
+ Background,
+ BackgroundVariant,
+ Controls,
+ MiniMap,
+ Handle,
+ Position,
+ MarkerType,
+} from '@xyflow/react'
import { ancestorsOf, descendantsOf } from '../../utils/knowledgeGraph'
-import { computeLayout, HEALTH_COLORS } from './graphLayout'
+import { computeLayout, HEALTH_COLORS, NODE_WIDTH, NODE_HEIGHT } from './graphLayout'
-const MIN_ZOOM = 0.25
-const MAX_ZOOM = 2.5
+/** Edge colors per lineage state (SVG markers need literal colors). */
+const EDGE_COLORS = { default: '#52525b', up: '#818cf8', down: '#2dd4bf' }
/**
- * The SVG knowledge-graph canvas: pan, zoom, hover lineage highlighting,
- * and node selection. Pure view โ all data comes from props.
+ * One concept pill: health-colored dot + name, card-count badge,
+ * ready/locked styling, and the numbered badge of the active track.
+ */
+function ConceptNode({ data }) {
+ const color = HEALTH_COLORS[data.health] || HEALTH_COLORS.unseen
+ return (
+
+
+
+ {data.name}
+ {(data.counts?.total ?? 0) > 0 && (
+ {data.counts.total}
+ )}
+ {typeof data.trackPos === 'number' && (
+ {data.trackPos}
+ )}
+
+
+ )
+}
+
+const nodeTypes = { concept: ConceptNode }
+
+/**
+ * The knowledge-graph canvas, powered by React Flow with a dagre
+ * layered layout (prerequisites flow left โ right). Pure view โ all
+ * data comes from props.
*
* @param {Array} nodes - Nodes with health/strength/counts from the API.
* @param {Array} edges - [{ from, to }] prerequisite edges.
@@ -18,12 +57,9 @@ const MAX_ZOOM = 2.5
* @param {Function} onSelect - (nodeId|null) => void
*/
export default function GraphCanvas({ nodes, edges, visibleIds, trackOrder, selectedId, onSelect }) {
- const containerRef = useRef(null)
- const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 })
const [hoverId, setHoverId] = useState(null)
- const dragRef = useRef(null)
- const layout = useMemo(() => computeLayout(nodes, edges), [nodes, edges])
+ const positions = useMemo(() => computeLayout(nodes, edges), [nodes, edges])
// Lineage of the focused node: everything it needs (ancestors) and
// everything it unlocks (descendants).
@@ -36,214 +72,88 @@ export default function GraphCanvas({ nodes, edges, visibleIds, trackOrder, sele
}
}, [focusId, edges])
- const fitToView = useCallback(() => {
- const el = containerRef.current
- if (!el) return
- const rect = el.getBoundingClientRect()
- if (rect.width === 0 || rect.height === 0) return
- const k = Math.min(
- MAX_ZOOM,
- Math.max(MIN_ZOOM, Math.min(rect.width / layout.width, rect.height / layout.height))
+ const flowNodes = useMemo(() => nodes.map((node) => ({
+ id: node.id,
+ type: 'concept',
+ position: positions.get(node.id) || { x: 0, y: 0 },
+ // Explicit dimensions: the graph never applies RF dimension changes,
+ // and the minimap + fitView need node sizes up front.
+ width: NODE_WIDTH,
+ height: NODE_HEIGHT,
+ draggable: false,
+ connectable: false,
+ data: {
+ ...node,
+ nodeId: node.id,
+ filteredOut: Boolean(visibleIds && !visibleIds.has(node.id)),
+ focus: node.id === focusId || node.id === selectedId,
+ inLineage: Boolean(lineage && (lineage.up.has(node.id) || lineage.down.has(node.id))),
+ trackPos: trackOrder?.get(node.id),
+ },
+ })), [nodes, positions, visibleIds, focusId, selectedId, lineage, trackOrder])
+
+ const flowEdges = useMemo(() => edges.map((edge) => {
+ const hidden = Boolean(
+ visibleIds && (!visibleIds.has(edge.from) || !visibleIds.has(edge.to))
)
- setTransform({
- x: (rect.width - layout.width * k) / 2,
- y: (rect.height - layout.height * k) / 2,
- k,
- })
- }, [layout])
-
- // Fit when the layout first resolves.
- useEffect(() => { fitToView() }, [fitToView])
-
- const zoomBy = (factor) => {
- const el = containerRef.current
- if (!el) return
- const rect = el.getBoundingClientRect()
- zoomAt(rect.width / 2, rect.height / 2, factor)
- }
-
- const zoomAt = (cx, cy, factor) => {
- setTransform((t) => {
- const k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, t.k * factor))
- const scale = k / t.k
- return {
- k,
- x: cx - (cx - t.x) * scale,
- y: cy - (cy - t.y) * scale,
- }
- })
- }
-
- const handleWheel = (e) => {
- e.preventDefault()
- const rect = containerRef.current.getBoundingClientRect()
- const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
- zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor)
- }
-
- // React attaches wheel listeners passively; bind manually so
- // preventDefault stops the page from scrolling while zooming.
- useEffect(() => {
- const el = containerRef.current
- if (!el) return
- const wheel = (e) => handleWheel(e)
- el.addEventListener('wheel', wheel, { passive: false })
- return () => el.removeEventListener('wheel', wheel)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
- const handlePointerDown = (e) => {
- if (e.button !== 0) return
- dragRef.current = { startX: e.clientX, startY: e.clientY, tx: transform.x, ty: transform.y, moved: false }
- e.currentTarget.setPointerCapture(e.pointerId)
- }
-
- const handlePointerMove = (e) => {
- const drag = dragRef.current
- if (!drag) return
- const dx = e.clientX - drag.startX
- const dy = e.clientY - drag.startY
- if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true
- setTransform((t) => ({ ...t, x: drag.tx + dx, y: drag.ty + dy }))
- }
-
- const handlePointerUp = (e) => {
- const drag = dragRef.current
- dragRef.current = null
- // A clean click (no drag) on empty canvas clears the selection.
- if (drag && !drag.moved && e.target.tagName === 'svg') onSelect(null)
- }
-
- const isDimmed = (id) => {
- if (visibleIds && !visibleIds.has(id)) return true
- if (lineage && id !== focusId && !lineage.up.has(id) && !lineage.down.has(id)) {
- // Keep filter-visible nodes readable, lineage just glows brighter.
- return false
+ let state = 'default'
+ if (lineage) {
+ const onUpPath =
+ (edge.to === focusId || lineage.up.has(edge.to)) &&
+ (lineage.up.has(edge.from) || edge.from === focusId)
+ const onDownPath =
+ (edge.from === focusId || lineage.down.has(edge.from)) &&
+ (lineage.down.has(edge.to) || edge.to === focusId)
+ state = onUpPath ? 'up' : onDownPath ? 'down' : 'faded'
}
- return false
- }
+ const color = EDGE_COLORS[state] || EDGE_COLORS.default
+ return {
+ id: `${edge.from}->${edge.to}`,
+ source: edge.from,
+ target: edge.to,
+ className: `graph-edge ${state}`,
+ hidden,
+ markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color },
+ }
+ }), [edges, visibleIds, lineage, focusId])
- const edgeState = (edge) => {
- if (visibleIds && (!visibleIds.has(edge.from) || !visibleIds.has(edge.to))) return 'hidden'
- if (!lineage) return 'default'
- const onUpPath =
- (edge.to === focusId || lineage.up.has(edge.to)) &&
- (lineage.up.has(edge.from) || edge.from === focusId)
- const onDownPath =
- (edge.from === focusId || lineage.down.has(edge.from)) &&
- (lineage.down.has(edge.to) || edge.to === focusId)
- if (onUpPath) return 'up'
- if (onDownPath) return 'down'
- return 'faded'
- }
+ const onNodeClick = useCallback((event, node) => {
+ onSelect(node.id === selectedId ? null : node.id)
+ }, [onSelect, selectedId])
- const nodeRadius = (node) => 11 + Math.min(7, (node.counts?.total || 0) * 1.2)
+ const minimapNodeColor = useCallback(
+ (node) => HEALTH_COLORS[node.data?.health] || HEALTH_COLORS.unseen,
+ []
+ )
return (
-
-
-
- {/* Zoom controls */}
-
-
-
-
-
+
+
+
+
)
}
diff --git a/src/components/graph/graphLayout.js b/src/components/graph/graphLayout.js
index 0fedab2..8affe69 100644
--- a/src/components/graph/graphLayout.js
+++ b/src/components/graph/graphLayout.js
@@ -1,90 +1,55 @@
/**
- * @fileoverview Static force-directed layout for the knowledge graph.
+ * @fileoverview Layered layout for the knowledge graph.
*
- * The layout runs d3-force synchronously (no animation loop):
- * - a strong forceX pins each node's column to its prerequisite depth,
- * so learning flows left โ right
- * - a weak forceY pulls nodes toward their pillar's band, keeping
- * related concepts vertically clustered
- * - collision + charge spread nodes apart
- *
- * Initial positions are deterministic, so the same graph always lays
- * out the same way.
+ * Dagre computes a left-to-right Sugiyama layout: every prerequisite
+ * sits in an earlier column than its dependents, and crossing
+ * minimization keeps the edge bundles readable. The layout is
+ * deterministic โ the same graph always lays out the same way.
*/
-import {
- forceSimulation,
- forceLink,
- forceManyBody,
- forceX,
- forceY,
- forceCollide,
-} from 'd3-force'
-import { nodeDepths } from '../../utils/knowledgeGraph'
-import { PILLARS } from '../../utils/constants'
+import dagre from '@dagrejs/dagre'
-const COLUMN_SPACING = 180
-const X_OFFSET = 110
-const BAND_HEIGHT = 130
-const PADDING = 70
+/** Concept pill dimensions used for layout spacing (must match the CSS). */
+export const NODE_WIDTH = 180
+export const NODE_HEIGHT = 44
/**
- * @param {Array} nodes - Graph nodes (need id + pillarId).
+ * @param {Array} nodes - Graph nodes (need id).
* @param {Array} edges - [{ from, to }]
- * @returns {{ positions: Map
, width: number, height: number }}
+ * @returns {Map} Top-left positions per node id.
*/
export function computeLayout(nodes, edges) {
- if (nodes.length === 0) {
- return { positions: new Map(), width: 800, height: 600 }
- }
-
- const depths = nodeDepths(nodes, edges)
- const pillarOrder = new Map(PILLARS.map((p, i) => [p.id, i]))
- const bandCenter = (pillarId) =>
- PADDING + ((pillarOrder.get(pillarId) ?? 3) + 0.5) * BAND_HEIGHT
-
- const simNodes = nodes.map((n, idx) => ({
- id: n.id,
- depth: depths.get(n.id) ?? 0,
- band: bandCenter(n.pillarId),
- // Deterministic starting positions โ no randomness in the layout.
- x: X_OFFSET + (depths.get(n.id) ?? 0) * COLUMN_SPACING,
- y: bandCenter(n.pillarId) + ((idx % 7) - 3) * 14,
- }))
- const simLinks = edges.map((e) => ({ source: e.from, target: e.to }))
-
- const simulation = forceSimulation(simNodes)
- .force('link', forceLink(simLinks).id((d) => d.id).distance(90).strength(0.12))
- .force('charge', forceManyBody().strength(-260))
- .force('x', forceX((d) => X_OFFSET + d.depth * COLUMN_SPACING).strength(0.85))
- .force('y', forceY((d) => d.band).strength(0.08))
- .force('collide', forceCollide(40))
- .stop()
+ const g = new dagre.graphlib.Graph()
+ g.setGraph({
+ rankdir: 'LR',
+ nodesep: 18,
+ ranksep: 80,
+ marginx: 40,
+ marginy: 40,
+ })
+ g.setDefaultEdgeLabel(() => ({}))
- for (let i = 0; i < 300; i++) simulation.tick()
-
- const positions = new Map()
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
- for (const n of simNodes) {
- positions.set(n.id, { x: n.x, y: n.y })
- minX = Math.min(minX, n.x)
- maxX = Math.max(maxX, n.x)
- minY = Math.min(minY, n.y)
- maxY = Math.max(maxY, n.y)
+ for (const node of nodes) {
+ g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
}
-
- // Normalize so everything sits in positive space with padding.
- const dx = PADDING - minX
- const dy = PADDING - minY
- for (const pos of positions.values()) {
- pos.x += dx
- pos.y += dy
+ for (const edge of edges) {
+ if (g.hasNode(edge.from) && g.hasNode(edge.to)) {
+ g.setEdge(edge.from, edge.to)
+ }
}
- return {
- positions,
- width: maxX - minX + PADDING * 2,
- height: maxY - minY + PADDING * 2,
+ dagre.layout(g)
+
+ const positions = new Map()
+ for (const node of nodes) {
+ const placed = g.node(node.id)
+ if (!placed) continue
+ // Dagre returns center coordinates; React Flow wants top-left.
+ positions.set(node.id, {
+ x: placed.x - NODE_WIDTH / 2,
+ y: placed.y - NODE_HEIGHT / 2,
+ })
}
+ return positions
}
/** Node fill/stroke colors per health bucket (matches the CSS legend). */
diff --git a/src/components/shared/ChatPanel.jsx b/src/components/shared/ChatPanel.jsx
index 21b30e3..d02caf5 100644
--- a/src/components/shared/ChatPanel.jsx
+++ b/src/components/shared/ChatPanel.jsx
@@ -175,11 +175,12 @@ function ChatPanelContent({ page, title = 'Ask AI', placeholder = 'Ask a questio
const contextString = (() => {
if (context) return context
if (page === 'builder' && canvasNodes.length > 0) {
- const nodeNames = canvasNodes.map((n) => `${n.name} (${n.category})`).join(', ')
+ // Canvas state is React Flow-shaped: data.{name,category}, source/target
+ const nodeNames = canvasNodes.map((n) => `${n.data?.name} (${n.data?.category})`).join(', ')
const edgeDescs = canvasEdges.map((e) => {
- const fromNode = canvasNodes.find((n) => n.id === e.from)
- const toNode = canvasNodes.find((n) => n.id === e.to)
- return `${fromNode?.name || 'Unknown'} โ ${toNode?.name || 'Unknown'}`
+ const fromNode = canvasNodes.find((n) => n.id === e.source)
+ const toNode = canvasNodes.find((n) => n.id === e.target)
+ return `${fromNode?.data?.name || 'Unknown'} โ ${toNode?.data?.name || 'Unknown'}`
}).join(', ')
let desc = `The user is designing a system architecture with ${canvasNodes.length} components: ${nodeNames}.`
if (canvasEdges.length > 0) {
diff --git a/src/index.css b/src/index.css
index 1d0982d..4b7ec75 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1147,17 +1147,11 @@ textarea.input {
.builder-canvas {
flex: 1;
position: relative;
- background:
- radial-gradient(circle, var(--color-border) 1px, transparent 1px);
- background-size: 24px 24px;
overflow: hidden;
- cursor: grab;
height: 100%;
min-height: 0;
-}
-
-.builder-canvas:active {
- cursor: grabbing;
+ /* Dot grid comes from the React Flow component */
+ background: var(--color-bg-primary);
}
.toolbox-section {
@@ -5370,105 +5364,144 @@ textarea.input {
flex: 1;
position: relative;
overflow: hidden;
- background:
- radial-gradient(circle at 1px 1px, var(--color-border) 1px, transparent 0) 0 0 / 28px 28px,
- var(--color-bg-primary);
- cursor: grab;
+ background: var(--color-bg-primary);
}
-.graph-canvas-wrap:active {
- cursor: grabbing;
+/* โโ Concept pill nodes (React Flow custom nodes) โโ */
+.graph-node {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 180px;
+ height: 44px;
+ padding: 0 12px;
+ background: var(--color-surface);
+ border: 1.5px solid color-mix(in srgb, var(--health-color) 55%, transparent);
+ border-radius: var(--radius-full);
+ box-shadow: var(--shadow-sm);
+ cursor: pointer;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast), opacity var(--duration-fast);
}
-.graph-svg {
- width: 100%;
- height: 100%;
- display: block;
- touch-action: none;
+.graph-node:hover,
+.graph-node.focus {
+ border-color: var(--health-color);
+ box-shadow: var(--shadow-md), 0 0 14px color-mix(in srgb, var(--health-color) 30%, transparent);
}
-.graph-edge {
- fill: none;
- stroke: var(--color-border-strong);
- stroke-width: 1.2;
- opacity: 0.55;
- transition: opacity var(--duration-fast);
+.graph-node.lineage {
+ border-color: var(--health-color);
}
-.graph-edge.faded {
+.graph-node.filtered-out {
opacity: 0.12;
+ pointer-events: none;
}
-.graph-edge.up {
- stroke: #818cf8;
- stroke-width: 2;
- opacity: 0.9;
+.graph-node.ready {
+ outline: 1px dashed var(--color-accent);
+ outline-offset: 3px;
}
-.graph-edge.down {
- stroke: #2dd4bf;
- stroke-width: 2;
- opacity: 0.9;
+.graph-node.locked {
+ border-style: dashed;
}
-.graph-node {
- cursor: pointer;
+.graph-node-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--health-color);
+ flex-shrink: 0;
+ box-shadow: 0 0 6px color-mix(in srgb, var(--health-color) 60%, transparent);
}
-.graph-node.filtered-out {
- opacity: 0.12;
- pointer-events: none;
+.graph-node-name {
+ flex: 1;
+ min-width: 0;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ line-height: 1.2;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
-.graph-node-label {
- font-size: 10px;
- fill: var(--color-text-secondary);
- text-anchor: middle;
- pointer-events: none;
- user-select: none;
+.graph-node:hover .graph-node-name,
+.graph-node.focus .graph-node-name,
+.graph-node.lineage .graph-node-name {
+ color: var(--color-text-primary);
}
-.graph-node.focus .graph-node-label,
-.graph-node:hover .graph-node-label {
- fill: var(--color-text-primary);
+.graph-node-count {
+ flex-shrink: 0;
+ font-size: 9px;
font-weight: 700;
+ font-family: var(--font-mono);
+ color: var(--color-text-tertiary);
+ background: var(--color-bg-tertiary);
+ padding: 1px 6px;
+ border-radius: var(--radius-full);
}
-.graph-node-ready-ring {
- fill: none;
- stroke: var(--color-accent);
- stroke-width: 1;
- stroke-dasharray: 2 3;
- opacity: 0.7;
+.graph-node-track {
+ position: absolute;
+ top: -9px;
+ right: -6px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ color: white;
+ font-size: 10px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: var(--shadow-sm);
}
-.graph-node-selected-ring {
- fill: none;
- stroke: var(--color-accent);
- stroke-width: 1.5;
- opacity: 0.9;
+/* Invisible connection ports โ edges attach here, users never interact */
+.graph-node-port {
+ width: 1px !important;
+ height: 1px !important;
+ min-width: 0 !important;
+ min-height: 0 !important;
+ background: transparent !important;
+ border: none !important;
+ pointer-events: none !important;
}
-.graph-node-track-badge circle {
- fill: var(--color-accent);
+/* Selected node ring (React Flow selection state) */
+.react-flow__node.selected .graph-node {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 3px;
}
-.graph-node-track-badge text {
- font-size: 9px;
- font-weight: 700;
- fill: white;
- text-anchor: middle;
- pointer-events: none;
+/* โโ Prerequisite edges โโ
+ Literal stroke color so the line matches its SVG arrow marker. */
+.react-flow__edge.graph-edge .react-flow__edge-path {
+ stroke: #52525b;
+ stroke-width: 1.4;
+ opacity: 0.8;
}
-.graph-zoom-controls {
- position: absolute;
- bottom: var(--space-4);
- right: var(--space-4);
- display: flex;
- flex-direction: column;
- gap: var(--space-1);
- z-index: 10;
+.react-flow__edge.graph-edge.faded .react-flow__edge-path {
+ opacity: 0.12;
+}
+
+.react-flow__edge.graph-edge.up .react-flow__edge-path {
+ stroke: #818cf8;
+ stroke-width: 2.2;
+ opacity: 0.95;
+}
+
+.react-flow__edge.graph-edge.down .react-flow__edge-path {
+ stroke: #2dd4bf;
+ stroke-width: 2.2;
+ opacity: 0.95;
}
/* Node slide-over panel */
@@ -5797,3 +5830,221 @@ textarea.input {
display: none;
}
}
+
+/* ============================================
+ REACT FLOW โ design-system theme overrides
+ (Builder whiteboard + Knowledge Graph)
+ ============================================ */
+
+/* Builder component nodes */
+.builder-node {
+ position: relative;
+ width: 168px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-surface-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-3);
+ box-shadow: var(--shadow-sm);
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
+}
+
+.builder-node:hover {
+ border-color: color-mix(in srgb, var(--node-color) 60%, transparent);
+}
+
+.builder-node.selected,
+.react-flow__node.selected .builder-node {
+ border-color: var(--node-color);
+ box-shadow: var(--shadow-lg), 0 0 12px color-mix(in srgb, var(--node-color) 20%, transparent);
+}
+
+.builder-node-body {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.builder-node-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--node-color) 10%, transparent);
+ color: var(--node-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.builder-node-name {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--color-text-primary);
+}
+
+.builder-node-delete {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ opacity: 0;
+ cursor: pointer;
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ color: var(--color-text-secondary);
+ transition: opacity var(--duration-fast), color var(--duration-fast);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+}
+
+.builder-node:hover .builder-node-delete {
+ opacity: 0.9;
+}
+
+.builder-node-delete:hover {
+ opacity: 1 !important;
+ color: var(--color-error);
+}
+
+/* Connection handles on builder nodes */
+.builder-node .builder-node-handle {
+ width: 10px;
+ height: 10px;
+ background: var(--node-color);
+ border: 2px solid var(--color-surface);
+ opacity: 0.55;
+ transition: opacity var(--duration-fast), transform var(--duration-fast);
+}
+
+.builder-node:hover .builder-node-handle,
+.react-flow__handle.connectingfrom,
+.react-flow__handle.connectingto {
+ opacity: 1;
+}
+
+.builder-node .builder-node-handle:hover {
+ transform: scale(1.4);
+}
+
+/* Builder edges: hint deletion on hover (click removes) */
+#builder-canvas .react-flow__edge:hover .react-flow__edge-path {
+ stroke: var(--color-error) !important;
+ stroke-dasharray: 6 3;
+ cursor: pointer;
+}
+
+/* Connection preview line */
+.react-flow__connection-path {
+ stroke: var(--color-accent);
+ stroke-width: 2;
+ stroke-dasharray: 6 3;
+}
+
+/* Empty-canvas prompt */
+.builder-canvas-empty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ pointer-events: none;
+ z-index: 5;
+}
+
+/* Toolbar hint text */
+.builder-toolbar-hint {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+}
+
+.builder-toolbar-hint kbd {
+ background: var(--color-bg-tertiary);
+ padding: 1px 5px;
+ border-radius: 3px;
+ border: 1px solid var(--color-border);
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+/* React Flow chrome โ design system */
+.react-flow {
+ background: transparent;
+}
+
+.react-flow__background {
+ color: var(--color-border);
+}
+
+.react-flow__controls {
+ box-shadow: var(--shadow-md);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+}
+
+.react-flow__controls-button {
+ background: var(--color-bg-elevated);
+ border-bottom: 1px solid var(--color-border);
+ color: var(--color-text-secondary);
+ width: 28px;
+ height: 28px;
+}
+
+.react-flow__controls-button:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.react-flow__controls-button svg {
+ fill: currentColor;
+ max-width: 12px;
+ max-height: 12px;
+}
+
+.react-flow__minimap {
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.react-flow__minimap-mask {
+ fill: color-mix(in srgb, var(--color-bg-primary) 60%, transparent);
+}
+
+.react-flow__attribution {
+ background: transparent;
+ color: var(--color-text-disabled);
+ font-size: 9px;
+}
+
+.react-flow__attribution a {
+ color: var(--color-text-disabled);
+}
+
+@media (max-width: 768px) {
+ .react-flow__minimap {
+ display: none;
+ }
+
+ .builder-toolbar-hint {
+ display: none;
+ }
+}
+
+/* The global `img, svg { max-width: 100% }` reset collapses React Flow's
+ absolutely-positioned edge/connection SVGs to zero width โ undo it here. */
+.react-flow svg {
+ max-width: none;
+}
diff --git a/src/main.jsx b/src/main.jsx
index 2898346..da8ca7a 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
+// React Flow base styles load first so index.css overrides win
+import '@xyflow/react/dist/style.css'
import './index.css'
import App from './App.jsx'
diff --git a/src/pages/BuilderPage.jsx b/src/pages/BuilderPage.jsx
index 38af72b..fad49b5 100644
--- a/src/pages/BuilderPage.jsx
+++ b/src/pages/BuilderPage.jsx
@@ -1,9 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
-import {
- MessageSquare, Save, Undo, Redo, ZoomIn, ZoomOut,
- MousePointer, Type, ArrowRight, Palette, Layout, Download, Calculator,
-} from 'lucide-react'
+import { MessageSquare, Save, Layout, Download, Calculator } from 'lucide-react'
import Toolbox from '../components/builder/Toolbox'
import Canvas from '../components/builder/Canvas'
import BoardList from '../components/builder/BoardList'
@@ -12,6 +9,12 @@ import ChatPanel from '../components/shared/ChatPanel'
import useAppStore from '../stores/appStore'
import useIsMobile from '../hooks/useIsMobile'
import { boardsApi } from '../utils/api'
+import {
+ toFlowNodes,
+ toFlowEdges,
+ toBoardData,
+ categoryColor,
+} from '../components/builder/boardModel'
// Auto-save debounce interval (ms)
const AUTO_SAVE_DELAY = 3000
@@ -28,8 +31,6 @@ export default function BuilderPage() {
const [boards, setBoards] = useState([])
const [activeBoard, setActiveBoard] = useState(null)
- const [activeTool, setActiveTool] = useState('select')
- const [zoomLevel, setZoomLevel] = useState(100)
const [showTemplates, setShowTemplates] = useState(false)
const isMobile = useIsMobile()
const autoSaveTimer = useRef(null)
@@ -91,12 +92,14 @@ export default function BuilderPage() {
return
}
- // Load from server
+ // Load from server (persisted board shape โ React Flow shape)
boardsApi.get(activeBoard).then((board) => {
if (board && board.data) {
- setNodes(board.data.nodes || [])
- setEdges(board.data.edges || [])
- lastSavedData.current = JSON.stringify({ nodes: board.data.nodes || [], edges: board.data.edges || [] })
+ const boardNodes = board.data.nodes || []
+ const boardEdges = board.data.edges || []
+ setNodes(toFlowNodes(boardNodes))
+ setEdges(toFlowEdges(boardEdges, boardNodes))
+ lastSavedData.current = JSON.stringify({ nodes: boardNodes, edges: boardEdges })
} else {
setNodes([])
setEdges([])
@@ -111,11 +114,14 @@ export default function BuilderPage() {
}, [activeBoard])
// โโโ Auto-save: debounced save when nodes or edges change โโโ
+ // Compare and persist in the board shape, so selection/drag churn in
+ // the React Flow state never triggers a save on its own.
useEffect(() => {
// Skip local (unsaved) boards
if (!activeBoard || unsavedIds.has(activeBoard)) return
- const currentData = JSON.stringify({ nodes, edges })
+ const boardData = toBoardData(nodes, edges)
+ const currentData = JSON.stringify(boardData)
if (currentData === lastSavedData.current) return
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current)
@@ -124,7 +130,7 @@ export default function BuilderPage() {
const currentBoard = boards.find((b) => b.id === activeBoard)
if (!currentBoard) return
try {
- await boardsApi.update(activeBoard, { name: currentBoard.name, data: { nodes, edges } })
+ await boardsApi.update(activeBoard, { name: currentBoard.name, data: boardData })
lastSavedData.current = currentData
} catch {
// Silent fail for auto-save
@@ -151,7 +157,7 @@ export default function BuilderPage() {
if (!currentBoard) return
const name = currentBoard.name
- const data = { nodes, edges }
+ const data = toBoardData(nodes, edges)
const isLocal = unsavedIds.has(activeBoard)
try {
@@ -279,21 +285,7 @@ export default function BuilderPage() {
// โโโ Canvas Handlers โโโ
- const handleZoomIn = () => {
- const canvas = document.getElementById('builder-canvas')
- if (canvas?._zoomIn) canvas._zoomIn()
- }
-
- const handleZoomOut = () => {
- const canvas = document.getElementById('builder-canvas')
- if (canvas?._zoomOut) canvas._zoomOut()
- }
-
- const handleTransformChange = (t) => {
- setZoomLevel(Math.round(t.scale * 100))
- }
-
- // Load template onto canvas
+ // Load template onto canvas (templates ship in the persisted board shape)
const handleLoadTemplate = (template) => {
const ts = Date.now()
const idMap = {}
@@ -308,18 +300,19 @@ export default function BuilderPage() {
from: idMap[e.from] || e.from,
to: idMap[e.to] || e.to,
}))
- setNodes(newNodes)
- setEdges(newEdges)
+ setNodes(toFlowNodes(newNodes))
+ setEdges(toFlowEdges(newEdges, newNodes))
setShowTemplates(false)
addToast({ type: 'success', message: `Loaded "${template.name}" template` })
}
- // Export board as PNG image
+ // Export board as PNG image (draws from the persisted board shape)
const handleExportImage = useCallback(() => {
const canvasEl = document.getElementById('builder-canvas')
if (!canvasEl) return
try {
+ const { nodes: boardNodes, edges: boardEdges } = toBoardData(nodes, edges)
const rect = canvasEl.getBoundingClientRect()
const canvas2d = document.createElement('canvas')
const scale = 2
@@ -341,17 +334,10 @@ export default function BuilderPage() {
}
}
- const catColors = {
- Compute: '#818cf8',
- Storage: '#34d399',
- Clients: '#60a5fa',
- Observability: '#fbbf24',
- }
-
ctx.scale(scale, scale)
- nodes.forEach((node) => {
- const color = catColors[node.category] || '#818cf8'
+ boardNodes.forEach((node) => {
+ const color = categoryColor(node.category)
const x = node.x || 0
const y = node.y || 0
const w = 140
@@ -376,9 +362,9 @@ export default function BuilderPage() {
ctx.fillText(node.name || 'Node', x + 14, y + h / 2)
})
- edges.forEach((edge) => {
- const fromNode = nodes.find((n) => n.id === edge.from)
- const toNode = nodes.find((n) => n.id === edge.to)
+ boardEdges.forEach((edge) => {
+ const fromNode = boardNodes.find((n) => n.id === edge.from)
+ const toNode = boardNodes.find((n) => n.id === edge.to)
if (!fromNode || !toNode) return
const fromX = (fromNode.x || 0) + 70
@@ -386,7 +372,7 @@ export default function BuilderPage() {
const toX = (toNode.x || 0) + 70
const toY = toNode.y || 0
- const fromColor = catColors[fromNode.category] || '#818cf8'
+ const fromColor = categoryColor(fromNode.category)
ctx.strokeStyle = fromColor + '88'
ctx.lineWidth = 1.5
ctx.beginPath()
@@ -410,13 +396,6 @@ export default function BuilderPage() {
}
}, [nodes, edges, boards, activeBoard, addToast])
- const tools = [
- { id: 'select', icon: MousePointer, label: 'Select' },
- { id: 'text', icon: Type, label: 'Add Text' },
- { id: 'arrow', icon: ArrowRight, label: 'Draw Arrow' },
- { id: 'color', icon: Palette, label: 'Change Color' },
- ]
-
return (
{/* Left: Component toolbox */}
@@ -439,56 +418,10 @@ export default function BuilderPage() {
{/* Toolbar */}
-
- {tools.map((tool) => {
- const Icon = tool.icon
- return (
-
- )
- })}
-
-
-
-
-
-
-
-
-
-
- {zoomLevel}%
+
+
+ Drag to connect anchors ยท Click an edge to remove it ยท โซ deletes selection
-
@@ -536,7 +469,7 @@ export default function BuilderPage() {
{/* Canvas */}
-
+
{showTemplates && (
({
srsVersion: 0,
bumpSrsVersion: () => set((s) => ({ srsVersion: s.srsVersion + 1 })),
- // Canvas Whiteboard Nodes
+ // Whiteboard canvas state (React Flow node/edge shape; the persisted
+ // board format is converted at the boundary โ see builder/boardModel.js)
nodes: [],
setNodes: (nodes) => set({ nodes }),
-
- // Canvas Whiteboard Edges
edges: [],
- setEdges: (edges) => {
- if (typeof edges === 'function') {
- set((s) => ({ edges: edges(s.edges) }))
- } else {
- set({ edges })
- }
- },
- addEdge: (edge) =>
- set((s) => ({ edges: [...s.edges, edge] })),
- removeEdge: (id) =>
- set((s) => ({ edges: s.edges.filter((e) => e.id !== id) })),
+ setEdges: (edges) => set({ edges }),
// Aha! Moment micro-interaction
ahaMomentActive: false,
triggerAhaMoment: () => {
diff --git a/vitest.setup.js b/vitest.setup.js
index 0a2536d..665b928 100644
--- a/vitest.setup.js
+++ b/vitest.setup.js
@@ -37,6 +37,29 @@ window.ResizeObserver = ResizeObserver
// 3. Mock scrollIntoView (unsupported by jsdom, used in ChatPanel message scroll)
Element.prototype.scrollIntoView = vi.fn()
+// 3b. React Flow (@xyflow/react) jsdom requirements โ per the official
+// testing guide: DOMMatrixReadOnly, element dimensions, and SVG getBBox.
+class DOMMatrixReadOnlyMock {
+ constructor(transform) {
+ const scale = transform?.match(/scale\(([\d.]+)\)/)?.[1]
+ this.m22 = scale !== undefined ? +scale : 1
+ }
+}
+globalThis.DOMMatrixReadOnly = DOMMatrixReadOnlyMock
+
+Object.defineProperties(globalThis.HTMLElement.prototype, {
+ offsetHeight: {
+ get() { return parseFloat(this.style.height) || 1 },
+ configurable: true,
+ },
+ offsetWidth: {
+ get() { return parseFloat(this.style.width) || 1 },
+ configurable: true,
+ },
+})
+
+globalThis.SVGElement.prototype.getBBox = () => ({ x: 0, y: 0, width: 0, height: 0 })
+
// 4. Mock global fetch API
globalThis.fetch = vi.fn()