beads-map 0.3.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +2 -2
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +1 -1
  17. package/.next/server/functions-config-manifest.json +1 -1
  18. package/.next/server/pages/404.html +1 -1
  19. package/.next/server/pages/500.html +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +1 -0
  22. package/.next/static/css/ade5301262971664.css +3 -0
  23. package/README.md +21 -11
  24. package/app/page.tsx +150 -7
  25. package/components/BeadTooltip.tsx +26 -2
  26. package/components/BeadsGraph.tsx +433 -243
  27. package/components/ContextMenu.tsx +51 -5
  28. package/components/DescriptionModal.tsx +48 -18
  29. package/components/HelpPanel.tsx +336 -0
  30. package/components/NodeDetail.tsx +33 -8
  31. package/components/TutorialOverlay.tsx +187 -0
  32. package/lib/types.ts +2 -1
  33. package/lib/utils.ts +23 -0
  34. package/package.json +1 -1
  35. package/.next/static/chunks/app/page-a0493d6741516b53.js +0 -1
  36. package/.next/static/css/4fded26534cb91e3.css +0 -3
  37. /package/.next/static/{_OvcD8YYgVPHv6Tomg-pB → JmL0suxsggbSwPxWcmUFV}/_buildManifest.js +0 -0
  38. /package/.next/static/{_OvcD8YYgVPHv6Tomg-pB → JmL0suxsggbSwPxWcmUFV}/_ssgManifest.js +0 -0
@@ -12,7 +12,7 @@ import React, {
12
12
  import { forceCollide, forceRadial, forceX, forceY } from "d3-force";
13
13
  import type { GraphNode, GraphLink, ColorMode } from "@/lib/types";
14
14
  import {
15
- STATUS_COLORS, STATUS_LABELS,
15
+ STATUS_COLORS, STATUS_LABELS, PRIORITY_COLORS, PRIORITY_LABELS,
16
16
  COLOR_MODE_LABELS, getPersonColor, getCatppuccinPrefixColor, getPrefixLabel,
17
17
  } from "@/lib/types";
18
18
 
@@ -52,6 +52,20 @@ interface BeadsGraphProps {
52
52
  colorMode?: ColorMode;
53
53
  /** Callback to change color mode (from legend selector) */
54
54
  onColorModeChange?: (mode: ColorMode) => void;
55
+ /** Whether to auto-zoom to fit all nodes after data updates and layout changes */
56
+ autoFit?: boolean;
57
+ /** Callback to toggle auto-fit */
58
+ onAutoFitToggle?: () => void;
59
+ /** Node ID to show a pulsing ripple on (most recently active node) */
60
+ pulseNodeId?: string | null;
61
+ /** Whether pulse animation is enabled */
62
+ showPulse?: boolean;
63
+ /** Callback to toggle pulse animation */
64
+ onShowPulseToggle?: () => void;
65
+ /** When set, only show this epic and its connected subgraph */
66
+ focusedEpicId?: string | null;
67
+ /** Callback to exit focused epic mode */
68
+ onExitFocusedEpic?: () => void;
55
69
  }
56
70
 
57
71
  // Node size calculation
@@ -75,6 +89,8 @@ let _currentColorMode: ColorMode = "status";
75
89
  // Get color based on current color mode
76
90
  function getNodeColor(node: GraphNode): string {
77
91
  switch (_currentColorMode) {
92
+ case "priority":
93
+ return PRIORITY_COLORS[node.priority] || PRIORITY_COLORS[2];
78
94
  case "owner":
79
95
  return getPersonColor(node.createdBy);
80
96
  case "assignee":
@@ -237,6 +253,13 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
237
253
  onExpandAll,
238
254
  colorMode = "status",
239
255
  onColorModeChange,
256
+ autoFit = true,
257
+ onAutoFitToggle,
258
+ pulseNodeId,
259
+ showPulse = true,
260
+ onShowPulseToggle,
261
+ focusedEpicId,
262
+ onExitFocusedEpic,
240
263
  }, ref) {
241
264
  const graphRef = useRef<any>(null);
242
265
  const containerRef = useRef<HTMLDivElement>(null);
@@ -293,6 +316,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
293
316
  );
294
317
  // Color mode ref for paintNode (which has [] deps) to read current color mode
295
318
  const colorModeRef = useRef<ColorMode>(colorMode);
319
+ // Pulse node ref: which node to show ripple animation on
320
+ const pulseNodeIdRef = useRef<string | null>(pulseNodeId || null);
321
+ const showPulseRef = useRef<boolean>(showPulse);
296
322
 
297
323
  // Callback ref for refreshing graph when avatar images finish loading
298
324
  const avatarRefreshRef = useRef<() => void>(() => {});
@@ -327,11 +353,64 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
327
353
  // then removes child nodes and remaps their links to the parent epic.
328
354
  // Must be declared BEFORE effects that reference viewNodes/viewLinks.
329
355
  const { viewNodes, viewLinks } = useMemo(() => {
330
- if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: nodes, viewLinks: links };
356
+ let currentNodes = nodes;
357
+ let currentLinks = links;
358
+
359
+ // === PHASE 1: Epic focus mode ===
360
+ // When focused on an epic, filter to only the epic's subgraph
361
+ if (focusedEpicId) {
362
+ // Build child->parent map
363
+ const childToParent = new Map<string, string>();
364
+ for (const link of currentLinks) {
365
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
366
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
367
+ if (link.type === "parent-child") {
368
+ childToParent.set(tgt, src);
369
+ }
370
+ }
371
+ const nodeIdSet = new Set(currentNodes.map((n) => n.id));
372
+ for (const node of currentNodes) {
373
+ if (!childToParent.has(node.id) && node.id.includes(".")) {
374
+ const parentId = node.id.split(".")[0];
375
+ if (nodeIdSet.has(parentId)) {
376
+ childToParent.set(node.id, parentId);
377
+ }
378
+ }
379
+ }
380
+
381
+ // Collect epic + direct children
382
+ const subgraphIds = new Set<string>();
383
+ subgraphIds.add(focusedEpicId);
384
+ for (const [childId, parentId] of childToParent) {
385
+ if (parentId === focusedEpicId) {
386
+ subgraphIds.add(childId);
387
+ }
388
+ }
389
+
390
+ // Add 1-hop neighbors connected via blocks/relates_to links
391
+ for (const link of currentLinks) {
392
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
393
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
394
+ if (link.type !== "parent-child") {
395
+ if (subgraphIds.has(src) && nodeIdSet.has(tgt)) subgraphIds.add(tgt);
396
+ if (subgraphIds.has(tgt) && nodeIdSet.has(src)) subgraphIds.add(src);
397
+ }
398
+ }
399
+
400
+ currentNodes = currentNodes.filter((n) => subgraphIds.has(n.id));
401
+ currentLinks = currentLinks.filter((link) => {
402
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
403
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
404
+ return subgraphIds.has(src) && subgraphIds.has(tgt);
405
+ });
406
+ }
407
+
408
+ // === PHASE 2: Collapse mode ===
409
+ if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
331
410
 
332
411
  // Build child->parent map from parent-child links
333
412
  const childToParent = new Map<string, string>();
334
- for (const link of links) {
413
+ for (const link of currentLinks) {
335
414
  const src = typeof link.source === "object" ? (link.source as any).id : link.source;
336
415
  const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
337
416
  if (link.type === "parent-child") {
@@ -340,8 +419,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
340
419
  }
341
420
  }
342
421
  // Fallback: infer from hierarchical IDs (e.g., "beads-map-3r3.1" -> parent "beads-map-3r3")
343
- const nodeIds = new Set(nodes.map((n) => n.id));
344
- for (const node of nodes) {
422
+ const nodeIds = new Set(currentNodes.map((n) => n.id));
423
+ for (const node of currentNodes) {
345
424
  if (!childToParent.has(node.id) && node.id.includes(".")) {
346
425
  const parentId = node.id.split(".")[0];
347
426
  if (nodeIds.has(parentId)) {
@@ -358,7 +437,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
358
437
  }
359
438
  }
360
439
 
361
- if (childIds.size === 0) return { viewNodes: nodes, viewLinks: links };
440
+ if (childIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
362
441
 
363
442
  // Also build a filtered childToParent for only the collapsed children (for link remapping)
364
443
  const collapsedChildToParent = new Map<string, string>();
@@ -372,7 +451,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
372
451
  const extraDependentCount = new Map<string, number>();
373
452
  for (const [childId, parentId] of collapsedChildToParent) {
374
453
  collapsedCounts.set(parentId, (collapsedCounts.get(parentId) || 0) + 1);
375
- const child = nodes.find((n) => n.id === childId);
454
+ const child = currentNodes.find((n) => n.id === childId);
376
455
  if (child) {
377
456
  extraBlockerCount.set(parentId, (extraBlockerCount.get(parentId) || 0) + child.blockerCount);
378
457
  extraDependentCount.set(parentId, (extraDependentCount.get(parentId) || 0) + child.dependentCount);
@@ -380,7 +459,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
380
459
  }
381
460
 
382
461
  // Filter nodes: remove collapsed children, augment their parents
383
- const filteredNodes: GraphNode[] = nodes
462
+ const filteredNodes: GraphNode[] = currentNodes
384
463
  .filter((n) => !childIds.has(n.id))
385
464
  .map((n) => ({
386
465
  ...n,
@@ -392,7 +471,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
392
471
  // Remap links: replace collapsed child IDs with parent IDs, drop internal parent-child links
393
472
  const remappedLinks: GraphLink[] = [];
394
473
  const linkSeen = new Set<string>();
395
- for (const link of links) {
474
+ for (const link of currentLinks) {
396
475
  let src = typeof link.source === "object" ? (link.source as any).id : link.source;
397
476
  let tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
398
477
  // Drop parent-child links where the child is collapsed
@@ -408,7 +487,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
408
487
  }
409
488
 
410
489
  return { viewNodes: filteredNodes, viewLinks: remappedLinks };
411
- }, [nodes, links, collapsedEpicIds]);
490
+ }, [nodes, links, collapsedEpicIds, focusedEpicId]);
412
491
 
413
492
  // Keep viewNodesRef in sync for mousemove avatar hit-testing
414
493
  viewNodesRef.current = viewNodes;
@@ -463,7 +542,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
463
542
 
464
543
  // Compute dynamic legend items based on color mode and visible nodes
465
544
  const legendItems = useMemo(() => {
466
- if (colorMode === "status") return []; // handled by static STATUS_COLORS rendering
545
+ if (colorMode === "status" || colorMode === "priority") return []; // handled by static rendering
467
546
 
468
547
  const items = new Map<string, string>(); // label -> color
469
548
 
@@ -552,6 +631,13 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
552
631
  minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
553
632
  }, [colorMode]);
554
633
 
634
+ // Sync pulse node ref
635
+ useEffect(() => {
636
+ pulseNodeIdRef.current = pulseNodeId || null;
637
+ showPulseRef.current = showPulse;
638
+ refreshGraph(graphRef);
639
+ }, [pulseNodeId, showPulse]);
640
+
555
641
  // Avatar hover detection: mousemove on container, hit-test against avatar positions
556
642
  useEffect(() => {
557
643
  const container = containerRef.current;
@@ -830,16 +916,19 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
830
916
  // Re-heat simulation so new forces take effect immediately
831
917
  fg.d3ReheatSimulation();
832
918
 
833
- // Fit to view after layout settles
834
- const delay = initialLayoutApplied.current ? 600 : 1000;
835
- const timer = setTimeout(() => {
836
- if (graphRef.current) graphRef.current.zoomToFit(400, 60);
837
- }, delay);
919
+ // Fit to view after layout settles (only if auto-fit is enabled)
920
+ let timer: ReturnType<typeof setTimeout> | undefined;
921
+ if (autoFit) {
922
+ const delay = initialLayoutApplied.current ? 600 : 1000;
923
+ timer = setTimeout(() => {
924
+ if (graphRef.current) graphRef.current.zoomToFit(400, 60);
925
+ }, delay);
926
+ }
838
927
 
839
928
  initialLayoutApplied.current = true;
840
929
 
841
- return () => clearTimeout(timer);
842
- }, [layoutMode, viewNodes, viewLinks]);
930
+ return () => { if (timer) clearTimeout(timer); };
931
+ }, [layoutMode, viewNodes, viewLinks, autoFit]);
843
932
 
844
933
  // Bootstrap trick: start in DAG to spread nodes into good positions,
845
934
  // then auto-switch to Force mode. This replicates the exact code path
@@ -855,16 +944,35 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
855
944
  return () => clearTimeout(timer);
856
945
  }, [ForceGraph2D, nodes.length]);
857
946
 
858
- // Fit to view on initial load (skip during timeline replay)
947
+ // Fit to view on initial load (skip during timeline replay or when auto-fit disabled)
859
948
  useEffect(() => {
860
949
  if (timelineActive) return;
950
+ if (!autoFit) return;
861
951
  if (graphRef.current && nodes.length > 0) {
862
952
  const timer = setTimeout(() => {
863
953
  graphRef.current.zoomToFit(400, 60);
864
954
  }, 800);
865
955
  return () => clearTimeout(timer);
866
956
  }
867
- }, [nodes.length, timelineActive]);
957
+ }, [nodes.length, timelineActive, autoFit]);
958
+
959
+ // Auto zoom-to-fit when entering/exiting epic focus mode (unconditional)
960
+ const prevFocusedEpicIdRef = useRef<string | null | undefined>(undefined);
961
+ useEffect(() => {
962
+ // Skip on mount (initial render)
963
+ if (prevFocusedEpicIdRef.current === undefined) {
964
+ prevFocusedEpicIdRef.current = focusedEpicId ?? null;
965
+ return;
966
+ }
967
+ prevFocusedEpicIdRef.current = focusedEpicId ?? null;
968
+ const graph = graphRef.current;
969
+ if (!graph) return;
970
+ // Small delay to let the force graph process the new node set
971
+ const timer = setTimeout(() => {
972
+ graph.zoomToFit(400, 60);
973
+ }, 100);
974
+ return () => clearTimeout(timer);
975
+ }, [focusedEpicId]);
868
976
 
869
977
  // Memoize graphData so the object reference stays stable across renders.
870
978
  // This prevents react-force-graph from treating it as "new data" and
@@ -938,23 +1046,13 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
938
1046
 
939
1047
  const animatedSize = size * animScale;
940
1048
 
941
- // Semantic zoom: fade out individual nodes when zoomed out
942
- // Transition zone: globalScale 0.1 (invisible) → 0.2 (fully visible)
943
- const FADE_OUT_MIN = 0.1;
944
- const FADE_OUT_MAX = 0.2;
945
- const zoomFade = globalScale <= FADE_OUT_MIN
946
- ? 0
947
- : globalScale >= FADE_OUT_MAX
948
- ? 1
949
- : (globalScale - FADE_OUT_MIN) / (FADE_OUT_MAX - FADE_OUT_MIN);
950
-
951
1049
  // Opacity: dim non-connected nodes when highlighting
952
1050
  const opacity =
953
1051
  (hasHighlight && !isConnected
954
1052
  ? 0.15
955
1053
  : graphNode.status === "closed"
956
1054
  ? 0.5
957
- : 1) * removeOpacity * zoomFade;
1055
+ : 1) * removeOpacity;
958
1056
 
959
1057
  if (opacity <= 0.01) return; // skip fully faded nodes
960
1058
 
@@ -1014,6 +1112,30 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1014
1112
  }
1015
1113
  }
1016
1114
 
1115
+ // --- Activity pulse ripple (continuous, on most-recently-active node) ---
1116
+ if (showPulseRef.current && pulseNodeIdRef.current === graphNode.id) {
1117
+ const RIPPLE_PERIOD = 2000; // full cycle in ms
1118
+ const RIPPLE_COUNT = 3;
1119
+ const RIPPLE_STAGGER = 500; // ms between each ring
1120
+ // Scale ripple to ~30 screen pixels regardless of zoom level
1121
+ const maxExpand = Math.max(25, 30 / globalScale);
1122
+ const MAX_RIPPLE_RADIUS = animatedSize + maxExpand;
1123
+
1124
+ for (let i = 0; i < RIPPLE_COUNT; i++) {
1125
+ const phase = ((now + i * RIPPLE_STAGGER) % RIPPLE_PERIOD) / RIPPLE_PERIOD;
1126
+ const rippleRadius = animatedSize + 2 / globalScale + phase * (MAX_RIPPLE_RADIUS - animatedSize);
1127
+ const rippleOpacity = (1 - phase) * 0.6;
1128
+
1129
+ ctx.beginPath();
1130
+ ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);
1131
+ ctx.strokeStyle = "#10b981"; // emerald-500
1132
+ ctx.lineWidth = Math.max(1.5 / globalScale, 0.5);
1133
+ ctx.globalAlpha = rippleOpacity * opacity;
1134
+ ctx.stroke();
1135
+ }
1136
+ ctx.globalAlpha = opacity; // reset
1137
+ }
1138
+
1017
1139
  // --- Spawn glow ---
1018
1140
  if (spawnTime) {
1019
1141
  const elapsed = now - spawnTime;
@@ -1223,15 +1345,6 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1223
1345
  const isConnectedLink =
1224
1346
  hasHighlight && connected.has(srcId) && connected.has(tgtId);
1225
1347
 
1226
- // Semantic zoom: fade out links when zoomed out (same range as nodes)
1227
- const LINK_FADE_OUT_MIN = 0.1;
1228
- const LINK_FADE_OUT_MAX = 0.2;
1229
- const linkZoomFade = globalScale <= LINK_FADE_OUT_MIN
1230
- ? 0
1231
- : globalScale >= LINK_FADE_OUT_MAX
1232
- ? 1
1233
- : (globalScale - LINK_FADE_OUT_MIN) / (LINK_FADE_OUT_MAX - LINK_FADE_OUT_MIN);
1234
-
1235
1348
  // Parent-child links are more subtle
1236
1349
  const opacity = (isParentChild
1237
1350
  ? hasHighlight
@@ -1239,7 +1352,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1239
1352
  : 0.2
1240
1353
  : hasHighlight
1241
1354
  ? isConnectedLink ? 0.8 : 0.08
1242
- : 0.35) * linkAnimAlpha * linkZoomFade;
1355
+ : 0.35) * linkAnimAlpha;
1243
1356
 
1244
1357
  if (opacity <= 0.01) return; // skip fully faded links
1245
1358
 
@@ -1330,7 +1443,6 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1330
1443
 
1331
1444
  // Semantic zoom: draw epic/cluster labels when zoomed out far.
1332
1445
  // Computes centroids from live node positions and draws titles.
1333
- // Appears as nodes fade out (inverse of node fade).
1334
1446
  const paintClusterLabels = useCallback(
1335
1447
  (ctx: CanvasRenderingContext2D, globalScale: number) => {
1336
1448
  if (!showClusters) return;
@@ -1786,230 +1898,293 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1786
1898
  );
1787
1899
 
1788
1900
  return (
1789
- <div ref={containerRef} className="w-full h-full relative">
1901
+ <div ref={containerRef} className="w-full h-full relative" data-tutorial="graph">
1790
1902
  {/* Top-left controls */}
1791
- <div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex items-start gap-1.5 sm:gap-2">
1792
- {/* Layout mode toggle */}
1793
- <div className="flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden">
1794
- <button
1795
- onClick={() => setLayoutMode("force")}
1796
- className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1797
- layoutMode === "force"
1798
- ? "bg-emerald-500 text-white"
1799
- : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1800
- }`}
1801
- >
1802
- <span className="flex items-center gap-1.5">
1803
- <svg
1804
- className="w-3.5 h-3.5"
1805
- viewBox="0 0 16 16"
1806
- fill="none"
1807
- stroke="currentColor"
1808
- strokeWidth="1.5"
1809
- >
1810
- {/* Spring/force icon: scattered dots with connections */}
1811
- <circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
1812
- <circle cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
1813
- <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
1814
- <circle cx="3" cy="12" r="1.5" fill="currentColor" stroke="none" />
1815
- <circle cx="13" cy="11" r="1.5" fill="currentColor" stroke="none" />
1816
- <line x1="4" y1="4" x2="8" y2="8" strokeOpacity="0.5" />
1817
- <line x1="12" y1="3" x2="8" y2="8" strokeOpacity="0.5" />
1818
- <line x1="3" y1="12" x2="8" y2="8" strokeOpacity="0.5" />
1819
- <line x1="13" y1="11" x2="8" y2="8" strokeOpacity="0.5" />
1820
- </svg>
1821
- <span className="hidden sm:inline">Force</span>
1822
- </span>
1823
- </button>
1824
- <div className="w-px bg-zinc-200" />
1825
- <button
1826
- onClick={() => setLayoutMode("dag")}
1827
- className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1828
- layoutMode === "dag"
1829
- ? "bg-emerald-500 text-white"
1830
- : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1831
- }`}
1832
- >
1833
- <span className="flex items-center gap-1.5">
1834
- <svg
1835
- className="w-3.5 h-3.5"
1836
- viewBox="0 0 16 16"
1837
- fill="none"
1838
- stroke="currentColor"
1839
- strokeWidth="1.5"
1840
- >
1841
- {/* Tree/DAG icon: top-down hierarchy */}
1842
- <circle cx="8" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
1843
- <circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
1844
- <circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
1845
- <circle cx="2" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
1846
- <circle cx="6" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
1847
- <circle cx="12" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
1848
- <line x1="8" y1="4" x2="4" y2="6.5" strokeOpacity="0.5" />
1849
- <line x1="8" y1="4" x2="12" y2="6.5" strokeOpacity="0.5" />
1850
- <line x1="4" y1="9.5" x2="2" y2="12" strokeOpacity="0.5" />
1851
- <line x1="4" y1="9.5" x2="6" y2="12" strokeOpacity="0.5" />
1852
- <line x1="12" y1="9.5" x2="12" y2="12" strokeOpacity="0.5" />
1853
- </svg>
1854
- <span className="hidden sm:inline">DAG</span>
1855
- </span>
1856
- </button>
1857
- <div className="w-px bg-zinc-200" />
1858
- <button
1859
- onClick={() => setLayoutMode("radial")}
1860
- className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1861
- layoutMode === "radial"
1862
- ? "bg-emerald-500 text-white"
1863
- : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1864
- }`}
1865
- >
1866
- <span className="flex items-center gap-1.5">
1867
- <svg
1868
- className="w-3.5 h-3.5"
1869
- viewBox="0 0 16 16"
1870
- fill="none"
1871
- stroke="currentColor"
1872
- strokeWidth="1.5"
1873
- >
1874
- {/* Radial icon: concentric rings with center dot */}
1875
- <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
1876
- <circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
1877
- <circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
1878
- </svg>
1879
- <span className="hidden sm:inline">Radial</span>
1880
- </span>
1881
- </button>
1882
- <div className="w-px bg-zinc-200" />
1883
- <button
1884
- onClick={() => setLayoutMode("cluster")}
1885
- className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1886
- layoutMode === "cluster"
1887
- ? "bg-emerald-500 text-white"
1888
- : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1889
- }`}
1890
- >
1891
- <span className="flex items-center gap-1.5">
1892
- <svg
1893
- className="w-3.5 h-3.5"
1894
- viewBox="0 0 16 16"
1895
- fill="none"
1896
- stroke="currentColor"
1897
- strokeWidth="1.5"
1898
- >
1899
- {/* Cluster icon: two groups of dots */}
1900
- <circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
1901
- <circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
1902
- <circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
1903
- <circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
1904
- <circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
1905
- <circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
1906
- </svg>
1907
- <span className="hidden sm:inline">Cluster</span>
1903
+ <div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2">
1904
+ {/* Focus mode banner */}
1905
+ {focusedEpicId && onExitFocusedEpic && (
1906
+ <div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-emerald-50/90 backdrop-blur-sm rounded-lg border border-emerald-200 shadow-sm text-emerald-700">
1907
+ <svg className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1908
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M16.5 20.25H18A2.25 2.25 0 0020.25 18v-1.5M7.5 20.25H6A2.25 2.25 0 013.75 18v-1.5" />
1909
+ </svg>
1910
+ <span className="truncate max-w-[180px]">
1911
+ Focused: <span className="font-semibold">{nodes.find((n) => n.id === focusedEpicId)?.title || focusedEpicId}</span>
1908
1912
  </span>
1909
- </button>
1910
- <div className="w-px bg-zinc-200" />
1911
- <button
1912
- onClick={() => setLayoutMode("spread")}
1913
- className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1914
- layoutMode === "spread"
1915
- ? "bg-emerald-500 text-white"
1916
- : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1917
- }`}
1918
- >
1919
- <span className="flex items-center gap-1.5">
1920
- <svg
1921
- className="w-3.5 h-3.5"
1922
- viewBox="0 0 16 16"
1923
- fill="none"
1924
- stroke="currentColor"
1925
- strokeWidth="1.5"
1926
- >
1927
- {/* Spread icon: dots spread far apart */}
1928
- <circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
1929
- <circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
1930
- <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
1931
- <circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
1932
- <circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
1913
+ <button
1914
+ onClick={onExitFocusedEpic}
1915
+ className="ml-auto p-0.5 rounded hover:bg-emerald-200/50 transition-colors flex-shrink-0"
1916
+ title="Show full graph"
1917
+ >
1918
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1919
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1933
1920
  </svg>
1934
- <span className="hidden sm:inline">Spread</span>
1935
- </span>
1936
- </button>
1937
- </div>
1921
+ </button>
1922
+ </div>
1923
+ )}
1938
1924
 
1939
- {/* Collapse / Expand all toggle */}
1940
- {(onCollapseAll || onExpandAll) && (
1941
- <button
1942
- onClick={collapsedEpicIds && collapsedEpicIds.size > 0 ? onExpandAll : onCollapseAll}
1943
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50 transition-colors"
1944
- >
1945
- {collapsedEpicIds && collapsedEpicIds.size > 0 ? (
1946
- <>
1925
+ {/* Row 1: Layout shape controls */}
1926
+ <div className="flex items-start gap-1.5 sm:gap-2">
1927
+ {/* Layout mode toggle */}
1928
+ <div className="flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden" data-tutorial="layouts">
1929
+ <button
1930
+ onClick={() => setLayoutMode("force")}
1931
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1932
+ layoutMode === "force"
1933
+ ? "bg-emerald-500 text-white"
1934
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1935
+ }`}
1936
+ >
1937
+ <span className="flex items-center gap-1.5">
1947
1938
  <svg
1948
1939
  className="w-3.5 h-3.5"
1949
- viewBox="0 0 24 24"
1940
+ viewBox="0 0 16 16"
1950
1941
  fill="none"
1951
- strokeWidth={1.5}
1952
1942
  stroke="currentColor"
1943
+ strokeWidth="1.5"
1953
1944
  >
1954
- <path
1955
- strokeLinecap="round"
1956
- strokeLinejoin="round"
1957
- d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
1958
- />
1945
+ {/* Spring/force icon: scattered dots with connections */}
1946
+ <circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
1947
+ <circle cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
1948
+ <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
1949
+ <circle cx="3" cy="12" r="1.5" fill="currentColor" stroke="none" />
1950
+ <circle cx="13" cy="11" r="1.5" fill="currentColor" stroke="none" />
1951
+ <line x1="4" y1="4" x2="8" y2="8" strokeOpacity="0.5" />
1952
+ <line x1="12" y1="3" x2="8" y2="8" strokeOpacity="0.5" />
1953
+ <line x1="3" y1="12" x2="8" y2="8" strokeOpacity="0.5" />
1954
+ <line x1="13" y1="11" x2="8" y2="8" strokeOpacity="0.5" />
1959
1955
  </svg>
1960
- <span className="hidden sm:inline">Expand all</span>
1961
- </>
1962
- ) : (
1963
- <>
1956
+ <span className="hidden sm:inline">Force</span>
1957
+ </span>
1958
+ </button>
1959
+ <div className="w-px bg-zinc-200" />
1960
+ <button
1961
+ onClick={() => setLayoutMode("dag")}
1962
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1963
+ layoutMode === "dag"
1964
+ ? "bg-emerald-500 text-white"
1965
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1966
+ }`}
1967
+ >
1968
+ <span className="flex items-center gap-1.5">
1964
1969
  <svg
1965
1970
  className="w-3.5 h-3.5"
1966
- viewBox="0 0 24 24"
1971
+ viewBox="0 0 16 16"
1967
1972
  fill="none"
1968
- strokeWidth={1.5}
1969
1973
  stroke="currentColor"
1974
+ strokeWidth="1.5"
1970
1975
  >
1971
- <path
1972
- strokeLinecap="round"
1973
- strokeLinejoin="round"
1974
- d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
1975
- />
1976
+ {/* Tree/DAG icon: top-down hierarchy */}
1977
+ <circle cx="8" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
1978
+ <circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
1979
+ <circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
1980
+ <circle cx="2" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
1981
+ <circle cx="6" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
1982
+ <circle cx="12" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
1983
+ <line x1="8" y1="4" x2="4" y2="6.5" strokeOpacity="0.5" />
1984
+ <line x1="8" y1="4" x2="12" y2="6.5" strokeOpacity="0.5" />
1985
+ <line x1="4" y1="9.5" x2="2" y2="12" strokeOpacity="0.5" />
1986
+ <line x1="4" y1="9.5" x2="6" y2="12" strokeOpacity="0.5" />
1987
+ <line x1="12" y1="9.5" x2="12" y2="12" strokeOpacity="0.5" />
1976
1988
  </svg>
1977
- <span className="hidden sm:inline">Collapse all</span>
1978
- </>
1979
- )}
1989
+ <span className="hidden sm:inline">DAG</span>
1990
+ </span>
1991
+ </button>
1992
+ <div className="w-px bg-zinc-200" />
1993
+ <button
1994
+ onClick={() => setLayoutMode("radial")}
1995
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1996
+ layoutMode === "radial"
1997
+ ? "bg-emerald-500 text-white"
1998
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1999
+ }`}
2000
+ >
2001
+ <span className="flex items-center gap-1.5">
2002
+ <svg
2003
+ className="w-3.5 h-3.5"
2004
+ viewBox="0 0 16 16"
2005
+ fill="none"
2006
+ stroke="currentColor"
2007
+ strokeWidth="1.5"
2008
+ >
2009
+ {/* Radial icon: concentric rings with center dot */}
2010
+ <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
2011
+ <circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
2012
+ <circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
2013
+ </svg>
2014
+ <span className="hidden sm:inline">Radial</span>
2015
+ </span>
2016
+ </button>
2017
+ <div className="w-px bg-zinc-200" />
2018
+ <button
2019
+ onClick={() => setLayoutMode("cluster")}
2020
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
2021
+ layoutMode === "cluster"
2022
+ ? "bg-emerald-500 text-white"
2023
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
2024
+ }`}
2025
+ >
2026
+ <span className="flex items-center gap-1.5">
2027
+ <svg
2028
+ className="w-3.5 h-3.5"
2029
+ viewBox="0 0 16 16"
2030
+ fill="none"
2031
+ stroke="currentColor"
2032
+ strokeWidth="1.5"
2033
+ >
2034
+ {/* Cluster icon: two groups of dots */}
2035
+ <circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
2036
+ <circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
2037
+ <circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
2038
+ <circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
2039
+ <circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
2040
+ <circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
2041
+ </svg>
2042
+ <span className="hidden sm:inline">Cluster</span>
2043
+ </span>
2044
+ </button>
2045
+ <div className="w-px bg-zinc-200" />
2046
+ <button
2047
+ onClick={() => setLayoutMode("spread")}
2048
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
2049
+ layoutMode === "spread"
2050
+ ? "bg-emerald-500 text-white"
2051
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
2052
+ }`}
2053
+ >
2054
+ <span className="flex items-center gap-1.5">
2055
+ <svg
2056
+ className="w-3.5 h-3.5"
2057
+ viewBox="0 0 16 16"
2058
+ fill="none"
2059
+ stroke="currentColor"
2060
+ strokeWidth="1.5"
2061
+ >
2062
+ {/* Spread icon: dots spread far apart */}
2063
+ <circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
2064
+ <circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
2065
+ <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
2066
+ <circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
2067
+ <circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
2068
+ </svg>
2069
+ <span className="hidden sm:inline">Spread</span>
2070
+ </span>
2071
+ </button>
2072
+ </div>
2073
+ </div>
2074
+
2075
+ {/* Row 2: View toggles */}
2076
+ <div className="flex items-start gap-1.5 sm:gap-2" data-tutorial="view-controls">
2077
+ {/* Collapse / Expand all toggle */}
2078
+ {(onCollapseAll || onExpandAll) && (
2079
+ <button
2080
+ onClick={collapsedEpicIds && collapsedEpicIds.size > 0 ? onExpandAll : onCollapseAll}
2081
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50 transition-colors"
2082
+ >
2083
+ {collapsedEpicIds && collapsedEpicIds.size > 0 ? (
2084
+ <>
2085
+ <svg
2086
+ className="w-3.5 h-3.5"
2087
+ viewBox="0 0 24 24"
2088
+ fill="none"
2089
+ strokeWidth={1.5}
2090
+ stroke="currentColor"
2091
+ >
2092
+ <path
2093
+ strokeLinecap="round"
2094
+ strokeLinejoin="round"
2095
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
2096
+ />
2097
+ </svg>
2098
+ <span className="hidden sm:inline">Expand all</span>
2099
+ </>
2100
+ ) : (
2101
+ <>
2102
+ <svg
2103
+ className="w-3.5 h-3.5"
2104
+ viewBox="0 0 24 24"
2105
+ fill="none"
2106
+ strokeWidth={1.5}
2107
+ stroke="currentColor"
2108
+ >
2109
+ <path
2110
+ strokeLinecap="round"
2111
+ strokeLinejoin="round"
2112
+ d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
2113
+ />
2114
+ </svg>
2115
+ <span className="hidden sm:inline">Collapse all</span>
2116
+ </>
2117
+ )}
2118
+ </button>
2119
+ )}
2120
+
2121
+ {/* Show/hide cluster labels toggle */}
2122
+ <button
2123
+ onClick={() => setShowClusters((v) => !v)}
2124
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium backdrop-blur-sm rounded-lg border shadow-sm transition-colors ${
2125
+ showClusters
2126
+ ? "bg-emerald-500 text-white border-emerald-500"
2127
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
2128
+ }`}
2129
+ title={showClusters ? "Hide cluster labels" : "Show cluster labels"}
2130
+ >
2131
+ <svg
2132
+ className="w-3.5 h-3.5"
2133
+ viewBox="0 0 16 16"
2134
+ fill="none"
2135
+ stroke="currentColor"
2136
+ strokeWidth="1.5"
2137
+ >
2138
+ {/* Dashed circle with label lines — cluster overlay icon */}
2139
+ <circle cx="8" cy="8" r="6" strokeDasharray="2.5 2" strokeOpacity={showClusters ? 1 : 0.5} />
2140
+ <line x1="5" y1="8" x2="11" y2="8" strokeOpacity={showClusters ? 1 : 0.4} />
2141
+ <line x1="6" y1="10" x2="10" y2="10" strokeOpacity={showClusters ? 0.6 : 0.25} strokeWidth="1" />
2142
+ </svg>
2143
+ <span className="hidden sm:inline">Clusters</span>
1980
2144
  </button>
1981
- )}
1982
2145
 
1983
- {/* Show/hide cluster labels toggle */}
1984
- <button
1985
- onClick={() => setShowClusters((v) => !v)}
1986
- className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium backdrop-blur-sm rounded-lg border shadow-sm transition-colors ${
1987
- showClusters
1988
- ? "bg-emerald-500 text-white border-emerald-500"
1989
- : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
1990
- }`}
1991
- title={showClusters ? "Hide cluster labels" : "Show cluster labels"}
1992
- >
1993
- <svg
1994
- className="w-3.5 h-3.5"
1995
- viewBox="0 0 16 16"
1996
- fill="none"
1997
- stroke="currentColor"
1998
- strokeWidth="1.5"
2146
+ {/* Auto-fit: lock/unlock automatic camera reframing */}
2147
+ <button
2148
+ onClick={() => onAutoFitToggle?.()}
2149
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium backdrop-blur-sm rounded-lg border shadow-sm transition-colors ${
2150
+ autoFit
2151
+ ? "bg-emerald-500 text-white border-emerald-500"
2152
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
2153
+ }`}
2154
+ title={autoFit ? "Auto-fit enabled: camera adjusts after updates" : "Auto-fit disabled: camera stays fixed"}
1999
2155
  >
2000
- {/* Dashed circle with label lines cluster overlay icon */}
2001
- <circle cx="8" cy="8" r="6" strokeDasharray="2.5 2" strokeOpacity={showClusters ? 1 : 0.5} />
2002
- <line x1="5" y1="8" x2="11" y2="8" strokeOpacity={showClusters ? 1 : 0.4} />
2003
- <line x1="6" y1="10" x2="10" y2="10" strokeOpacity={showClusters ? 0.6 : 0.25} strokeWidth="1" />
2004
- </svg>
2005
- <span className="hidden sm:inline">Clusters</span>
2006
- </button>
2156
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
2157
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M20.25 16.5V18A2.25 2.25 0 0118 20.25h-1.5M3.75 16.5V18A2.25 2.25 0 006 20.25h1.5" />
2158
+ <circle cx="12" cy="12" r="3" />
2159
+ </svg>
2160
+ <span className="hidden sm:inline">Auto-fit</span>
2161
+ </button>
2162
+
2163
+ {/* Pulse: highlight most recently active node */}
2164
+ <button
2165
+ onClick={() => onShowPulseToggle?.()}
2166
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium backdrop-blur-sm rounded-lg border shadow-sm transition-colors ${
2167
+ showPulse
2168
+ ? "bg-emerald-500 text-white border-emerald-500"
2169
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
2170
+ }`}
2171
+ title={showPulse ? "Pulse enabled: ripple highlights most recent activity" : "Pulse disabled: no activity highlight"}
2172
+ >
2173
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
2174
+ <circle cx="12" cy="12" r="3" />
2175
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.07 4.93A10 10 0 014.93 19.07M4.93 4.93a10 10 0 0114.14 14.14" strokeOpacity="0.5" />
2176
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.24 7.76a6 6 0 01.01 8.49M7.76 7.76a6 6 0 000 8.49" strokeOpacity="0.7" />
2177
+ </svg>
2178
+ <span className="hidden sm:inline">Pulse</span>
2179
+ </button>
2180
+ </div>
2007
2181
  </div>
2008
2182
 
2009
2183
  {/* Bottom-right info panel: stats + color mode selector + legend (hidden when timeline active) */}
2010
2184
  {!timelineActive && (
2011
2185
  <div
2012
2186
  className="absolute bottom-4 z-10 bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2 text-xs text-zinc-400 transition-[right] duration-300 ease-out"
2187
+ data-tutorial="legend"
2013
2188
  style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem", maxWidth: 320 }}
2014
2189
  >
2015
2190
  {stats && (
@@ -2024,7 +2199,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
2024
2199
  )}
2025
2200
  {/* Color mode segmented control */}
2026
2201
  <div className="hidden sm:flex bg-zinc-100 rounded-md overflow-hidden mb-1.5">
2027
- {(["status", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
2202
+ {(["status", "priority", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
2028
2203
  <button
2029
2204
  key={mode}
2030
2205
  onClick={() => onColorModeChange?.(mode)}
@@ -2038,7 +2213,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
2038
2213
  </button>
2039
2214
  ))}
2040
2215
  </div>
2041
- {/* Dynamic legend: status dots or person/prefix dots */}
2216
+ {/* Dynamic legend: status/priority dots or person/prefix dots */}
2042
2217
  <div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5">
2043
2218
  {colorMode === "status" ? (
2044
2219
  <>
@@ -2052,6 +2227,18 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
2052
2227
  </span>
2053
2228
  ))}
2054
2229
  </>
2230
+ ) : colorMode === "priority" ? (
2231
+ <>
2232
+ {[0, 1, 2, 3, 4].map((p) => (
2233
+ <span key={p} className="flex items-center gap-1">
2234
+ <span
2235
+ className="w-2 h-2 rounded-full"
2236
+ style={{ backgroundColor: PRIORITY_COLORS[p] }}
2237
+ />
2238
+ <span className="text-zinc-500">{PRIORITY_LABELS[p]}</span>
2239
+ </span>
2240
+ ))}
2241
+ </>
2055
2242
  ) : (
2056
2243
  <>
2057
2244
  {legendItems.map(({ label, color }) => (
@@ -2079,6 +2266,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
2079
2266
  {/* Minimap — bottom-left, hidden on mobile, resizable */}
2080
2267
  <div
2081
2268
  className="hidden sm:block absolute bottom-4 left-4 z-10"
2269
+ data-tutorial="minimap"
2082
2270
  style={{ width: MINIMAP_W, height: MINIMAP_H }}
2083
2271
  >
2084
2272
  <canvas
@@ -2220,6 +2408,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
2220
2408
  onZoom={handleZoom}
2221
2409
  // Background
2222
2410
  backgroundColor="transparent"
2411
+ // Disable auto-pause when pulse is active so canvas redraws every frame
2412
+ autoPauseRedraw={!(showPulse && pulseNodeId)}
2223
2413
  />
2224
2414
  ) : (
2225
2415
  <div className="flex items-center justify-center h-full">