Files
turbostarter/_bmad-output/implementation-artifacts/2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model.md
Alejandro Gutiérrez 5033109656 feat: implement Story 2.1 — canvas workspace with @xyflow/react and unified graph model
Replace the placeholder diagram editor with a professional Studio layout featuring
an interactive @xyflow/react canvas, unified graph data model with bidirectional
converters, Zustand state management, and oklch design tokens. Includes 25 unit
tests for converters and store, with all code review fixes applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:07:59 +00:00

30 KiB

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

  • Task 1: Install @xyflow/react and configure CSS (AC: #1, #3)

    • 1.1: Install @xyflow/react in apps/web
    • 1.2: Add @xyflow/react/dist/style.css import in the app's global CSS within @layer base
    • 1.3: Verify no peer dependency conflicts with React 19 and Zustand 5
  • Task 2: Define unified graph model TypeScript types (AC: #2)

    • 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)
    • 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
  • Task 3: Build Studio layout shell for the diagram editor (AC: #1, #4, #5)

    • 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
    • 3.2: Create apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx — the main Studio layout: left sidebar area, center canvas, right panel placeholder
    • 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
    • 3.4: Create apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx — 28px status bar with zoom %, diagram type indicator, node count
    • 3.5: Create apps/web/src/modules/diagram/components/editor/RightPanel.tsx — 320px collapsible right panel with placeholder tabs (Chat | Inspector | Annotations)
    • 3.6: Implement keyboard shortcuts: Cmd+B toggle sidebar, Cmd+J toggle right panel
    • 3.7: Apply design tokens: --canvas-bg, --canvas-grid, --node-bg, --node-border as CSS custom properties
  • Task 4: Implement the canvas with @xyflow/react (AC: #1, #2, #3, #4)

    • 4.1: Create apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsxReactFlowProvider + ReactFlow wrapper with Background (dot grid), Controls (zoom +/-/reset), MiniMap
    • 4.2: Wire canvas to diagram's graphData — load from API response, convert via graphToFlow(), render as nodes/edges
    • 4.3: Handle empty diagrams — show dot grid background with no nodes, right panel shows "Start a conversation" placeholder
    • 4.4: Configure @xyflow/react: fitView, colorMode based on system theme, selection support, zoom/pan defaults
  • Task 5: Create Zustand store for graph state (AC: #2, #3)

    • 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
    • 5.2: Wire onNodesChange, onEdgesChange to Zustand store via applyNodeChanges/applyEdgeChanges
    • 5.3: Expose zoomLevel, nodeCount, diagramType as derived state for the status bar
  • Task 6: Tests (AC: all)

    • 6.1: Unit tests for graphToFlow() and flowToGraph() converter functions with all 6 diagram types
    • 6.2: Unit tests for Zustand store actions (add/update/remove nodes, zoom tracking)
    • 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<string, DiagramNode>, edges as a Map<string, DiagramEdge>.

  3. Component Structure: Feature code in ~/modules/diagram/, NOT co-located in route directories. The page.tsx stays minimal — it fetches data and renders <DiagramEditor>.

  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:

/** 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:

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):

@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)
  • <ReactFlowProvider> wraps <ReactFlow> — provider enables hooks in child components
  • Props: nodes, edges, onNodesChange, onEdgesChange, fitView, colorMode
  • Sub-components: <Background variant="dots" />, <Controls />, <MiniMap />
  • 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:

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<GraphState>((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 <DiagramEditor>
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<object>().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:

import { ReactFlow, ReactFlowProvider, MiniMap, Controls, Background, Panel } from "@xyflow/react";

CSS import (for Tailwind CSS 4):

@layer base {
  @import "@xyflow/react/dist/style.css";
}

CRITICAL — Parent container must have explicit dimensions:

<div style={{ width: '100%', height: '100%' }}>
  <ReactFlow ... />
</div>

CRITICAL — nodeTypes must be defined OUTSIDE the component:

const nodeTypes = { /* ... */ }; // Outside component to prevent re-renders

function DiagramCanvas() {
  return <ReactFlow nodeTypes={nodeTypes} ... />;
}

v12 API changes from v11:

  • node.parentNodenode.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 <ReactFlow>, or colorMode="system" to auto-detect.

Custom node props type:

import type { Node, NodeProps } from "@xyflow/react";
type MyNode = Node<MyNodeData, "myType">;
function MyNode({ data }: NodeProps<MyNode>) { ... }

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<string, unknown> 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)