SuperAPI AI Directory

interactive-flow-diagram

General & other

View repository

Updated 10-16 02:32

About this skill

Interactive User Flow Diagram Builder

Build horizontally-scrolling, single-canvas user flow diagrams as standalone HTML files powered by D3.js. All nodes render at full detail on one flat canvas — no multi-level zoom, no phase grouping.

CRITICAL: Use the Complete Template Below

Copy the FULL HTML template from the "Complete Template" section below. Only modify the flowData object to match the user's flow. Do NOT restructure the rendering code, do NOT add phases/levels, do NOT change the node rendering functions.

Node Types & Colors (from Figma reference)

Type Shape Fill Stroke Text Color
start Stadium pill 272×106, rx=53 #40454A #B8BCC0 #FFFFFF (white)
end Stadium pill 272×106, rx=53 #FCF5B1 #B29B24 #333333
process Rect 272×106, rx=6 #C1DDE3 #77B2C7 #333333
decision Diamond 120×120 (rotated rect) #E6E6E6 #B8B8B8 #333333
user-decision Circle r=50 #FFFFFF #B8B8B8 #333333
error Rect 272×106, rx=6 #EEA79E #DB7060 #333333
confirmation Rect 272×106, rx=6 #E2EDCC #7A9A01 #333333
alt-flow Rect 272×106, rx=6 #C39DE3 #805AA1 #333333
note Square 160×160, no stroke #FFD966 none #333333

All rectangular/pill nodes: 4px inside stroke. Canvas background: #FAF8F5. Node labels: 20px Semi Bold (Inter). Decision text: 16px Medium.

Choosing Node Types

  • start: Entry point. One per flow. Dark background, white text.
  • end: Terminal nodes. Yellow background. Can have multiple.
  • process: Screens, pages, forms, actions. Blue-teal. Default type.
  • decision: System checks (validation, auth, API). Diamond shape. "Yes/No" or "Pass/Fail".
  • user-decision: User choices between paths. Circle shape.
  • error: Error states, failures, blocked paths. Salmon/red.
  • confirmation: Success screens, completion. Green.
  • alt-flow: Alternative/optional paths. Purple.
  • note: Annotations, not part of flow. Yellow sticky.

Connectors

  • Default: #B8B8B8, 2px, orthogonal right-angle paths
  • Happy path: #7A9A01, 2.5px — primary user journey
  • Error path: #CF6253, 2px, dashed (6,4)
  • Arrowheads: Filled triangles via SVG <marker>, 10×7px
  • No port dots — arrows touch node edges directly, zero gap
  • Edge labels: 12px, #666666, white background pill

Layout Rules

  • Primary axis: left to right. Happy path = center row.
  • Branches go down (errors) or up (alt flows) from decision nodes.
  • Horizontal gap: 160px between nodes. Vertical gap: 160px between rows.
  • Column width: 432px (272px node + 160px gap).
  • Row height: 280px (106px node + ~160px gap + room for diamonds).

Accepting User Input

Parse user content (markdown, bullets, Mermaid, verbal description) into the flowData format. For each step: identify type, connections, labels, happy path, and error/alt branches.


Complete Template

COPY THIS ENTIRE TEMPLATE. Only change the flowData object and the <title> tag. Everything else works as-is.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>[Flow Name] — Interactive Flow</title>
  <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      background: #FAF8F5;
      color: #1C1A17;
      font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      overflow: hidden;
      width: 100vw;
      height: 100vh;
    }
    #header {
      position: fixed; top: 0; left: 0; right: 0;
      height: 48px;
      background: #FFFFFF;
      border-bottom: 1px solid #E0DCD3;
      display: flex; align-items: center; justify-content: space-between;
      padding: 0 24px;
      z-index: 100;
      font-family: Inter, sans-serif;
    }
    #header .title {
      font-size: 15px; font-weight: 600; color: #1C1A17;
      text-transform: uppercase; letter-spacing: 0.5px;
    }
    #header .controls {
      display: flex; gap: 4px;
    }
    #header .controls button {
      width: 32px; height: 32px; border: 1px solid #E0DCD3;
      background: #FFFFFF; border-radius: 6px; cursor: pointer;
      font-size: 16px; color: #333; display: flex; align-items: center; justify-content: center;
    }
    #header .controls button:hover { background: #F5F3F0; }
    #flow-svg { display: block; }
    .node { cursor: pointer; }
    .node:hover rect, .node:hover circle { filter: brightness(0.97); }
    .node:hover { filter: drop-shadow(0 2px 8px rgba(0,0,0,0.1)); }

    /* Sidebar */
    #sidebar {
      position: fixed; top: 48px; right: -380px; bottom: 0; width: 380px;
      background: #FFFFFF; border-left: 1px solid #E0DCD3;
      z-index: 90; transition: right 0.3s ease;
      overflow-y: auto; padding: 32px 24px;
      font-family: Inter, sans-serif;
    }
    #sidebar.open { right: 0; }
    #sidebar .close-btn {
      position: absolute; top: 16px; right: 16px; width: 28px; height: 28px;
      border: none; background: none; cursor: pointer; font-size: 20px; color: #999;
    }
    #sidebar .close-btn:hover { color: #333; }
    #sidebar .node-type-badge {
      display: inline-block; padding: 3px 10px; border-radius: 12px;
      font-size: 11px; font-weight: 600; text-transform: uppercase;
      letter-spacing: 0.5px; margin-bottom: 12px;
    }
    #sidebar h2 { font-size: 20px; font-weight: 600; color: #1C1A17; margin-bottom: 20px; }
    #sidebar .section { margin-bottom: 20px; }
    #sidebar .section-label {
      font-size: 11px; font-weight: 600; text-transform: uppercase;
      letter-spacing: 0.5px; color: #999; margin-bottom: 6px;
    }
    #sidebar .section-content { font-size: 14px; color: #333; line-height: 1.6; }
    #sidebar .exit-list { list-style: none; padding: 0; }
    #sidebar .exit-list li {
      padding: 6px 0; border-bottom: 1px solid #F0EDE8;
      font-size: 13px; color: #333;
    }
    #sidebar .exit-list li strong { color: #013D5B; }

    /* Edge label pills */
    .edge-label-bg { fill: #FFFFFF; rx: 4; opacity: 0.95; }
    .edge-label-text { font-size: 12px; fill: #666666; font-family: Inter, sans-serif; }
  </style>
</head>
<body>
  <div id="header">
    <div class="title" id="flow-title"></div>
    <div class="controls">
      <button onclick="zoomIn()" title="Zoom in">+</button>
      <button onclick="zoomOut()" title="Zoom out">−</button>
      <button onclick="fitAll()" title="Fit all">⊡</button>
    </div>
  </div>
  <svg id="flow-svg"></svg>
  <div id="sidebar">
    <button class="close-btn" onclick="closeSidebar()">✕</button>
    <div id="sidebar-content"></div>
  </div>

<script>
// ═══════════════════════════════════════════════════════════════
//  FLOW DATA — MODIFY THIS SECTION ONLY
//  Change nodes[] and edges[] to match the user's flow.
//  Everything below this section renders automatically.
// ═══════════════════════════════════════════════════════════════
const flowData = {
  title: "Example Flow",
  nodes: [
    { id: "start", type: "start", label: "Start", description: "Entry point of the flow." },
    { id: "step1", type: "process", label: "Step 1", description: "First step in the process.", userAction: "User does something", edgeCases: ["Edge case 1"] },
    { id: "check", type: "decision", label: "Valid?", description: "System validates input." },
    { id: "step2", type: "process", label: "Step 2", description: "Continue on success." },
    { id: "error1", type: "error", label: "Error", description: "Validation failed." },
    { id: "end", type: "end", label: "Done", description: "Flow complete." }
  ],
  edges: [
    { from: "start", to: "step1", isHappyPath: true },
    { from: "step1", to: "check", isHappyPath: true },
    { from: "check", to: "step2", label: "Yes", isHappyPath: true },
    { from: "check", to: "error1", label: "No", isError: true },
    { from: "error1", to: "step1", label: "Retry" },
    { from: "step2", to: "end", isHappyPath: true }
  ]
};

// ═══════════════════════════════════════════════════════════════
//  DO NOT MODIFY BELOW THIS LINE
//  Rendering engine — handles layout, drawing, interactions.
// ═══════════════════════════════════════════════════════════════

// ─── Node style definitions ───
const NODE_STYLES = {
  'start':         { fill: '#40454A', stroke: '#B8BCC0', textColor: '#FFFFFF' },
  'end':           { fill: '#FCF5B1', stroke: '#B29B24', textColor: '#333333' },
  'process':       { fill: '#C1DDE3', stroke: '#77B2C7', textColor: '#333333' },
  'decision':      { fill: '#E6E6E6', stroke: '#B8B8B8', textColor: '#333333' },
  'user-decision': { fill: '#FFFFFF', stroke: '#B8B8B8', textColor: '#333333' },
  'error':         { fill: '#EEA79E', stroke: '#DB7060', textColor: '#333333' },
  'confirmation':  { fill: '#E2EDCC', stroke: '#7A9A01', textColor: '#333333' },
  'alt-flow':      { fill: '#C39DE3', stroke: '#805AA1', textColor: '#333333' },
  'note':          { fill: '#FFD966', stroke: 'none',    textColor: '#333333' }
};

function getStyle(type) { return NODE_STYLES[type] || NODE_STYLES['process']; }

// ─── Node dimensions (from Figma reference) ───
const NODE_W = 272, NODE_H = 106, PILL_RX = 53, RECT_RX = 6;
const DIAMOND_SIZE = 85; // half-side length before rotation — gives ~120px visual diagonal
const CIRCLE_R = 50;
const NOTE_SIZE = 140;

function getNodeWidth(node) {
  if (node.type === 'decision') return DIAMOND_SIZE * 2 * 1.414; // rotated diagonal
  if (node.type === 'user-decision') return CIRCLE_R * 2;
  if (node.type === 'note') return NOTE_SIZE;
  return NODE_W;
}
function getNodeHeight(node) {
  if (node.type === 'decision') return DIAMOND_SIZE * 2 * 1.414; // rotated diagonal
  if (node.type === 'user-decision') return CIRCLE_R * 2;
  if (node.type === 'note') return NOTE_SIZE;
  return NODE_H;
}

// ─── Layout algorithm ───
const COL_GAP = 160, ROW_GAP = 160;
const COL_W = NODE_W + COL_GAP; // 432
const ROW_H = NODE_H + ROW_GAP; // 266

function layoutNodes(nodes, edges) {
  // Build adjacency from edges
  const adj = {};
  const inDegree = {};
  nodes.forEach(n => { adj[n.id] = []; inDegree[n.id] = 0; });
  edges.forEach(e => {
    adj[e.from] = adj[e.from] || [];
    adj[e.from].push(e);
    inDegree[e.to] = (inDegree[e.to] || 0) + 1;
  });

  // Find start node
  let startNode = nodes.find(n => n.type === 'start');
  if (!startNode) startNode = nodes.find(n => inDegree[n.id] === 0) || nodes[0];

  // BFS to assign columns and rows
  const visited = new Set();
  const queue = [{ id: startNode.id, col: 0, row: 0 }];
  const positions = {};
  const colUsed = {}; // track which rows are used per column

  while (queue.length > 0) {
    const { id, col, row } = queue.shift();
    if (visited.has(id)) continue;
    visited.add(id);
    positions[id] = { col, row };

    // Track column usage
    if (!colUsed[col]) colUsed[col] = new Set();
    colUsed[col].add(row);

    const outEdges = adj[id] || [];
    // Sort: happy path first, then normal, then error
    const sorted = [...outEdges].sort((a, b) => {
      if (a.isHappyPath && !b.isHappyPath) return -1;
      if (!a.isHappyPath && b.isHappyPath) return 1;
      if (a.isError && !b.isError) return 1;
      if (!a.isError && b.isError) return -1;
      return 0;
    });

    let branchOffset = 1;
    sorted.forEach((edge, i) => {
      if (visited.has(edge.to)) return;
      if (i === 0 && (edge.isHappyPath || sorted.length === 1)) {
        // Happy path or only path — same row, next column
        queue.push({ id: edge.to, col: col + 1, row: row });
      } else {
        // Branch — offset row. Errors go down, alt goes up.
        const dir = edge.isError ? 1 : (edge.isAltFlow ? -1 : branchOffset);
        queue.push({ id: edge.to, col: col + 1, row: row + dir });
        branchOffset++;
      }
    });
  }

  // Handle unvisited nodes (disconnected)
  let maxCol = 0;
  Object.values(positions).forEach(p => { if (p.col > maxCol) maxCol = p.col; });
  nodes.forEach(n => {
    if (!visited.has(n.id)) {
      maxCol++;
      positions[n.id] = { col: maxCol, row: 0 };
    }
  });

  // Convert grid positions to pixel coordinates
  const START_X = 200;
  const CENTER_Y = 400;
  nodes.forEach(n => {
    const pos = positions[n.id];
    n.col = pos.col;
    n.row = pos.row;
    n.x = START_X + pos.col * COL_W;
    n.y = CENTER_Y + pos.row * ROW_H;
  });
}

// ─── Edge center for connector attachment ───
function getEdgePoint(node, side) {
  const w = getNodeWidth(node);
  const h = getNodeHeight(node);
  switch (side) {
    case 'right':  return { x: node.x + w / 2, y: node.y };
    case 'left':   return { x: node.x - w / 2, y: node.y };
    case 'bottom': return { x: node.x, y: node.y + h / 2 };
    case 'top':    return { x: node.x, y: node.y - h / 2 };
  }
}

// ─── Build orthogonal path ───
function buildPath(srcNode, tgtNode) {
  const sameRow = Math.abs(srcNode.y - tgtNode.y) < 10;
  const goingRight = tgtNode.x > srcNode.x;

  if (sameRow && goingRight) {
    // Straight horizontal line
    const src = getEdgePoint(srcNode, 'right');
    const tgt = getEdgePoint(tgtNode, 'left');
    return `M${src.x},${src.y} L${tgt.x},${tgt.y}`;
  }

  if (goingRight) {
    // Different row, going right — exit right, bend, enter left
    const src = getEdgePoint(srcNode, 'right');
    const tgt = getEdgePoint(tgtNode, 'left');
    const midX = src.x + COL_GAP * 0.4;
    return `M${src.x},${src.y} H${midX} V${tgt.y} H${tgt.x}`;
  }

  // Going LEFT (loop back) — route cleanly around the bottom
  const src = getEdgePoint(srcNode, 'bottom');
  const tgt = getEdgePoint(tgtNode, 'bottom');
  const loopY = Math.max(src.y, tgt.y) + ROW_GAP * 0.6;
  return `M${src.x},${src.y} V${loopY} H${tgt.x} V${tgt.y}`;
}

// ─── Rendering ───
const width = window.innerWidth;
const height = window.innerHeight - 48;

document.getElementById('flow-title').textContent = flowData.title;

const svg = d3.select('#flow-svg')
  .attr('width', width)
  .attr('height', height)
  .style('background', '#FAF8F5')
  .style('margin-top', '48px');

const defs = svg.append('defs');

// Arrowhead markers
function addMarker(id, color) {
  defs.append('marker')
    .attr('id', id)
    .attr('viewBox', '0 0 10 7')
    .attr('refX', 10).attr('refY', 3.5)
    .attr('markerWidth', 10).attr('markerHeight', 7)
    .attr('orient', 'auto')
    .append('path')
    .attr('d', 'M0,0 L10,3.5 L0,7 Z')
    .attr('fill', color);
}
addMarker('arrow-default', '#B8B8B8');
addMarker('arrow-happy', '#7A9A01');
addMarker('arrow-error', '#CF6253');

const container = svg.append('g').attr('class', 'canvas-container');

// Zoom
const zoomBehavior = d3.zoom()
  .scaleExtent([0.15, 3])
  .on('zoom', (event) => container.attr('transform', event.transform));
svg.call(zoomBehavior);

// Zoom controls
window.zoomIn = () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
window.zoomOut = () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 0.75);
window.fitAll = () => fitToContent();

// ─── Layout ───
layoutNodes(flowData.nodes, flowData.edges);

// Build node lookup
const nodeMap = {};
flowData.nodes.forEach(n => nodeMap[n.id] = n);

// ─── Draw edges ───
const edgesGroup = container.append('g').attr('class', 'edges');

flowData.edges.forEach(edge => {
  const src = nodeMap[edge.from];
  const tgt = nodeMap[edge.to];
  if (!src || !tgt) return;

  const isHappy = edge.isHappyPath;
  const isErr = edge.isError;
  const color = isHappy ? '#7A9A01' : (isErr ? '#CF6253' : '#B8B8B8');
  const strokeW = isHappy ? 2.5 : 2;
  const marker = isHappy ? 'url(#arrow-happy)' : (isErr ? 'url(#arrow-error)' : 'url(#arrow-default)');
  const dashArray = isErr ? '6,4' : 'none';

  const path = buildPath(src, tgt);

  edgesGroup.append('path')
    .attr('d', path)
    .attr('fill', 'none')
    .attr('stroke', color)
    .attr('stroke-width', strokeW)
    .attr('stroke-dasharray', dashArray)
    .attr('marker-end', marker);

  // Edge label
  if (edge.label) {
    const midX = (src.x + tgt.x) / 2;
    const midY = (src.y + tgt.y) / 2;
    // Offset labels above horizontal lines, beside vertical segments
    const offsetY = Math.abs(src.y - tgt.y) < 10 ? -16 : 0;
    const offsetX = Math.abs(src.y - tgt.y) < 10 ? 0 : 14;

    const labelG = edgesGroup.append('g')
      .attr('transform', `translate(${midX + offsetX}, ${midY + offsetY})`);

    labelG.append('rect')
      .attr('class', 'edge-label-bg')
      .attr('x', -24).attr('y', -12)
      .attr('width', 48).attr('height', 24)
      .attr('rx', 12)
      .attr('fill', '#FFFFFF')
      .attr('stroke', '#E0DCD3')
      .attr('stroke-width', 1);

    labelG.append('text')
      .attr('class', 'edge-label-text')
      .attr('text-anchor', 'middle')
      .attr('dy', '0.35em')
      .attr('font-size', '13px')
      .attr('font-weight', '500')
      .attr('fill', '#555555')
      .text(edge.label);

    // Auto-size background to text
    const textNode = labelG.select('text').node();
    if (textNode) {
      const bbox = textNode.getBBox();
      labelG.select('rect')
        .attr('x', bbox.x - 10)
        .attr('y', bbox.y - 5)
        .attr('width', bbox.width + 20)
        .attr('height', bbox.height + 10);
    }
  }
});

// ─── Draw nodes ───
const nodesGroup = container.append('g').attr('class', 'nodes');

flowData.nodes.forEach(node => {
  const style = getStyle(node.type);
  const g = nodesGroup.append('g')
    .attr('class', `node node-${node.type}`)
    .attr('transform', `translate(${node.x}, ${node.y})`)
    .style('cursor', 'pointer')
    .on('click', () => showSidebar(node));

  if (node.type === 'decision') {
    // Diamond — rotated rect. DIAMOND_SIZE = half side length
    g.append('rect')
      .attr('x', -DIAMOND_SIZE).attr('y', -DIAMOND_SIZE)
      .attr('width', DIAMOND_SIZE * 2).attr('height', DIAMOND_SIZE * 2)
      .attr('rx', 6)
      .attr('fill', style.fill)
      .attr('stroke', style.stroke)
      .attr('stroke-width', 4)
      .attr('transform', 'rotate(45)');
    // Text NOT rotated — wrap if needed
    const words = node.label.split(/\s+/);
    if (words.length <= 2) {
      g.append('text')
        .attr('text-anchor', 'middle')
        .attr('dy', '0.35em')
        .attr('fill', style.textColor)
        .attr('font-size', '16px')
        .attr('font-weight', '500')
        .text(node.label);
    } else {
      // Multi-line for longer labels
      const mid = Math.ceil(words.length / 2);
      g.append('text').attr('text-anchor', 'middle').attr('dy', '-0.3em')
        .attr('fill', style.textColor).attr('font-size', '15px').attr('font-weight', '500')
        .text(words.slice(0, mid).join(' '));
      g.append('text').attr('text-anchor', 'middle').attr('dy', '1.1em')
        .attr('fill', style.textColor).attr('font-size', '15px').attr('font-weight', '500')
        .text(words.slice(mid).join(' '));
    }

  } else if (node.type === 'user-decision') {
    // Circle
    g.append('circle')
      .attr('r', CIRCLE_R)
      .attr('fill', style.fill)
      .attr('stroke', style.stroke)
      .attr('stroke-width', 4);
    g.append('text')
      .attr('text-anchor', 'middle')
      .attr('dy', '0.35em')
      .attr('fill', style.textColor)
      .attr('font-size', '16px')
      .attr('font-weight', '500')
      .text(node.label);

  } else if (node.type === 'start' || node.type === 'end') {
    // Stadium pill
    g.append('rect')
      .attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
      .attr('width', NODE_W).attr('height', NODE_H)
      .attr('rx', PILL_RX)
      .attr('fill', style.fill)
      .attr('stroke', style.stroke)
      .attr('stroke-width', 4);
    g.append('text')
      .attr('text-anchor', 'middle')
      .attr('dy', '0.35em')
      .attr('fill', style.textColor)
      .attr('font-size', '20px')
      .attr('font-weight', '600')
      .text(node.label);

  } else if (node.type === 'note') {
    // Yellow sticky note
    g.append('rect')
      .attr('x', -NOTE_SIZE / 2).attr('y', -NOTE_SIZE / 2)
      .attr('width', NOTE_SIZE).attr('height', NOTE_SIZE)
      .attr('fill', style.fill);
    g.append('text')
      .attr('x', -NOTE_SIZE / 2 + 12).attr('y', -NOTE_SIZE / 2 + 20)
      .attr('fill', style.textColor)
      .attr('font-size', '11px')
      .attr('font-weight', '600')
      .text('NOTE');
    // Wrap body text
    const words = (node.label || '').split(' ');
    let line = '', lineNum = 0;
    words.forEach(word => {
      const test = line ? line + ' ' + word : word;
      if (test.length > 18) {
        g.append('text')
          .attr('x', -NOTE_SIZE / 2 + 12)
          .attr('y', -NOTE_SIZE / 2 + 40 + lineNum * 16)
          .attr('fill', style.textColor)
          .attr('font-size', '12px')
          .text(line);
        line = word;
        lineNum++;
      } else {
        line = test;
      }
    });
    if (line) {
      g.append('text')
        .attr('x', -NOTE_SIZE / 2 + 12)
        .attr('y', -NOTE_SIZE / 2 + 40 + lineNum * 16)
        .attr('fill', style.textColor)
        .attr('font-size', '12px')
        .text(line);
    }

  } else {
    // Default rectangle: process, error, confirmation, alt-flow
    g.append('rect')
      .attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
      .attr('width', NODE_W).attr('height', NODE_H)
      .attr('rx', RECT_RX)
      .attr('fill', style.fill)
      .attr('stroke', style.stroke)
      .attr('stroke-width', 4);
    g.append('text')
      .attr('text-anchor', 'middle')
      .attr('dy', '0.35em')
      .attr('fill', style.textColor)
      .attr('font-size', '20px')
      .attr('font-weight', '500')
      .text(node.label);
  }

  // Hover effect
  g.on('mouseenter', function() {
    d3.select(this).select('rect, circle').transition().duration(150)
      .attr('stroke-width', 5);
  }).on('mouseleave', function() {
    d3.select(this).select('rect, circle').transition().duration(150)
      .attr('stroke-width', 4);
  });
});

// ─── Sidebar ───
const sidebar = document.getElementById('sidebar');
const sidebarContent = document.getElementById('sidebar-content');

function showSidebar(node) {
  const style = getStyle(node.type);
  const typeName = node.type.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase());

  let html = `<div class="node-type-badge" style="background:${style.fill};color:${style.textColor};border:1px solid ${style.stroke}">${typeName}</div>`;
  html += `<h2>${node.label}</h2>`;

  if (node.description) {
    html += `<div class="section"><div class="section-label">Description</div><div class="section-content">${node.description}</div></div>`;
  }
  if (node.userAction) {
    html += `<div class="section"><div class="section-label">User Action</div><div class="section-content">${node.userAction}</div></div>`;
  }
  if (node.edgeCases && node.edgeCases.length) {
    html += `<div class="section"><div class="section-label">Edge Cases</div><ul class="exit-list">${node.edgeCases.map(e => `<li>${e}</li>`).join('')}</ul></div>`;
  }

  // Show exits
  const exits = flowData.edges.filter(e => e.from === node.id);
  if (exits.length) {
    html += `<div class="section"><div class="section-label">Exits</div><ul class="exit-list">`;
    exits.forEach(e => {
      const tgt = nodeMap[e.to];
      html += `<li>${e.label ? `<strong>${e.label}</strong> → ` : '→ '}${tgt ? tgt.label : e.to}</li>`;
    });
    html += `</ul></div>`;
  }

  sidebarContent.innerHTML = html;
  sidebar.classList.add('open');
}

window.closeSidebar = () => sidebar.classList.remove('open');

// Click empty canvas to close sidebar
svg.on('click', (event) => {
  if (event.target === svg.node()) closeSidebar();
});

// ─── Fit to content on load ───
function fitToContent() {
  const bounds = container.node().getBBox();
  if (bounds.width === 0 || bounds.height === 0) return;
  const pad = 100;
  const bw = bounds.width + pad * 2;
  const bh = bounds.height + pad * 2;
  const scale = Math.min(width / bw, height / bh, 1.2);
  const tx = (width - bounds.width * scale) / 2 - bounds.x * scale;
  const ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
  svg.transition().duration(600)
    .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}

// Initial fit after a small delay to let fonts load
setTimeout(fitToContent, 100);

</script>
</body>
</html>

Iteration Prompts

User says What to do
"add edge labels" Add label property to edges in flowData
"highlight the happy path" Set isHappyPath: true on primary journey edges
"add error handling" Add error nodes + edges with isError: true
"add a note" Add a node with type: "note"
"add [step]" Add to flowData.nodes[] and flowData.edges[]