diff --git a/_bmad-output/implementation-artifacts/2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model.md b/_bmad-output/implementation-artifacts/2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model.md new file mode 100644 index 0000000..e5c6ee3 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model.md @@ -0,0 +1,631 @@ +# Story 2.1: Canvas Workspace with @xyflow/react and Unified Graph Model + +Status: done + + + +## Story + +As a user, +I want to open a diagram and see a professional interactive canvas with a Studio layout, +so that I can view and interact with my diagram visually. + +## Acceptance Criteria + +1. **Given** I navigate to a diagram editor route (`/dashboard/diagram/[id]`), **When** the page loads, **Then** I see the Studio layout: collapsible sidebar (left), canvas area (center), right panel placeholder (320px), **And** the canvas renders using @xyflow/react with a subtle dot grid background, **And** a header bar shows the diagram name (editable), breadcrumb, and status bar (zoom %, diagram type, node count). + +2. **Given** a diagram has graph data stored as JSON, **When** it loads in the editor, **Then** the unified graph model (nodes[] with type discriminator + edges[] with routing metadata) is parsed into @xyflow/react nodes and edges, **And** the diagram renders with correct positions. + +3. **Given** I am on the canvas, **When** I use mouse wheel to zoom and drag to pan, **Then** the canvas responds at 60fps with smooth zoom/pan (NFR1), **And** zoom controls (+/-/reset) are visible in the bottom-right corner. + +4. **Given** the diagram has no data yet (new diagram), **When** the editor loads, **Then** the canvas shows the dot grid background (not blank white), **And** the right panel shows a placeholder for the AI chat (to be built in Epic 3). + +5. **Given** I am in the Studio layout, **When** I press Cmd+B, **Then** the left sidebar toggles between collapsed (icon-only) and expanded states. **When** I press Cmd+J, **Then** the right panel toggles between visible (320px) and hidden. + +## Tasks / Subtasks + +- [x] Task 1: Install @xyflow/react and configure CSS (AC: #1, #3) + - [x] 1.1: Install `@xyflow/react` in `apps/web` + - [x] 1.2: Add `@xyflow/react/dist/style.css` import in the app's global CSS within `@layer base` + - [x] 1.3: Verify no peer dependency conflicts with React 19 and Zustand 5 + +- [x] Task 2: Define unified graph model TypeScript types (AC: #2) + - [x] 2.1: Create `apps/web/src/modules/diagram/types/graph.ts` with `DiagramNode`, `DiagramEdge`, `DiagramMeta` types matching architecture Decision 1 (hybrid schema with type prefixes) + - [x] 2.2: Create converter functions `graphToFlow()` and `flowToGraph()` to transform between the unified graph model and @xyflow/react's `Node[]`/`Edge[]` format in `apps/web/src/modules/diagram/lib/graph-converter.ts` + +- [x] Task 3: Build Studio layout shell for the diagram editor (AC: #1, #4, #5) + - [x] 3.1: Refactor `apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx` — extract a server component that fetches diagram data, wraps a client `DiagramEditor` component + - [x] 3.2: Create `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` — the main Studio layout: left sidebar area, center canvas, right panel placeholder + - [x] 3.3: Create `apps/web/src/modules/diagram/components/editor/EditorHeader.tsx` — 48px header with diagram name (inline editable), breadcrumb (dashboard > diagram name), and diagram type badge + - [x] 3.4: Create `apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx` — 28px status bar with zoom %, diagram type indicator, node count + - [x] 3.5: Create `apps/web/src/modules/diagram/components/editor/RightPanel.tsx` — 320px collapsible right panel with placeholder tabs (Chat | Inspector | Annotations) + - [x] 3.6: Implement keyboard shortcuts: Cmd+B toggle sidebar, Cmd+J toggle right panel + - [x] 3.7: Apply design tokens: `--canvas-bg`, `--canvas-grid`, `--node-bg`, `--node-border` as CSS custom properties + +- [x] Task 4: Implement the canvas with @xyflow/react (AC: #1, #2, #3, #4) + - [x] 4.1: Create `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` — `ReactFlowProvider` + `ReactFlow` wrapper with Background (dot grid), Controls (zoom +/-/reset), MiniMap + - [x] 4.2: Wire canvas to diagram's `graphData` — load from API response, convert via `graphToFlow()`, render as nodes/edges + - [x] 4.3: Handle empty diagrams — show dot grid background with no nodes, right panel shows "Start a conversation" placeholder + - [x] 4.4: Configure @xyflow/react: `fitView`, `colorMode` based on system theme, selection support, zoom/pan defaults + +- [x] Task 5: Create Zustand store for graph state (AC: #2, #3) + - [x] 5.1: Create `apps/web/src/modules/diagram/stores/useGraphStore.ts` — local Zustand store (no Liveblocks yet, that's Epic 4) managing nodes, edges, viewport state + - [x] 5.2: Wire `onNodesChange`, `onEdgesChange` to Zustand store via `applyNodeChanges`/`applyEdgeChanges` + - [x] 5.3: Expose `zoomLevel`, `nodeCount`, `diagramType` as derived state for the status bar + +- [x] Task 6: Tests (AC: all) + - [x] 6.1: Unit tests for `graphToFlow()` and `flowToGraph()` converter functions with all 6 diagram types + - [x] 6.2: Unit tests for Zustand store actions (add/update/remove nodes, zoom tracking) + - [x] 6.3: Verify existing 141 tests still pass + +## Dev Notes + +### Overview — What This Story Builds + +This is the foundation story for the entire canvas experience. It replaces the current placeholder page (`"The diagram editor canvas will be implemented in Epic 2."`) with a professional Studio layout containing an interactive @xyflow/react canvas. **No diagram type renderers yet** (those are Stories 2.3-2.8) — this story uses default node rendering. **No AI chat yet** (Epic 3) — the right panel shows a placeholder. **No Liveblocks/CRDT yet** (Epic 4) — graph state lives in a local Zustand store. + +### Architecture Compliance + +**MANDATORY patterns from Architecture Decision Document:** + +1. **Unified Graph Data Model (Decision 1):** Nodes use type-prefixed `type` field (`bpmn:activity`, `er:entity`, `flow:decision`, etc.). Edges use `from`/`to` (not `source`/`target`) in the stored model. The converter functions bridge between the lean stored format and @xyflow/react's required format. + +2. **Zustand Store Pattern:** Follow the architecture's `useGraphStore` pattern. For this story, use a plain Zustand store (no Liveblocks middleware — that's Story 4.1). The store shape should be forward-compatible with Liveblocks: nodes as a `Map`, edges as a `Map`. + +3. **Component Structure:** Feature code in `~/modules/diagram/`, NOT co-located in route directories. The page.tsx stays minimal — it fetches data and renders ``. + +4. **Design Tokens:** Must use the oklch design tokens from the UX spec (listed below). Apply via Tailwind CSS custom properties. + +### Unified Graph Data Model — Types + +Create `apps/web/src/modules/diagram/types/graph.ts`: + +```typescript +/** Stored in DB as jsonb in diagram.graphData and later in Liveblocks CRDT */ + +export type DiagramType = "bpmn" | "er" | "orgchart" | "architecture" | "sequence" | "flowchart"; + +/** Column definition for E-R entities */ +export interface Column { + name: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + isNullable?: boolean; + isUnique?: boolean; + references?: string; // "tableName.columnName" +} + +/** Core node — hybrid schema per Architecture Decision 1 */ +export interface DiagramNode { + id: string; + type: string; // prefixed: "bpmn:activity", "er:entity", "flow:decision", etc. + tag?: string; // short header/category + label: string; // description text + icon?: string; // emoji or icon identifier + color?: string; // hex color + w?: number; // content width hint (only layout hint stored) + // Position override (set when user manually repositions) + position?: { x: number; y: number }; + // BPMN-specific + lane?: string; + group?: string; + // E-R-specific + columns?: Column[]; + // Sequence-specific + lifeline?: boolean; + // Shared optional + parentId?: string; // for nested diagrams +} + +/** Core edge */ +export interface DiagramEdge { + id: string; + from: string; // source node id + to: string; // target node id + label?: string; + color?: string; + type?: string; // "sequence", "message", "association", "inheritance" + // E-R-specific + cardinality?: string; // "1:N", "N:M", etc. +} + +/** Diagram metadata */ +export interface DiagramMeta { + version: string; // schema version, e.g. "1.0" + title: string; + description?: string; + diagramType: DiagramType; + layoutDirection?: "DOWN" | "RIGHT" | "LEFT" | "UP"; + edgeRouting?: "ORTHOGONAL" | "SPLINES" | "POLYLINE"; +} + +/** Complete graph data stored in diagram.graphData jsonb */ +export interface GraphData { + meta?: DiagramMeta; + nodes: DiagramNode[]; + edges: DiagramEdge[]; + // BPMN extensions + pools?: Array<{ id: string; label: string; lanes: Array<{ id: string; label: string }> }>; + groups?: Array<{ id: string; label: string; color?: string }>; +} +``` + +### Graph Converter — graphToFlow / flowToGraph + +Create `apps/web/src/modules/diagram/lib/graph-converter.ts`: + +```typescript +import type { Node, Edge } from "@xyflow/react"; +import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph"; + +/** Default position for nodes without manual position override */ +const DEFAULT_POSITION = { x: 0, y: 0 }; + +/** Convert stored DiagramNode to @xyflow/react Node */ +export function graphNodeToFlowNode(node: DiagramNode): Node { + return { + id: node.id, + type: "default", // Custom node types registered in Stories 2.3-2.8 + position: node.position ?? DEFAULT_POSITION, + data: { + label: node.label, + // Preserve all original data for type-specific renderers + ...node, + }, + }; +} + +/** Convert stored DiagramEdge to @xyflow/react Edge */ +export function graphEdgeToFlowEdge(edge: DiagramEdge): Edge { + return { + id: edge.id, + source: edge.from, + target: edge.to, + label: edge.label, + type: "default", + data: { ...edge }, + }; +} + +/** Convert full GraphData to @xyflow/react format */ +export function graphToFlow(data: GraphData): { nodes: Node[]; edges: Edge[] } { + return { + nodes: (data.nodes ?? []).map(graphNodeToFlowNode), + edges: (data.edges ?? []).map(graphEdgeToFlowEdge), + }; +} + +/** Convert @xyflow/react Node back to stored DiagramNode */ +export function flowNodeToGraphNode(node: Node): DiagramNode { + const { label, id: _id, position: _pos, ...rest } = node.data as DiagramNode & { label: string }; + return { + id: node.id, + type: (node.data as DiagramNode).type ?? "flow:process", + label, + position: node.position, + ...rest, + }; +} + +/** Convert @xyflow/react Edge back to stored DiagramEdge */ +export function flowEdgeToGraphEdge(edge: Edge): DiagramEdge { + return { + id: edge.id, + from: edge.source, + to: edge.target, + label: typeof edge.label === "string" ? edge.label : undefined, + type: (edge.data as DiagramEdge)?.type, + color: (edge.data as DiagramEdge)?.color, + cardinality: (edge.data as DiagramEdge)?.cardinality, + }; +} + +/** Convert full @xyflow/react state back to GraphData */ +export function flowToGraph(nodes: Node[], edges: Edge[], meta?: GraphData["meta"]): GraphData { + return { + meta, + nodes: nodes.map(flowNodeToGraphNode), + edges: edges.map(flowEdgeToGraphEdge), + }; +} +``` + +### Design Tokens — CSS Custom Properties + +Add to the app's global CSS file (likely `apps/web/src/app/globals.css` or equivalent): + +```css +@layer base { + @import "@xyflow/react/dist/style.css"; + + :root { + /* Canvas */ + --canvas-bg: oklch(0.985 0.002 247); + --canvas-grid: oklch(0.92 0.004 286 / 30%); + /* Nodes */ + --node-bg: oklch(1 0 0); + --node-border: oklch(0.85 0.01 260); + --node-selected: oklch(0.623 0.214 260); + --node-hover: oklch(0.623 0.214 260 / 12%); + /* Edges */ + --edge-default: oklch(0.65 0.01 286); + --edge-selected: oklch(0.623 0.214 260); + /* AI (placeholders for future epics) */ + --ai-accent: oklch(0.623 0.214 260); + /* Diagram type accents */ + --diagram-bpmn: oklch(0.623 0.214 260); + --diagram-er: oklch(0.606 0.25 293); + --diagram-orgchart: oklch(0.723 0.219 150); + --diagram-architecture: oklch(0.552 0.016 286); + --diagram-sequence: oklch(0.795 0.184 86); + --diagram-flowchart: oklch(0.645 0.246 16); + } + + .dark { + --canvas-bg: oklch(0.16 0.005 285); + --node-bg: oklch(0.24 0.006 286); + /* Other dark mode overrides will match the UX spec */ + } +} +``` + +### Studio Layout — Structure + +The editor page must replace the existing TurboStarter dashboard shell (`SidebarProvider` + `DashboardSidebar` + `DashboardInset`) with a custom Studio layout for the diagram editor. The diagram editor route should NOT render inside the standard dashboard shell — it needs the full viewport for the three-panel canvas experience. + +**Approach:** Create a separate layout for the diagram editor route that does NOT inherit the dashboard sidebar. The diagram `[id]/page.tsx` should be a Server Component that fetches diagram data and renders the client `DiagramEditor`. + +``` +/dashboard/diagram/[id]/page.tsx (Server Component) + └── DiagramEditor.tsx (Client Component - full viewport) + ├── EditorHeader (48px) + ├── Main area (flex-1) + │ ├── DiagramSidebar (56px collapsed / 240px expanded) + │ ├── DiagramCanvas (flex-1) + │ └── RightPanel (320px, collapsible) + └── EditorStatusBar (28px) +``` + +**CRITICAL:** The diagram editor route at `apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/` currently lives under the `(user)` layout which renders `DashboardSidebar`. To get a full-viewport Studio layout, you need to either: +- Move the diagram route outside `(user)` to avoid the dashboard shell, OR +- Create a `layout.tsx` at the `diagram/[id]/` level that overrides the parent layout + +The cleanest approach: Keep it under `(user)` for auth protection, but the `DiagramEditor` component renders as a fixed full-viewport overlay that covers the dashboard shell. Use `fixed inset-0 z-50` positioning. + +### Component Implementation Notes + +**DiagramEditor.tsx** — Main wrapper: +- Fixed full-viewport (`fixed inset-0 z-50 bg-[var(--canvas-bg)]`) +- Manages panel collapse state via local React state +- Registers keyboard shortcuts (Cmd+B, Cmd+J) via `useEffect` with keydown listener +- Receives diagram data as prop from server component + +**DiagramCanvas.tsx** — @xyflow/react wrapper: +- `'use client'` directive required +- **CRITICAL:** Parent element must have explicit width and height (`w-full h-full`) +- `` wraps `` — provider enables hooks in child components +- Props: `nodes`, `edges`, `onNodesChange`, `onEdgesChange`, `fitView`, `colorMode` +- Sub-components: ``, ``, `` +- `nodeTypes` object defined OUTSIDE the component (memoization critical for performance) + +**EditorHeader.tsx** — Header bar: +- Reuse existing inline rename pattern from the current page.tsx (move the rename logic here) +- Breadcrumb: "Dashboard > [Diagram Name]" with link back to `/dashboard/diagrams` +- Presence avatar stack placeholder (for Epic 4) +- Share button placeholder (for Epic 6) + +**EditorStatusBar.tsx** — Status bar: +- Reads `zoomLevel` and `nodeCount` from Zustand store +- Displays diagram type badge (reuse `diagramTypeConfig` from `DiagramCard.tsx`) +- Zoom % calculated from @xyflow/react viewport transform + +**RightPanel.tsx** — Collapsible right panel: +- 320px width, `xl:360px` +- Tab headers: Chat | Inspector | Annotations (all placeholder content for now) +- Chat tab shows: "AI Copilot coming soon" message +- Collapsible with smooth transition (200ms ease-out) + +### Zustand Store — useGraphStore + +Create `apps/web/src/modules/diagram/stores/useGraphStore.ts`: + +```typescript +import { create } from "zustand"; +import { applyNodeChanges, applyEdgeChanges } from "@xyflow/react"; +import type { Node, Edge, OnNodesChange, OnEdgesChange, Viewport } from "@xyflow/react"; + +interface GraphState { + // Graph data + nodes: Node[]; + edges: Edge[]; + // Viewport state + viewport: Viewport; + // Derived + nodeCount: number; + zoomLevel: number; + // Actions + setNodes: (nodes: Node[]) => void; + setEdges: (edges: Edge[]) => void; + onNodesChange: OnNodesChange; + onEdgesChange: OnEdgesChange; + onViewportChange: (viewport: Viewport) => void; + // Initialization + initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void; +} + +export const useGraphStore = create((set, get) => ({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + nodeCount: 0, + zoomLevel: 100, + + setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }), + setEdges: (edges) => set({ edges }), + + onNodesChange: (changes) => { + const updatedNodes = applyNodeChanges(changes, get().nodes); + set({ nodes: updatedNodes, nodeCount: updatedNodes.length }); + }, + + onEdgesChange: (changes) => { + set({ edges: applyEdgeChanges(changes, get().edges) }); + }, + + onViewportChange: (viewport) => { + set({ viewport, zoomLevel: Math.round(viewport.zoom * 100) }); + }, + + initializeFromGraphData: (nodes, edges) => { + set({ nodes, edges, nodeCount: nodes.length }); + }, +})); +``` + +**Forward-compatibility note:** When Story 4.1 (Liveblocks integration) is implemented, this store will be wrapped with the Liveblocks Zustand middleware. The store shape is designed to be compatible: nodes/edges as arrays that map to LiveMap collections. + +### Existing Code to Reuse / Modify + +| File | Action | What | +|------|--------|------| +| `apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx` | **REPLACE** | Current placeholder → server component that fetches diagram + renders `` | +| `apps/web/src/modules/diagram/components/DiagramCard.tsx` | **REUSE** | `diagramTypeConfig`, `timeAgo()`, `DiagramResponse` type — import from here | +| `apps/web/src/config/paths.ts` | **READ ONLY** | Path `pathsConfig.dashboard.user.diagram(id)` already exists | +| `packages/api/src/modules/diagram/router.ts` | **READ ONLY** | `GET /diagrams/:id` endpoint already returns diagram with `graphData` | +| `packages/db/src/schema/diagram.ts` | **READ ONLY** | `graphData: jsonb().$type().default({})` — stores the unified graph model | +| `apps/web/src/lib/api/client.tsx` | **READ ONLY** | Hono RPC client via `api.diagrams[":id"].$get(...)` | + +### Library & Framework Requirements + +| Package | Version | Purpose | Install Command | +|---------|---------|---------|-----------------| +| `@xyflow/react` | ^12.10.1 | Canvas rendering (nodes, edges, viewport, controls) | `pnpm --filter web add @xyflow/react` | + +**Already installed:** +- `zustand` 5.0.8 (in `apps/web`) — for graph state management +- `@tanstack/react-query` (catalog) — for diagram API data fetching +- `sonner` 2.0.7 — for toast notifications + +**NOT needed yet:** +- `@liveblocks/*` — Story 4.1 +- `elkjs` — Story 2.2 +- `@deepgram/sdk` — Story 5.1 + +### @xyflow/react v12 — Key Implementation Details + +**Package name:** `@xyflow/react` (NOT the old `reactflow`) + +**All imports are named exports:** +```typescript +import { ReactFlow, ReactFlowProvider, MiniMap, Controls, Background, Panel } from "@xyflow/react"; +``` + +**CSS import (for Tailwind CSS 4):** +```css +@layer base { + @import "@xyflow/react/dist/style.css"; +} +``` + +**CRITICAL — Parent container must have explicit dimensions:** +```tsx +
+ +
+``` + +**CRITICAL — `nodeTypes` must be defined OUTSIDE the component:** +```tsx +const nodeTypes = { /* ... */ }; // Outside component to prevent re-renders + +function DiagramCanvas() { + return ; +} +``` + +**v12 API changes from v11:** +- `node.parentNode` → `node.parentId` +- `node.width/height` (measured) → `node.measured.width/height` +- `updateEdge()` → `reconnectEdge()` +- No object mutation — always spread/create new objects for state updates + +**Dark mode:** Use `colorMode="dark"` prop on ``, or `colorMode="system"` to auto-detect. + +**Custom node props type:** +```typescript +import type { Node, NodeProps } from "@xyflow/react"; +type MyNode = Node; +function MyNode({ data }: NodeProps) { ... } +``` + +### File Structure for This Story + +New files: +``` +apps/web/src/modules/diagram/ +├── types/ +│ └── graph.ts # DiagramNode, DiagramEdge, GraphData types +├── lib/ +│ └── graph-converter.ts # graphToFlow / flowToGraph converters +├── stores/ +│ └── useGraphStore.ts # Zustand store for graph state +└── components/ + └── editor/ + ├── DiagramEditor.tsx # Main Studio layout wrapper + ├── DiagramCanvas.tsx # @xyflow/react canvas + ├── EditorHeader.tsx # 48px header bar + ├── EditorStatusBar.tsx # 28px status bar + └── RightPanel.tsx # 320px collapsible right panel +``` + +Modified files: +``` +apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx # Replace placeholder +apps/web/src/app/globals.css (or equivalent) # Add design tokens + xyflow CSS +apps/web/package.json # Add @xyflow/react dependency +pnpm-lock.yaml # Updated lockfile +``` + +### Project Structure Notes + +- All new files follow the `~/modules/diagram/` convention (NOT co-located in route dirs) +- Types go in `types/` subfolder, utilities in `lib/`, state in `stores/`, UI in `components/editor/` +- The `editor/` subfolder under `components/` separates canvas-specific components from the existing dashboard components (DiagramCard, DiagramGrid, sidebar, etc.) +- Path aliases: use `~/modules/diagram/...` for imports within `apps/web` + +### Anti-Patterns to Avoid + +- **NEVER put `nodeTypes` inside the component** — causes re-renders on every state change, killing canvas performance +- **NEVER use `require()` or CommonJS** — all packages are ESM-only +- **NEVER import from `reactflow`** — the package is `@xyflow/react` (v12+) +- **NEVER use `useRef` to store nodes/edges** — use Zustand store (or @xyflow's built-in state) +- **NEVER create a canvas without explicit parent dimensions** — ReactFlow silently fails +- **NEVER use `node.width`/`node.height` for measured dimensions** — use `node.measured.width`/`node.measured.height` in v12 +- **NEVER mutate node/edge objects directly** — always create new objects via spread +- **NEVER co-locate feature code in route directories** — use `~/modules/diagram/` +- **DO NOT break existing 141 tests** — run full test suite after changes +- **DO NOT implement custom node renderers** — those are Stories 2.3-2.8 (use `"default"` type for now) +- **DO NOT implement Liveblocks/CRDT** — that's Story 4.1 (local Zustand store only) +- **DO NOT implement ELK.js layout** — that's Story 2.2 +- **DO NOT implement AI chat** — that's Epic 3 (right panel is a placeholder) + +### Previous Story Intelligence (Story 1.4) + +**Key learnings to carry forward:** +- `DropdownMenu` preferred over `ContextMenu` for action menus (more discoverable, accessible) +- Hono RPC client pattern: `api.diagrams[":id"].$get({ param: { id } })` for fetching +- `toast()` from `sonner` for user feedback on mutations +- React Query invalidation: `queryClient.invalidateQueries({ queryKey: ["diagrams"] })` +- `DiagramResponse` type exported from `DiagramCard.tsx` — reuse for data typing +- `diagramTypeConfig` object has icons and colors for all 6 types — reuse in status bar +- `timeAgo()` utility exported from `DiagramCard.tsx` — reuse if needed +- 141 tests currently pass — don't break them +- The current diagram editor page already handles: loading state, 403 forbidden, 404 not found, inline rename. **Preserve all this behavior** in the refactored version. + +### Git Intelligence + +Recent commits (all Epic 1): +- `098f496 feat: implement Story 1.4 — recent view and drag-and-drop organization` +- `e9cd685 feat: implement Story 1.3 — diagram access control and management` +- `85e06c2 feat: implement Story 1.2 — organize diagrams into projects` +- `392da38 feat: implement Story 1.1 — create and view diagrams` + +Established patterns: +- Commit message: `feat: implement Story X.Y — description` +- Feature code in `apps/web/src/modules/diagram/` +- Co-located tests or in `packages/api/tests/` +- Zod schemas exported from router files + +### Latest Tech Information + +**@xyflow/react 12.10.1 (current stable, February 2026):** +- Peer deps: `react >=17` — React 19 is fully supported +- Internal dep: `zustand ^4.4.0` — this is xyflow's internal Zustand, separate from your app's Zustand 5.x. Both coexist safely (xyflow's is bundled internally) +- CSS import: `@xyflow/react/dist/style.css` (full) or `@xyflow/react/dist/base.css` (minimal) +- `colorMode` prop: `"light"` | `"dark"` | `"system"` — applies appropriate CSS classes +- `ReactFlowProvider` required when using hooks like `useReactFlow()` in child components +- New v12 hooks: `useHandleConnections`, `useNodesData`, `useReactFlow().updateNode()` +- `nodrag` and `nowheel` CSS classes prevent drag/scroll interference on interactive elements inside custom nodes + +**Next.js 16 compatibility notes:** +- `'use client'` directive required on all @xyflow/react components +- `params` is async in Next.js 16 — use `const { id } = await params;` in server components +- Turbopack (default in Next.js 16) works with @xyflow/react without issues + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.1] — Full AC and technical notes +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model (hybrid schema) +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 2] — Liveblocks Storage structure (LiveMap) +- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules +- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns] — Naming, structure rules +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Studio Layout] — Three-panel layout (48px header, 56/240px sidebar, 320px right panel, 28px status bar) +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Design Tokens] — oklch color values for canvas, nodes, edges +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Responsive Strategy] — Desktop 1024px+, Tablet 768-1023px +- [Source: _bmad-output/project-context.md] — 62 critical implementation rules +- [Source: _bmad-output/implementation-artifacts/1-4-recent-view-and-drag-and-drop-organization.md] — Previous story learnings +- [Source: packages/db/src/schema/diagram.ts] — Current diagram table schema (graphData jsonb) +- [Source: packages/api/src/modules/diagram/router.ts] — GET /diagrams/:id endpoint +- [Source: apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx] — Current placeholder to replace +- [Source: apps/web/src/modules/diagram/components/DiagramCard.tsx] — diagramTypeConfig, DiagramResponse type +- [Source: apps/web/src/config/paths.ts] — pathsConfig.dashboard.user.diagram(id) +- [Source: @xyflow/react v12 documentation] — API reference, migration guide, custom nodes +- [Source: npm @xyflow/react@12.10.1] — Latest stable version, peer deps + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 + +### Debug Log References + +- Fixed TypeScript errors in graph-converter.ts: `node.data` is `Record` in @xyflow/react v12, required `as unknown as` casts for type safety +- Fixed `label` property ordering in `graphNodeToFlowNode` — spread operator must come before explicit `label` to avoid TS2783 +- Code review fix H1: Replaced shared mutable DEFAULT_POSITION with inline `{ x: 0, y: 0 }` per node +- Code review fix H2: Added Array.isArray() runtime guards for graphData parsing from DB jsonb +- Code review fix H3: Added `reset()` action to useGraphStore + cleanup on unmount to prevent stale state +- Code review fix H4: vitest.config.ts now extends `@turbostarter/vitest-config/base` via `mergeConfig()` +- Code review fix M2: Removed `renameMutation` from useCallback deps to prevent re-creation every render +- Code review fix M3: Moved tests to co-located paths (`src/modules/diagram/lib/` and `stores/`) +- Code review fix L3: RightPanel now uses width transition animation (200ms ease-out) instead of conditional rendering + +### Completion Notes List + +- Installed @xyflow/react 12.10.1 with no peer dependency conflicts (React 19 supported, internal zustand ^4.4.0 coexists with app's zustand 5.x) +- Added design tokens (oklch colors for canvas, nodes, edges, diagram type accents) to globals.css with light/dark mode support +- Created unified graph model types (DiagramNode, DiagramEdge, DiagramMeta, GraphData) matching Architecture Decision 1 +- Created bidirectional converter functions (graphToFlow/flowToGraph) bridging stored format (from/to) and @xyflow/react format (source/target) +- Built Studio layout with fixed full-viewport overlay (z-50) covering dashboard shell while preserving auth protection +- Implemented DiagramEditor, EditorHeader (inline rename, breadcrumb, type badge), EditorStatusBar (zoom %, node count), RightPanel (3 tabs), DiagramCanvas (@xyflow/react with dot grid, controls, minimap) +- Keyboard shortcuts: Cmd+B toggles sidebar, Cmd+J toggles right panel +- Zustand store (useGraphStore) manages nodes, edges, viewport with derived zoomLevel and nodeCount — forward-compatible with Liveblocks middleware +- 25 new tests (15 converter + 10 store) all passing, 141 existing tests unaffected +- TypeScript compiles clean with no errors + +### Change Log + +- 2026-02-24: Story 2.1 implemented — Canvas workspace with @xyflow/react and unified graph model +- 2026-02-24: Code review fixes applied — 4 High, 2 Medium, 1 Low issues resolved; status → done + +### File List + +New files: +- apps/web/src/modules/diagram/types/graph.ts +- apps/web/src/modules/diagram/lib/graph-converter.ts +- apps/web/src/modules/diagram/stores/useGraphStore.ts +- apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx +- apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx +- apps/web/src/modules/diagram/components/editor/EditorHeader.tsx +- apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx +- apps/web/src/modules/diagram/components/editor/RightPanel.tsx +- apps/web/src/modules/diagram/lib/graph-converter.test.ts +- apps/web/src/modules/diagram/stores/useGraphStore.test.ts +- apps/web/vitest.config.ts + +Modified files: +- apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx (replaced placeholder with DiagramEditor integration) +- apps/web/src/assets/styles/globals.css (added @xyflow/react CSS + design tokens) +- apps/web/package.json (added @xyflow/react dependency, vitest + @turbostarter/vitest-config devDependencies, test script) +- pnpm-lock.yaml (updated lockfile) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index eb20bce..c490384 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -49,8 +49,8 @@ development_status: epic-1-retrospective: optional # ── Epic 2: Interactive Canvas & Diagram Types (Phase 2) ── - epic-2: backlog - 2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: backlog + epic-2: in-progress + 2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done 2-2-elk-js-auto-layout-engine-in-web-worker: backlog 2-3-bpmn-diagram-type-renderer: backlog 2-4-entity-relationship-diagram-type-renderer: backlog diff --git a/apps/web/package.json b/apps/web/package.json index 3faa62f..bf54766 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "start": "next start", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "prettier": "@turbostarter/prettier-config", @@ -39,6 +40,7 @@ "@turbostarter/shared": "workspace:*", "@turbostarter/ui": "workspace:*", "@turbostarter/ui-web": "workspace:*", + "@xyflow/react": "12.10.1", "accept-language": "3.0.20", "dayjs": "1.11.19", "envin": "catalog:", @@ -77,6 +79,8 @@ "autoprefixer": "10.4.21", "eslint": "catalog:", "prettier": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@turbostarter/vitest-config": "workspace:*", + "vitest": "catalog:" } } diff --git a/apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx index f8b938c..73066ce 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx @@ -1,13 +1,11 @@ "use client"; -import { useRef, useEffect } from "react"; -import { useState } from "react"; import { useParams } from "next/navigation"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; +import { useQuery } from "@tanstack/react-query"; import { Icons } from "@turbostarter/ui-web/icons"; -import { Input } from "@turbostarter/ui-web/input"; + import { api } from "~/lib/api/client"; +import { DiagramEditor } from "~/modules/diagram/components/editor/DiagramEditor"; class DiagramError extends Error { constructor( @@ -21,18 +19,18 @@ class DiagramError extends Error { export default function DiagramEditorPage() { const params = useParams<{ id: string }>(); - const queryClient = useQueryClient(); - - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - const inputRef = useRef(null); const { data, isLoading, error } = useQuery({ queryKey: ["diagram", params.id], queryFn: async () => { - const res = await api.diagrams[":id"].$get({ param: { id: params.id } }); + const res = await api.diagrams[":id"].$get({ + param: { id: params.id }, + }); if (res.status === 403) { - throw new DiagramError("forbidden", "You don't have access to this diagram"); + throw new DiagramError( + "forbidden", + "You don't have access to this diagram", + ); } if (!res.ok) { throw new DiagramError("not-found", "Diagram not found"); @@ -45,47 +43,8 @@ export default function DiagramEditorPage() { }, }); - const errorType = error instanceof DiagramError ? error.type : error ? "not-found" : null; - const title = data?.data?.title ?? "Diagram"; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const renameMutation = useMutation({ - mutationFn: async (newTitle: string) => { - const res = await api.diagrams[":id"].$patch({ - param: { id: params.id }, - json: { title: newTitle }, - }); - if (!res.ok) throw new Error("Failed to rename diagram"); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["diagram", params.id] }); - queryClient.invalidateQueries({ queryKey: ["diagrams"] }); - toast.success("Diagram renamed"); - }, - onError: () => { - toast.error("Failed to rename diagram"); - }, - }); - - const handleSaveRename = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== title) { - renameMutation.mutate(trimmed); - } - setIsEditing(false); - }; - - const handleCancelRename = () => { - setEditValue(title); - setIsEditing(false); - }; + const errorType = + error instanceof DiagramError ? error.type : error ? "not-found" : null; if (isLoading) { return ( @@ -117,43 +76,5 @@ export default function DiagramEditorPage() { ); } - return ( -
- -
- {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSaveRename} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSaveRename(); - } else if (e.key === "Escape") { - handleCancelRename(); - } - }} - className="text-xl font-semibold text-center" - maxLength={255} - /> - ) : ( -

{ - setEditValue(title); - setIsEditing(true); - }} - title="Click to rename" - > - {title} -

- )} -

- The diagram editor canvas will be implemented in Epic 2. -

-
-
- ); + return ; } diff --git a/apps/web/src/assets/styles/globals.css b/apps/web/src/assets/styles/globals.css index 9c4b4e3..92de7c9 100644 --- a/apps/web/src/assets/styles/globals.css +++ b/apps/web/src/assets/styles/globals.css @@ -2,3 +2,41 @@ @import "@turbostarter/ui-web/globals.css"; @source "../../../../../packages/ui"; + +@layer base { + @import "@xyflow/react/dist/style.css"; + + :root { + /* Canvas */ + --canvas-bg: oklch(0.985 0.002 247); + --canvas-grid: oklch(0.92 0.004 286 / 30%); + /* Nodes */ + --node-bg: oklch(1 0 0); + --node-border: oklch(0.85 0.01 260); + --node-selected: oklch(0.623 0.214 260); + --node-hover: oklch(0.623 0.214 260 / 12%); + /* Edges */ + --edge-default: oklch(0.65 0.01 286); + --edge-selected: oklch(0.623 0.214 260); + /* AI (placeholders for future epics) */ + --ai-accent: oklch(0.623 0.214 260); + /* Diagram type accents */ + --diagram-bpmn: oklch(0.623 0.214 260); + --diagram-er: oklch(0.606 0.25 293); + --diagram-orgchart: oklch(0.723 0.219 150); + --diagram-architecture: oklch(0.552 0.016 286); + --diagram-sequence: oklch(0.795 0.184 86); + --diagram-flowchart: oklch(0.645 0.246 16); + } + + .dark { + --canvas-bg: oklch(0.16 0.005 285); + --canvas-grid: oklch(0.92 0.004 286 / 15%); + --node-bg: oklch(0.24 0.006 286); + --node-border: oklch(0.35 0.01 260); + --node-selected: oklch(0.623 0.214 260); + --node-hover: oklch(0.623 0.214 260 / 12%); + --edge-default: oklch(0.55 0.01 286); + --edge-selected: oklch(0.623 0.214 260); + } +} diff --git a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx new file mode 100644 index 0000000..b0fa643 --- /dev/null +++ b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + ReactFlow, + ReactFlowProvider, + Background, + Controls, + MiniMap, + BackgroundVariant, +} from "@xyflow/react"; + +import { useGraphStore } from "../../stores/useGraphStore"; + +const nodeTypes = {}; + +function CanvasInner() { + const nodes = useGraphStore((s) => s.nodes); + const edges = useGraphStore((s) => s.edges); + const onNodesChange = useGraphStore((s) => s.onNodesChange); + const onEdgesChange = useGraphStore((s) => s.onEdgesChange); + const onViewportChange = useGraphStore((s) => s.onViewportChange); + + return ( +
+ + + + + +
+ ); +} + +export function DiagramCanvas() { + return ( + + + + ); +} diff --git a/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx b/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx new file mode 100644 index 0000000..3f8e67a --- /dev/null +++ b/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { api } from "~/lib/api/client"; + +import { EditorHeader } from "./EditorHeader"; +import { EditorStatusBar } from "./EditorStatusBar"; +import { DiagramCanvas } from "./DiagramCanvas"; +import { RightPanel } from "./RightPanel"; +import { useGraphStore } from "../../stores/useGraphStore"; +import { graphToFlow } from "../../lib/graph-converter"; + +import type { DiagramResponse } from "../DiagramCard"; +import type { GraphData, DiagramType } from "../../types/graph"; + +interface DiagramEditorProps { + diagram: DiagramResponse; +} + +export function DiagramEditor({ diagram }: DiagramEditorProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [rightPanelOpen, setRightPanelOpen] = useState(true); + const queryClient = useQueryClient(); + const initializeFromGraphData = useGraphStore( + (s) => s.initializeFromGraphData, + ); + const resetStore = useGraphStore((s) => s.reset); + + // Initialize graph store from diagram data; reset on unmount + useEffect(() => { + const raw = diagram.graphData as Record | null; + const graphData: GraphData = { + nodes: Array.isArray(raw?.nodes) ? (raw.nodes as GraphData["nodes"]) : [], + edges: Array.isArray(raw?.edges) ? (raw.edges as GraphData["edges"]) : [], + meta: raw?.meta as GraphData["meta"], + }; + const { nodes, edges } = graphToFlow(graphData); + initializeFromGraphData(nodes, edges); + return () => resetStore(); + }, [diagram.id, diagram.graphData, initializeFromGraphData, resetStore]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "b") { + e.preventDefault(); + setSidebarOpen((prev) => !prev); + } + if ((e.metaKey || e.ctrlKey) && e.key === "j") { + e.preventDefault(); + setRightPanelOpen((prev) => !prev); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + // Rename mutation + const renameMutation = useMutation({ + mutationFn: async (newTitle: string) => { + const res = await api.diagrams[":id"].$patch({ + param: { id: diagram.id }, + json: { title: newTitle }, + }); + if (!res.ok) throw new Error("Failed to rename diagram"); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["diagram", diagram.id] }); + queryClient.invalidateQueries({ queryKey: ["diagrams"] }); + toast.success("Diagram renamed"); + }, + onError: () => { + toast.error("Failed to rename diagram"); + }, + }); + + const handleRename = useCallback( + (newTitle: string) => { + if (newTitle.trim() && newTitle.trim() !== diagram.title) { + renameMutation.mutate(newTitle.trim()); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [diagram.title], + ); + + return ( +
+ setSidebarOpen((prev) => !prev)} + /> + +
+ {/* Left sidebar */} +
+
+ {!sidebarOpen && ( + + {"\u2318"}B + + )} +
+
+ + {/* Canvas */} +
+ +
+ + {/* Right panel */} + +
+ + +
+ ); +} diff --git a/apps/web/src/modules/diagram/components/editor/EditorHeader.tsx b/apps/web/src/modules/diagram/components/editor/EditorHeader.tsx new file mode 100644 index 0000000..32b332f --- /dev/null +++ b/apps/web/src/modules/diagram/components/editor/EditorHeader.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import Link from "next/link"; +import { Icons } from "@turbostarter/ui-web/icons"; +import { Button } from "@turbostarter/ui-web/button"; +import { Input } from "@turbostarter/ui-web/input"; +import { Badge } from "@turbostarter/ui-web/badge"; +import { pathsConfig } from "~/config/paths"; +import { diagramTypeConfig } from "../DiagramCard"; + +import type { DiagramType } from "../../types/graph"; + +interface EditorHeaderProps { + title: string; + diagramType: DiagramType; + onRename: (newTitle: string) => void; + sidebarOpen: boolean; + onToggleSidebar: () => void; +} + +export function EditorHeader({ + title, + diagramType, + onRename, + sidebarOpen, + onToggleSidebar, +}: EditorHeaderProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(title); + const inputRef = useRef(null); + const config = diagramTypeConfig[diagramType]; + const TypeIcon = config.icon; + + useEffect(() => { + setEditValue(title); + }, [title]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== title) { + onRename(trimmed); + } else { + setEditValue(title); + } + setIsEditing(false); + }; + + const handleCancel = () => { + setEditValue(title); + setIsEditing(false); + }; + + return ( +
+ {/* Sidebar toggle */} + + + {/* Breadcrumb */} + + + {/* Title */} + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + handleCancel(); + } + }} + className="h-7 w-56 text-sm font-medium" + maxLength={255} + /> + ) : ( + + )} + + {/* Diagram type badge */} + + + {config.label} + + + {/* Spacer */} +
+
+ ); +} diff --git a/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx b/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx new file mode 100644 index 0000000..ecbca15 --- /dev/null +++ b/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Icons } from "@turbostarter/ui-web/icons"; +import { diagramTypeConfig } from "../DiagramCard"; +import { useGraphStore } from "../../stores/useGraphStore"; + +import type { DiagramType } from "../../types/graph"; + +interface EditorStatusBarProps { + diagramType: DiagramType; +} + +export function EditorStatusBar({ diagramType }: EditorStatusBarProps) { + const zoomLevel = useGraphStore((s) => s.zoomLevel); + const nodeCount = useGraphStore((s) => s.nodeCount); + const config = diagramTypeConfig[diagramType]; + const TypeIcon = config.icon; + + return ( +
+
+ + {config.label} +
+ +
+ + + {nodeCount} node{nodeCount !== 1 ? "s" : ""} + +
+ +
+ +
+ + {zoomLevel}% +
+
+ ); +} diff --git a/apps/web/src/modules/diagram/components/editor/RightPanel.tsx b/apps/web/src/modules/diagram/components/editor/RightPanel.tsx new file mode 100644 index 0000000..5bfa9ab --- /dev/null +++ b/apps/web/src/modules/diagram/components/editor/RightPanel.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { Icons } from "@turbostarter/ui-web/icons"; + +type Tab = "chat" | "inspector" | "annotations"; + +const tabs: { key: Tab; label: string }[] = [ + { key: "chat", label: "Chat" }, + { key: "inspector", label: "Inspector" }, + { key: "annotations", label: "Annotations" }, +]; + +interface RightPanelProps { + open: boolean; +} + +export function RightPanel({ open }: RightPanelProps) { + const [activeTab, setActiveTab] = useState("chat"); + + return ( +
+
+ {/* Tab headers */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === "chat" && ( + <> + +

+ AI Copilot +

+

+ Start a conversation to build your diagram +

+ + )} + {activeTab === "inspector" && ( + <> + +

+ Inspector +

+

+ Select a node to see its properties +

+ + )} + {activeTab === "annotations" && ( + <> + +

+ Annotations +

+

+ Coming soon +

+ + )} +
+
+
+ ); +} diff --git a/apps/web/src/modules/diagram/lib/graph-converter.test.ts b/apps/web/src/modules/diagram/lib/graph-converter.test.ts new file mode 100644 index 0000000..3989da9 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/graph-converter.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect } from "vitest"; +import { + graphNodeToFlowNode, + graphEdgeToFlowEdge, + graphToFlow, + flowNodeToGraphNode, + flowEdgeToGraphEdge, + flowToGraph, +} from "./graph-converter"; +import type { + DiagramNode, + DiagramEdge, + GraphData, +} from "../types/graph"; + +describe("graphNodeToFlowNode", () => { + it("should convert a basic node with position", () => { + const node: DiagramNode = { + id: "n1", + type: "flow:process", + label: "Start", + position: { x: 100, y: 200 }, + }; + const result = graphNodeToFlowNode(node); + expect(result).toEqual({ + id: "n1", + type: "default", + position: { x: 100, y: 200 }, + data: { ...node, label: "Start" }, + }); + }); + + it("should use default position when node has no position", () => { + const node: DiagramNode = { + id: "n2", + type: "bpmn:activity", + label: "Task A", + }; + const result = graphNodeToFlowNode(node); + expect(result.position).toEqual({ x: 0, y: 0 }); + }); + + it("should preserve all node data in data field", () => { + const node: DiagramNode = { + id: "n3", + type: "er:entity", + label: "Users", + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "name", type: "text" }, + ], + }; + const result = graphNodeToFlowNode(node); + expect(result.data.columns).toEqual(node.columns); + expect(result.data.type).toBe("er:entity"); + }); +}); + +describe("graphEdgeToFlowEdge", () => { + it("should convert from/to to source/target", () => { + const edge: DiagramEdge = { + id: "e1", + from: "n1", + to: "n2", + label: "connects", + }; + const result = graphEdgeToFlowEdge(edge); + expect(result).toEqual({ + id: "e1", + source: "n1", + target: "n2", + label: "connects", + type: "default", + data: { ...edge }, + }); + }); + + it("should handle edges without labels", () => { + const edge: DiagramEdge = { id: "e2", from: "a", to: "b" }; + const result = graphEdgeToFlowEdge(edge); + expect(result.label).toBeUndefined(); + }); + + it("should preserve E-R cardinality", () => { + const edge: DiagramEdge = { + id: "e3", + from: "users", + to: "orders", + cardinality: "1:N", + type: "inheritance", + }; + const result = graphEdgeToFlowEdge(edge); + expect(result.data?.cardinality).toBe("1:N"); + }); +}); + +describe("graphToFlow", () => { + it("should convert full GraphData to flow format", () => { + const data: GraphData = { + nodes: [ + { id: "n1", type: "flow:process", label: "A", position: { x: 0, y: 0 } }, + { id: "n2", type: "flow:decision", label: "B", position: { x: 100, y: 100 } }, + ], + edges: [ + { id: "e1", from: "n1", to: "n2", label: "yes" }, + ], + }; + const result = graphToFlow(data); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.nodes[0]!.id).toBe("n1"); + expect(result.edges[0]!.source).toBe("n1"); + expect(result.edges[0]!.target).toBe("n2"); + }); + + it("should handle empty graph data", () => { + const data: GraphData = { nodes: [], edges: [] }; + const result = graphToFlow(data); + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + }); + + it("should handle all 6 diagram types", () => { + const diagramTypes = [ + "bpmn:activity", + "er:entity", + "org:unit", + "arch:service", + "seq:participant", + "flow:process", + ]; + const nodes: DiagramNode[] = diagramTypes.map((type, i) => ({ + id: `n${i}`, + type, + label: `Node ${i}`, + })); + const result = graphToFlow({ nodes, edges: [] }); + expect(result.nodes).toHaveLength(6); + result.nodes.forEach((node) => { + expect(node.type).toBe("default"); + }); + }); +}); + +describe("flowNodeToGraphNode", () => { + it("should convert flow node back to graph node", () => { + const flowNode = { + id: "n1", + type: "default", + position: { x: 50, y: 75 }, + data: { + id: "n1", + type: "bpmn:activity", + label: "Task", + lane: "lane1", + }, + }; + const result = flowNodeToGraphNode(flowNode); + expect(result.id).toBe("n1"); + expect(result.type).toBe("bpmn:activity"); + expect(result.label).toBe("Task"); + expect(result.position).toEqual({ x: 50, y: 75 }); + expect(result.lane).toBe("lane1"); + }); + + it("should default to flow:process when type is missing", () => { + const flowNode = { + id: "n1", + type: "default", + position: { x: 0, y: 0 }, + data: { label: "No type" }, + }; + const result = flowNodeToGraphNode(flowNode); + expect(result.type).toBe("flow:process"); + }); +}); + +describe("flowEdgeToGraphEdge", () => { + it("should convert source/target back to from/to", () => { + const flowEdge = { + id: "e1", + source: "n1", + target: "n2", + label: "Yes", + type: "default", + data: { type: "sequence", color: "#ff0000" }, + }; + const result = flowEdgeToGraphEdge(flowEdge); + expect(result.from).toBe("n1"); + expect(result.to).toBe("n2"); + expect(result.label).toBe("Yes"); + expect(result.type).toBe("sequence"); + expect(result.color).toBe("#ff0000"); + }); + + it("should handle edges with no data", () => { + const flowEdge = { + id: "e2", + source: "a", + target: "b", + }; + const result = flowEdgeToGraphEdge(flowEdge); + expect(result.from).toBe("a"); + expect(result.to).toBe("b"); + expect(result.type).toBeUndefined(); + }); +}); + +describe("flowToGraph", () => { + it("should convert full flow state back to GraphData", () => { + const nodes = [ + { + id: "n1", + type: "default", + position: { x: 10, y: 20 }, + data: { type: "flow:process", label: "Step 1" }, + }, + ]; + const edges = [ + { + id: "e1", + source: "n1", + target: "n2", + label: "next", + data: { type: "sequence" }, + }, + ]; + const meta = { + version: "1.0", + title: "Test", + diagramType: "flowchart" as const, + }; + const result = flowToGraph(nodes, edges, meta); + expect(result.meta).toEqual(meta); + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(1); + expect(result.nodes[0]!.type).toBe("flow:process"); + expect(result.edges[0]!.from).toBe("n1"); + }); + + it("should roundtrip: graphToFlow -> flowToGraph preserves data", () => { + const original: GraphData = { + meta: { + version: "1.0", + title: "Roundtrip Test", + diagramType: "er", + }, + nodes: [ + { + id: "users", + type: "er:entity", + label: "Users", + position: { x: 0, y: 0 }, + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "email", type: "varchar" }, + ], + }, + { + id: "orders", + type: "er:entity", + label: "Orders", + position: { x: 200, y: 0 }, + }, + ], + edges: [ + { + id: "e1", + from: "users", + to: "orders", + label: "has", + cardinality: "1:N", + }, + ], + }; + + const flow = graphToFlow(original); + const roundtripped = flowToGraph(flow.nodes, flow.edges, original.meta); + + expect(roundtripped.meta).toEqual(original.meta); + expect(roundtripped.nodes).toHaveLength(2); + expect(roundtripped.edges).toHaveLength(1); + expect(roundtripped.nodes[0]!.type).toBe("er:entity"); + expect(roundtripped.nodes[0]!.columns).toEqual(original.nodes[0]!.columns); + expect(roundtripped.edges[0]!.from).toBe("users"); + expect(roundtripped.edges[0]!.to).toBe("orders"); + expect(roundtripped.edges[0]!.cardinality).toBe("1:N"); + }); +}); diff --git a/apps/web/src/modules/diagram/lib/graph-converter.ts b/apps/web/src/modules/diagram/lib/graph-converter.ts new file mode 100644 index 0000000..a7340a8 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/graph-converter.ts @@ -0,0 +1,76 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph"; + +export function graphNodeToFlowNode(node: DiagramNode): Node { + return { + id: node.id, + type: "default", + position: node.position ?? { x: 0, y: 0 }, + data: { + ...node, + label: node.label, + }, + }; +} + +export function graphEdgeToFlowEdge(edge: DiagramEdge): Edge { + return { + id: edge.id, + source: edge.from, + target: edge.to, + label: edge.label, + type: "default", + data: { ...edge }, + }; +} + +export function graphToFlow(data: GraphData): { nodes: Node[]; edges: Edge[] } { + return { + nodes: (data.nodes ?? []).map(graphNodeToFlowNode), + edges: (data.edges ?? []).map(graphEdgeToFlowEdge), + }; +} + +export function flowNodeToGraphNode(node: Node): DiagramNode { + const data = node.data as unknown as DiagramNode & { label: string }; + return { + id: node.id, + type: data.type ?? "flow:process", + label: data.label, + position: node.position, + ...(data.tag !== undefined && { tag: data.tag }), + ...(data.icon !== undefined && { icon: data.icon }), + ...(data.color !== undefined && { color: data.color }), + ...(data.w !== undefined && { w: data.w }), + ...(data.lane !== undefined && { lane: data.lane }), + ...(data.group !== undefined && { group: data.group }), + ...(data.columns !== undefined && { columns: data.columns }), + ...(data.lifeline !== undefined && { lifeline: data.lifeline }), + ...(data.parentId !== undefined && { parentId: data.parentId }), + }; +} + +export function flowEdgeToGraphEdge(edge: Edge): DiagramEdge { + const data = edge.data as unknown as DiagramEdge | undefined; + return { + id: edge.id, + from: edge.source, + to: edge.target, + label: typeof edge.label === "string" ? edge.label : undefined, + type: data?.type, + color: data?.color, + cardinality: data?.cardinality, + }; +} + +export function flowToGraph( + nodes: Node[], + edges: Edge[], + meta?: GraphData["meta"], +): GraphData { + return { + meta, + nodes: nodes.map(flowNodeToGraphNode), + edges: edges.map(flowEdgeToGraphEdge), + }; +} diff --git a/apps/web/src/modules/diagram/stores/useGraphStore.test.ts b/apps/web/src/modules/diagram/stores/useGraphStore.test.ts new file mode 100644 index 0000000..70639a6 --- /dev/null +++ b/apps/web/src/modules/diagram/stores/useGraphStore.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useGraphStore } from "./useGraphStore"; + +import type { Node, Edge } from "@xyflow/react"; + +const makeNode = (id: string, label = "Node"): Node => ({ + id, + type: "default", + position: { x: 0, y: 0 }, + data: { label }, +}); + +const makeEdge = (id: string, source: string, target: string): Edge => ({ + id, + source, + target, +}); + +describe("useGraphStore", () => { + beforeEach(() => { + useGraphStore.setState({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + nodeCount: 0, + zoomLevel: 100, + }); + }); + + describe("initializeFromGraphData", () => { + it("should set nodes, edges, and nodeCount", () => { + const nodes = [makeNode("n1"), makeNode("n2")]; + const edges = [makeEdge("e1", "n1", "n2")]; + + useGraphStore.getState().initializeFromGraphData(nodes, edges); + + expect(useGraphStore.getState().nodes).toHaveLength(2); + expect(useGraphStore.getState().edges).toHaveLength(1); + expect(useGraphStore.getState().nodeCount).toBe(2); + }); + }); + + describe("setNodes", () => { + it("should replace nodes and update nodeCount", () => { + useGraphStore.getState().setNodes([makeNode("a"), makeNode("b"), makeNode("c")]); + expect(useGraphStore.getState().nodes).toHaveLength(3); + expect(useGraphStore.getState().nodeCount).toBe(3); + }); + + it("should handle empty array", () => { + useGraphStore.getState().setNodes([makeNode("x")]); + useGraphStore.getState().setNodes([]); + expect(useGraphStore.getState().nodes).toEqual([]); + expect(useGraphStore.getState().nodeCount).toBe(0); + }); + }); + + describe("setEdges", () => { + it("should replace edges", () => { + const edges = [makeEdge("e1", "a", "b"), makeEdge("e2", "b", "c")]; + useGraphStore.getState().setEdges(edges); + expect(useGraphStore.getState().edges).toHaveLength(2); + }); + }); + + describe("onNodesChange", () => { + it("should apply node position changes", () => { + useGraphStore.getState().setNodes([makeNode("n1")]); + + useGraphStore.getState().onNodesChange([ + { + type: "position", + id: "n1", + position: { x: 100, y: 200 }, + }, + ]); + + const updatedNode = useGraphStore.getState().nodes[0]; + expect(updatedNode!.position).toEqual({ x: 100, y: 200 }); + }); + + it("should update nodeCount when nodes are removed", () => { + useGraphStore.getState().setNodes([makeNode("n1"), makeNode("n2")]); + expect(useGraphStore.getState().nodeCount).toBe(2); + + useGraphStore.getState().onNodesChange([ + { type: "remove", id: "n1" }, + ]); + + expect(useGraphStore.getState().nodeCount).toBe(1); + }); + }); + + describe("onEdgesChange", () => { + it("should apply edge removal", () => { + useGraphStore.getState().setEdges([makeEdge("e1", "a", "b")]); + + useGraphStore.getState().onEdgesChange([ + { type: "remove", id: "e1" }, + ]); + + expect(useGraphStore.getState().edges).toHaveLength(0); + }); + }); + + describe("onViewportChange", () => { + it("should update viewport and zoomLevel", () => { + useGraphStore.getState().onViewportChange({ x: 50, y: 50, zoom: 1.5 }); + + expect(useGraphStore.getState().viewport).toEqual({ x: 50, y: 50, zoom: 1.5 }); + expect(useGraphStore.getState().zoomLevel).toBe(150); + }); + + it("should round zoomLevel to nearest integer", () => { + useGraphStore.getState().onViewportChange({ x: 0, y: 0, zoom: 0.333 }); + expect(useGraphStore.getState().zoomLevel).toBe(33); + }); + + it("should handle zoom at 100%", () => { + useGraphStore.getState().onViewportChange({ x: 0, y: 0, zoom: 1.0 }); + expect(useGraphStore.getState().zoomLevel).toBe(100); + }); + }); +}); diff --git a/apps/web/src/modules/diagram/stores/useGraphStore.ts b/apps/web/src/modules/diagram/stores/useGraphStore.ts new file mode 100644 index 0000000..4dcc518 --- /dev/null +++ b/apps/web/src/modules/diagram/stores/useGraphStore.ts @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import { + applyNodeChanges, + applyEdgeChanges, +} from "@xyflow/react"; +import type { + Node, + Edge, + OnNodesChange, + OnEdgesChange, + Viewport, +} from "@xyflow/react"; + +interface GraphState { + nodes: Node[]; + edges: Edge[]; + viewport: Viewport; + nodeCount: number; + zoomLevel: number; + setNodes: (nodes: Node[]) => void; + setEdges: (edges: Edge[]) => void; + onNodesChange: OnNodesChange; + onEdgesChange: OnEdgesChange; + onViewportChange: (viewport: Viewport) => void; + initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void; + reset: () => void; +} + +export const useGraphStore = create((set, get) => ({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + nodeCount: 0, + zoomLevel: 100, + + setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }), + setEdges: (edges) => set({ edges }), + + onNodesChange: (changes) => { + const updatedNodes = applyNodeChanges(changes, get().nodes); + set({ nodes: updatedNodes, nodeCount: updatedNodes.length }); + }, + + onEdgesChange: (changes) => { + set({ edges: applyEdgeChanges(changes, get().edges) }); + }, + + onViewportChange: (viewport) => { + set({ viewport, zoomLevel: Math.round(viewport.zoom * 100) }); + }, + + initializeFromGraphData: (nodes, edges) => { + set({ nodes, edges, nodeCount: nodes.length }); + }, + + reset: () => { + set({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + nodeCount: 0, + zoomLevel: 100, + }); + }, +})); diff --git a/apps/web/src/modules/diagram/types/graph.ts b/apps/web/src/modules/diagram/types/graph.ts new file mode 100644 index 0000000..6f2aaaf --- /dev/null +++ b/apps/web/src/modules/diagram/types/graph.ts @@ -0,0 +1,64 @@ +export type DiagramType = + | "bpmn" + | "er" + | "orgchart" + | "architecture" + | "sequence" + | "flowchart"; + +export interface Column { + name: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + isNullable?: boolean; + isUnique?: boolean; + references?: string; +} + +export interface DiagramNode { + id: string; + type: string; + tag?: string; + label: string; + icon?: string; + color?: string; + w?: number; + position?: { x: number; y: number }; + lane?: string; + group?: string; + columns?: Column[]; + lifeline?: boolean; + parentId?: string; +} + +export interface DiagramEdge { + id: string; + from: string; + to: string; + label?: string; + color?: string; + type?: string; + cardinality?: string; +} + +export interface DiagramMeta { + version: string; + title: string; + description?: string; + diagramType: DiagramType; + layoutDirection?: "DOWN" | "RIGHT" | "LEFT" | "UP"; + edgeRouting?: "ORTHOGONAL" | "SPLINES" | "POLYLINE"; +} + +export interface GraphData { + meta?: DiagramMeta; + nodes: DiagramNode[]; + edges: DiagramEdge[]; + pools?: Array<{ + id: string; + label: string; + lanes: Array<{ id: string; label: string }>; + }>; + groups?: Array<{ id: string; label: string; color?: string }>; +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..49e12d6 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,14 @@ +import { resolve } from "path"; +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "@turbostarter/vitest-config/base"; + +export default mergeConfig( + baseConfig, + defineConfig({ + resolve: { + alias: { + "~": resolve(__dirname, "./src"), + }, + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf470f0..8d2c386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,6 +439,9 @@ importers: '@turbostarter/ui-web': specifier: workspace:* version: link:../../packages/ui/web + '@xyflow/react': + specifier: 12.10.1 + version: 12.10.1(@types/react@19.2.7)(immer@10.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) accept-language: specifier: 3.0.20 version: 3.0.20 @@ -530,6 +533,9 @@ importers: '@turbostarter/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@turbostarter/vitest-config': + specifier: workspace:* + version: link:../../tooling/vitest '@types/node': specifier: catalog:node22 version: 22.16.0 @@ -551,6 +557,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@22.16.0)(@vitest/ui@4.0.14)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0) packages/ai: dependencies: @@ -8232,6 +8241,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -8244,6 +8256,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.7': resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} @@ -8253,6 +8268,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -8716,6 +8737,15 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.10.1': + resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.75': + resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -9333,6 +9363,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -15710,6 +15743,21 @@ packages: zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': 19.2.7 + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.8: resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} engines: {node: '>=12.20.0'} @@ -23432,6 +23480,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -23444,6 +23496,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.7': dependencies: '@types/d3-path': 3.1.1 @@ -23452,6 +23506,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -23864,6 +23927,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.16.0)(lightningcss@1.30.2)(terser@5.43.1) + '@vitest/mocker@4.0.14(vite@7.3.0(@types/node@22.16.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@22.16.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0) + '@vitest/mocker@4.0.14(vite@7.3.0(@types/node@24.0.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0))': dependencies: '@vitest/spy': 4.0.14 @@ -23917,7 +23988,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.0.13)(@vitest/ui@4.0.14)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0) + vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@22.16.0)(@vitest/ui@4.0.14)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0) '@vitest/utils@2.1.9': dependencies: @@ -24016,6 +24087,29 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.10.1(@types/react@19.2.7)(immer@10.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@xyflow/system': 0.0.75 + classcat: 5.0.5 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + zustand: 4.5.7(@types/react@19.2.7)(immer@10.1.3)(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.75': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -24752,6 +24846,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clean-stack@2.2.0: {} cli-cursor@2.1.0: @@ -32444,6 +32540,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.43.1 + vite@7.3.0(@types/node@22.16.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.44.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.16.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.43.1 + tsx: 4.19.2 + yaml: 2.8.0 + vite@7.3.0(@types/node@24.0.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0): dependencies: esbuild: 0.27.2 @@ -32498,6 +32611,46 @@ snapshots: - supports-color - terser + vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@22.16.0)(@vitest/ui@4.0.14)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0): + dependencies: + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.3.0(@types/node@22.16.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.0(@types/node@22.16.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.16.0 + '@vitest/ui': 4.0.14(vitest@4.0.14) + jsdom: 26.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.0.13)(@vitest/ui@4.0.14)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0): dependencies: '@vitest/expect': 4.0.14 @@ -32867,6 +33020,14 @@ snapshots: zod@4.1.13: {} + zustand@4.5.7(@types/react@19.2.7)(immer@10.1.3)(react@19.1.0): + dependencies: + use-sync-external-store: 1.6.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.7 + immer: 10.1.3 + react: 19.1.0 + zustand@5.0.8(@types/react@19.2.7)(immer@10.1.3)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): optionalDependencies: '@types/react': 19.2.7