beads-map 0.3.0 → 0.3.3

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 (37) 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 +5 -5
  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-4a4f07fcb5bd4637.js +1 -0
  22. package/.next/static/css/df2737696baac0fa.css +3 -0
  23. package/README.md +21 -11
  24. package/app/page.tsx +113 -7
  25. package/components/BeadTooltip.tsx +26 -2
  26. package/components/BeadsGraph.tsx +460 -234
  27. package/components/DescriptionModal.tsx +48 -18
  28. package/components/HelpPanel.tsx +336 -0
  29. package/components/NodeDetail.tsx +33 -8
  30. package/components/TutorialOverlay.tsx +187 -0
  31. package/lib/types.ts +67 -0
  32. package/lib/utils.ts +23 -0
  33. package/package.json +1 -1
  34. package/.next/static/chunks/app/page-68492e6aaf15a6dd.js +0 -1
  35. package/.next/static/css/c854bc2280bc4b27.css +0 -3
  36. /package/.next/static/{ac0cLw5kGBDWoceTBnu21 → bsmkR-2y8Ra7VuoNZWLzB}/_buildManifest.js +0 -0
  37. /package/.next/static/{ac0cLw5kGBDWoceTBnu21 → bsmkR-2y8Ra7VuoNZWLzB}/_ssgManifest.js +0 -0
@@ -1 +1 @@
1
- {"issues":[{"id":"beads-map-21c","title":"Timeline replay: scrubber bar to animate project history","description":"## Timeline Replay: scrubber bar to animate project history\n\n### Summary\nAdd a timeline replay feature that lets users watch the project's history unfold. A playback bar at the bottom-right of the graph (replacing the current floating legend hint) provides play/pause, a draggable scrubber, and speed controls. As the virtual clock advances, nodes pop into existence (at their createdAt time), show status changes (at updatedAt), and fade to closed (at closedAt). Links appear when their dependency was created.\n\n### Architecture\n\n**Data layer (`lib/timeline.ts` — new file):**\n- `TimelineEvent` type: `{ time: number, type: 'node-created'|'node-closed'|'link-created', id: string }`\n- `buildTimelineEvents(nodes, links)`: extracts all timestamped events from nodes (createdAt, closedAt) and links (createdAt), sorts chronologically, returns `{ events: TimelineEvent[], minTime: number, maxTime: number }`\n- `filterDataAtTime(allNodes, allLinks, currentTime)`: returns `{ nodes: GraphNode[], links: GraphLink[] }` containing only items visible at `currentTime`. Nodes visible when `createdAt <= currentTime`. Node status = closed if `closedAt && closedAt <= currentTime`, else original status. Links visible when both endpoints visible AND `link.createdAt <= currentTime`.\n\n**Component (`components/TimelineBar.tsx` — new file):**\n- Positioned absolute bottom-right inside BeadsGraph, replaces the floating legend hint\n- Contains: play/pause button (svg icons), horizontal range slider, current date/time label, speed toggle (1x/2x/4x)\n- `requestAnimationFrame` loop advances currentTime when playing\n- Dragging slider pauses playback and updates currentTime\n- Props: `minTime`, `maxTime`, `currentTime`, `isPlaying`, `speed`, `onTimeChange`, `onPlayPause`, `onSpeedChange`\n- Tick marks on slider for event density (optional visual enhancement)\n\n**Wiring (`app/page.tsx` + `components/BeadsGraph.tsx`):**\n- New state in page.tsx: `timelineActive: boolean`, `timelineTime: number`, `timelinePlaying: boolean`, `timelineSpeed: number`\n- New pill button in header (same style as Force/DAG/Comments pills) to toggle timeline mode\n- When timelineActive: compute filtered nodes/links via filterDataAtTime, stamp _spawnTime on newly-visible nodes, pass filtered data to BeadsGraph\n- SSE live updates still accumulate into `data` but filtered view controls what's shown\n- TimelineBar rendered inside BeadsGraph (or as overlay in graph area)\n\n**NodeDetail date format enhancement:**\n- Change formatDate() to include hour:minute — \"Feb 10, 2026 at 11:48\"\n\n**GraphLink.createdAt:**\n- Add optional `createdAt?: string` to GraphLink type\n- Populate from BeadDependency.created_at in buildGraphData()\n\n### Subject areas\n- `lib/types.ts` — GraphLink.createdAt addition\n- `lib/parse-beads.ts` — populate link createdAt\n- `lib/timeline.ts` — new file, pure functions for event extraction and time-filtering\n- `components/TimelineBar.tsx` — new component\n- `components/BeadsGraph.tsx` — render TimelineBar, replace legend hint when timeline active\n- `components/NodeDetail.tsx` — formatDate with time\n- `app/page.tsx` — state, pill button, filtering logic, wiring\n\n### Status at a point in time\nSince we only have createdAt/updatedAt/closedAt (not per-status-change history), the replay shows:\n- Before createdAt: node doesn't exist\n- Between createdAt and closedAt: node shows as \"open\" (original non-closed status)\n- At closedAt: node transitions to \"closed\" status with ripple animation\n- updatedAt: can trigger a subtle pulse to indicate activity\n\n### Speed mapping\n1x = 1 real second per calendar day of project time. 2x = 2 days/sec. 4x = 4 days/sec.\n\n### Dependency chain\n.1 (formatDate) is independent\n.2 (GraphLink.createdAt) is independent\n.3 (timeline.ts) depends on .2 (needs link createdAt)\n.4 (TimelineBar component) is independent (pure UI)\n.5 (wiring in page.tsx + BeadsGraph) depends on .2, .3, .4\n.6 (build verification) depends on .5","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T01:47:14.847191+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:13:05.329913+13:00","closed_at":"2026-02-11T02:13:05.329913+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.244292+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.1","title":"Add hour:minute to date display in NodeDetail","description":"## Add hour:minute to date display in NodeDetail\n\n### What\nChange the formatDate() function in components/NodeDetail.tsx to include hour and minute alongside the existing date.\n\n### Current code (components/NodeDetail.tsx, lines 132-144)\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n } catch {\n return dateStr;\n }\n};\n```\n\nCurrent output: \"Feb 10, 2026\"\n\n### Target output\n\"Feb 10, 2026 at 11:48\"\n\n### Implementation\nReplace the formatDate function body. Use toLocaleDateString for the date part and toLocaleTimeString for the time part:\n\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n const date = d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n const time = d.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n hour12: false,\n });\n return `${date} at ${time}`;\n } catch {\n return dateStr;\n }\n};\n```\n\n### Where it's used\nThe formatDate function is called in three places in the same file (lines 220-258):\n- `formatDate(node.createdAt)` — Created row\n- `formatDate(node.updatedAt)` — Updated row\n- `formatDate(node.closedAt)` — Closed row (conditional)\n\nAll three will automatically pick up the new format.\n\n### Files to edit\n- `components/NodeDetail.tsx` — lines 132-144, formatDate function only\n\n### Acceptance criteria\n- Date rows in NodeDetail show \"Feb 10, 2026 at 11:48\" format\n- Hours use 24h format (no AM/PM) for compactness\n- No other files changed\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:47:27.387689+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:53:53.448072+13:00","closed_at":"2026-02-11T01:53:53.448072+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.1","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:27.389228+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.10","title":"Build verify and push timeline link/preamble/speed fix","description":"## Build verify and push\n\nRun pnpm build, fix any type errors, commit and push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.10\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline: links with both nodes, empty preamble, 2s per event (beads-map-21c.9)\"\ngit push\n```\n\n### Edge cases\n- Link between two nodes that appear on the same step — link should appear immediately\n- Preamble (step -1) shows empty canvas, then step 0 shows first event\n- Scrubbing slider to step 0 shows first event (not preamble)\n- Speed change during playback — interval restarts correctly\n- Toggle off during preamble — clears state\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:07:34.241545+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:09:44.108526+13:00","closed_at":"2026-02-11T02:09:44.108526+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:34.243147+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c.9","type":"blocks","created_at":"2026-02-11T02:07:38.658953+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.11","title":"Fix timeline replay links: normalize source/target to string IDs before diff/merge","description":"## Fix timeline replay links: normalize source/target to string IDs\n\n### Bug\nLinks during timeline replay appear but are NOT connected to their nodes — they float/draw to wrong positions.\n\n### Root cause\nreact-force-graph-2d mutates link objects in-place, replacing link.source and link.target from string IDs to object references pointing to actual node objects in the simulation.\n\nWhen filterDataAtTime() is called with data.graphData.links, those links already have mutated source/target (object refs pointing to the MAIN graph's node objects). These mutated links flow through mergeBeadsData() and get passed to BeadsGraph as timeline data. ForceGraph2D sees already-resolved object references and uses them directly — but they point to the WRONG node objects (main graph nodes, not timeline nodes). Links draw to invisible ghost positions.\n\n### Fix\nIn filterDataAtTime() in lib/timeline.ts, line 126, normalize source/target back to string IDs when pushing links into the result:\n\nCurrent code (lib/timeline.ts, lines 111-128):\n```typescript\nfor (const link of allLinks) {\n const src =\n typeof link.source === \"object\"\n ? (link.source as { id: string }).id\n : link.source;\n const tgt =\n typeof link.target === \"object\"\n ? (link.target as { id: string }).id\n : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n links.push(link); // <-- BUG: pushes mutated link with object refs\n}\n```\n\nFix — replace the last line:\n```typescript\n links.push({\n ...link,\n source: src,\n target: tgt,\n });\n```\n\nThe src and tgt variables are already extracted as string IDs (lines 112-118). By spreading a new link object with string source/target, d3-force will resolve them to the correct node objects in the timeline's node array.\n\n### Files to edit\n- lib/timeline.ts — line 126: replace links.push(link) with links.push({ ...link, source: src, target: tgt })\n\n### Acceptance criteria\n- During timeline replay, links visually connect to their nodes\n- Links draw correctly at every step, including first appearance and after scrubbing\n- pnpm build passes","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:12:07.008838+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:13:05.073793+13:00","closed_at":"2026-02-11T02:13:05.073793+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.11","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:07.010331+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.12","title":"Build verify and push timeline link connection fix","description":"## Build verify and push\n\npnpm build, close tasks, sync, commit, push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.11\nbd close beads-map-21c.12\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline links: normalize source/target to string IDs (beads-map-21c.11)\"\ngit push\n```\n\n### Acceptance criteria\n- pnpm build passes\n- git status clean after push","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:12:14.928323+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:13:05.202556+13:00","closed_at":"2026-02-11T02:13:05.202556+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:14.930729+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c.11","type":"blocks","created_at":"2026-02-11T02:12:15.066268+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.2","title":"Add createdAt field to GraphLink type and populate from dependency data","description":"## Add createdAt to GraphLink\n\n### What\nGraphLink currently has no timestamp. Add an optional createdAt field and populate it from BeadDependency.created_at so links can be time-filtered in the timeline replay.\n\n### Current GraphLink type (lib/types.ts, lines 70-78)\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 1: lib/types.ts\nAdd `createdAt?: string;` to GraphLink, after `type` and before `_spawnTime`:\n\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n createdAt?: string; // <-- ADD THIS: ISO 8601 from BeadDependency.created_at\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 2: lib/parse-beads.ts\nIn buildGraphData(), the link mapping (lines 161-175) currently drops created_at:\n\n```typescript\nconst links: GraphLink[] = dependencies\n .filter(\n (d) =>\n (d.type === \"blocks\" || d.type === \"parent-child\") &&\n issueMap.has(d.issue_id) &&\n issueMap.has(d.depends_on_id)\n )\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n }));\n```\n\nAdd `createdAt: d.created_at,` to the .map() return object:\n\n```typescript\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n createdAt: d.created_at, // <-- ADD THIS\n }));\n```\n\n### Files to edit\n- `lib/types.ts` — add createdAt to GraphLink interface\n- `lib/parse-beads.ts` — add createdAt to link mapping in buildGraphData()\n\n### What NOT to change\n- Do NOT change BeadDependency type (it already has created_at)\n- Do NOT change diff-beads.ts (link diffing uses linkKey which only considers source/target/type)\n- Do NOT change mergeBeadsData in page.tsx (it spreads link objects, so createdAt will be preserved)\n\n### Acceptance criteria\n- GraphLink.createdAt is optional string type\n- Links built from JSONL data carry their dependency creation timestamp\n- pnpm build passes\n- No runtime behavior changes (createdAt is informational until timeline feature uses it)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:47:41.589951+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:53:53.622113+13:00","closed_at":"2026-02-11T01:53:53.622113+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.2","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:41.591486+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.3","title":"Build timeline event extraction and time-filter logic (lib/timeline.ts)","description":"## Build timeline.ts: event extraction and time-filtering\n\n### What\nCreate a new file lib/timeline.ts with pure functions for:\n1. Extracting a sorted list of temporal events from graph data\n2. Filtering nodes/links to only show what exists at a given point in time\n\n### New file: lib/timeline.ts\n\n```typescript\nimport type { GraphNode, GraphLink } from \"./types\";\n\n// --- Types ---\n\nexport type TimelineEventType = \"node-created\" | \"node-closed\" | \"link-created\";\n\nexport interface TimelineEvent {\n time: number; // unix ms\n type: TimelineEventType;\n id: string; // node ID or link key (source->target)\n}\n\nexport interface TimelineRange {\n events: TimelineEvent[];\n minTime: number; // earliest event (unix ms)\n maxTime: number; // latest event (unix ms)\n}\n\n// --- Event extraction ---\n\n/**\n * Extract all temporal events from nodes and links, sorted chronologically.\n *\n * Events:\n * - node-created: from node.createdAt\n * - node-closed: from node.closedAt (if present)\n * - link-created: from link.createdAt (if present)\n *\n * Nodes/links missing timestamps are skipped.\n * Returns { events, minTime, maxTime }.\n */\nexport function buildTimelineEvents(\n nodes: GraphNode[],\n links: GraphLink[]\n): TimelineRange {\n const events: TimelineEvent[] = [];\n\n for (const node of nodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (!isNaN(createdMs)) {\n events.push({ time: createdMs, type: \"node-created\", id: node.id });\n }\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs)) {\n events.push({ time: closedMs, type: \"node-closed\", id: node.id });\n }\n }\n }\n\n for (const link of links) {\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs)) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n events.push({ time: linkMs, type: \"link-created\", id: `${src}->${tgt}` });\n }\n }\n }\n\n events.sort((a, b) => a.time - b.time);\n\n const times = events.map(e => e.time);\n const minTime = times.length > 0 ? times[0] : Date.now();\n const maxTime = times.length > 0 ? times[times.length - 1] : Date.now();\n\n return { events, minTime, maxTime };\n}\n\n// --- Time filtering ---\n\n/**\n * Filter nodes and links to only include items visible at `currentTime`.\n *\n * Node visibility: createdAt <= currentTime (parsed as Date).\n * Node status override: if closedAt && closedAt <= currentTime, force status to \"closed\".\n * Link visibility: both source and target nodes are visible AND link.createdAt <= currentTime.\n * If link has no createdAt, it appears when both endpoints are visible.\n *\n * Returns shallow copies of node objects with status potentially overridden.\n * Does NOT mutate input arrays.\n */\nexport function filterDataAtTime(\n allNodes: GraphNode[],\n allLinks: GraphLink[],\n currentTime: number\n): { nodes: GraphNode[]; links: GraphLink[] } {\n // Filter visible nodes\n const visibleNodeIds = new Set<string>();\n const nodes: GraphNode[] = [];\n\n for (const node of allNodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (isNaN(createdMs) || createdMs > currentTime) continue;\n\n visibleNodeIds.add(node.id);\n\n // Check if node should show as closed at this time\n let status = node.status;\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs) && closedMs <= currentTime) {\n status = \"closed\";\n } else if (node.status === \"closed\") {\n // Node is closed in current data but we're before closedAt — show as open\n status = \"open\";\n }\n } else if (node.status === \"closed\") {\n // Closed but no closedAt timestamp — show as closed always (legacy data)\n status = \"closed\";\n }\n\n // Shallow copy with potentially overridden status\n if (status !== node.status) {\n nodes.push({ ...node, status } as GraphNode);\n } else {\n nodes.push(node);\n }\n }\n\n // Filter visible links\n const links: GraphLink[] = [];\n for (const link of allLinks) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n // If link has createdAt, check it\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n }\n\n links.push(link);\n }\n\n return { nodes, links };\n}\n```\n\n### Key design decisions\n- Pure functions, no React, no side effects — easy to test\n- filterDataAtTime returns shallow copies when status is overridden, original objects when not (preserves x/y positions from force simulation)\n- Link source/target can be string or object (force-graph mutates these) — handle both\n- Links without createdAt appear as soon as both endpoints are visible (graceful fallback)\n- For nodes that are \"closed\" in current data but we're scrubbing to before closedAt, we show them as \"open\"\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt must exist in the type)\n\n### Files to create\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- buildTimelineEvents extracts events from nodes and links, sorted by time\n- filterDataAtTime correctly shows only nodes/links that exist at a given time\n- Closed nodes appear as \"open\" when scrubbing before their closedAt\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:48:09.026383+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:54:26.759387+13:00","closed_at":"2026-02-11T01:54:26.759387+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:09.027391+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.440174+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.4","title":"Create TimelineBar component with play/pause, scrubber, speed controls","description":"## TimelineBar component\n\n### What\nA horizontal playback bar positioned at the bottom-right of the graph area. Replaces the current floating legend hint when timeline mode is active. Contains play/pause, a scrubber slider, date/time display, and speed toggle.\n\n### Layout & positioning\nThe TimelineBar replaces the existing floating legend hint (currently at bottom-4 right-4 z-10 in BeadsGraph.tsx lines 1227-1234):\n```tsx\n{!selectedNode && !hoveredNode && (\n <div className=\"absolute bottom-4 right-4 z-10 text-xs text-zinc-400 bg-white/90 ...\">\n Node size = dependency importance | Color = status | Ring = project\n </div>\n)}\n```\n\nThe TimelineBar should be rendered in the same position: `absolute bottom-4 right-4 z-10` with similar styling (`bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm`). It should NOT overlap with the minimap (bottom-4 left-4, 160x120px).\n\n### New file: components/TimelineBar.tsx\n\nProps interface:\n```typescript\ninterface TimelineBarProps {\n minTime: number; // earliest event timestamp (unix ms)\n maxTime: number; // latest event timestamp (unix ms)\n currentTime: number; // current playback position (unix ms)\n isPlaying: boolean;\n speed: number; // 1, 2, or 4\n onTimeChange: (time: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\n### Visual design\n```\n┌──────────────────────────────────────────────────────────┐\n│ ▶ ──────────────●────────────────── Feb 10, 2026 2x │\n└──────────────────────────────────────────────────────────┘\n```\n\nElements left to right:\n1. **Play/Pause button**: SVG icon, toggle between play (triangle) and pause (two bars). Size: w-6 h-6. Color: emerald-500 when playing, zinc-500 when paused.\n2. **Scrubber slider**: HTML `<input type=\"range\">` styled with Tailwind. Min=minTime, max=maxTime, value=currentTime, step=1. Full width (flex-1). Track: h-1 bg-zinc-200 rounded. Thumb: w-3 h-3 bg-emerald-500 rounded-full. Filled portion: emerald-500.\n3. **Current date/time label**: Shows the date at the scrubber position. Format: \"Feb 10, 2026\" (compact). Font: text-xs text-zinc-500 font-medium. Fixed width to prevent layout shift (~100px).\n4. **Speed button**: Cycles through 1x -> 2x -> 4x -> 1x on click. Shows current speed as text. Font: text-xs font-medium. Color: emerald-500 background pill when not 1x, zinc border when 1x. Style: same pill as layout buttons.\n\n### Styling\n- Container: `bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2`\n- Width: auto-sized, roughly 300-400px, using `min-w-[300px] max-w-[480px]`\n- Height: compact, ~40px\n- Flex row layout: `flex items-center gap-2`\n- On mobile (sm:hidden for the full bar, show just play/pause + date)\n\n### Range input custom styling\nUse CSS in globals.css or inline styles to customize the range slider:\n```css\n/* In globals.css */\n.timeline-slider::-webkit-slider-track {\n height: 4px;\n background: #e4e4e7; /* zinc-200 */\n border-radius: 2px;\n}\n.timeline-slider::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n background: #10b981; /* emerald-500 */\n border-radius: 50%;\n margin-top: -4px;\n cursor: pointer;\n}\n```\n\n### Date formatting\n```typescript\nfunction formatTimelineDate(ms: number): string {\n const d = new Date(ms);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n}\n```\n\n### Play/Pause SVG icons\nPlay icon (triangle pointing right):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M4 2l10 6-10 6V2z\" />\n</svg>\n```\n\nPause icon (two vertical bars):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <rect x=\"3\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n <rect x=\"9.5\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n</svg>\n```\n\n### Interaction behavior\n- Dragging the slider calls onTimeChange(newTime) continuously\n- The component does NOT manage the rAF playback loop — that lives in page.tsx\n- Clicking speed cycles: 1 -> 2 -> 4 -> 1\n- The component is purely controlled (all state via props)\n\n### Files to create\n- `components/TimelineBar.tsx`\n\n### Files to edit\n- `app/globals.css` — add .timeline-slider custom range input styles\n\n### Acceptance criteria\n- TimelineBar renders play/pause button, slider, date label, speed toggle\n- Slider responds to drag, calls onTimeChange\n- Play/pause button calls onPlayPause\n- Speed button cycles through 1x/2x/4x\n- Matches existing UI style (white/90, backdrop-blur, rounded-lg, zinc borders)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:48:40.960221+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:53:53.797119+13:00","closed_at":"2026-02-11T01:53:53.797119+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.4","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:40.961908+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.5","title":"Wire timeline into page.tsx and BeadsGraph: state, filtering, animations, pill button","description":"## Wire timeline into page.tsx and BeadsGraph\n\n### What\nThis is the integration task. Connect the timeline data layer (lib/timeline.ts), the TimelineBar component, and the existing graph rendering. Add a pill button to toggle timeline mode, manage playback state, and filter nodes/links based on the virtual clock.\n\n### Overview of changes\n\n**page.tsx** — Add state, pill button, rAF playback loop, filtering, and TimelineBar wiring\n**BeadsGraph.tsx** — Accept optional timeline props, conditionally render TimelineBar instead of legend hint\n\n---\n\n### Change 1: page.tsx — New imports\n\nAdd at top of file:\n```typescript\nimport { buildTimelineEvents, filterDataAtTime } from \"@/lib/timeline\";\nimport type { TimelineRange } from \"@/lib/timeline\";\nimport TimelineBar from \"@/components/TimelineBar\";\n```\n\n### Change 2: page.tsx — New state variables\n\nAdd after existing state declarations (around line 190):\n```typescript\n// Timeline replay state\nconst [timelineActive, setTimelineActive] = useState(false);\nconst [timelineTime, setTimelineTime] = useState(0); // current virtual clock (unix ms)\nconst [timelinePlaying, setTimelinePlaying] = useState(false);\nconst [timelineSpeed, setTimelineSpeed] = useState(1); // 1x, 2x, 4x\n```\n\n### Change 3: page.tsx — Compute timeline range\n\nAdd a useMemo that computes the timeline event range from the full data. This MUST be computed from the full (unfiltered) data set:\n\n```typescript\nconst timelineRange = useMemo<TimelineRange | null>(() => {\n if (!data) return null;\n return buildTimelineEvents(data.graphData.nodes, data.graphData.links);\n}, [data]);\n```\n\n### Change 4: page.tsx — Initialize timelineTime when activating\n\nWhen timeline mode is activated, set timelineTime to minTime:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next && timelineRange) {\n setTimelineTime(timelineRange.minTime);\n setTimelinePlaying(false);\n }\n if (!next) {\n setTimelinePlaying(false);\n }\n return next;\n });\n}, [timelineRange]);\n```\n\n### Change 5: page.tsx — rAF playback loop\n\nAdd a useEffect that advances timelineTime when playing. Speed mapping: 1x = 1 real second advances 1 calendar day of project time. So:\n- msPerFrame = (1000/60) * speed * (86400000 / 1000) = speed * 1440000 / 60 = speed * 24000 per frame at 60fps\n\nActually simpler: track last rAF timestamp, compute real elapsed ms, multiply by speed factor:\n- 1x: 1 real second = 1 day (86400000ms) of project time -> factor = 86400\n- 2x: factor = 172800\n- 4x: factor = 345600\n\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n\n let rafId: number;\n let lastTs: number | null = null;\n const factor = timelineSpeed * 86400; // 1 real ms = factor project ms\n\n function tick(ts: number) {\n if (lastTs !== null) {\n const realElapsed = ts - lastTs;\n setTimelineTime(prev => {\n const next = prev + realElapsed * factor;\n if (next >= timelineRange!.maxTime) {\n setTimelinePlaying(false);\n return timelineRange!.maxTime;\n }\n return next;\n });\n }\n lastTs = ts;\n rafId = requestAnimationFrame(tick);\n }\n\n rafId = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(rafId);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\n### Change 6: page.tsx — Filter data for timeline mode\n\nCompute the filtered nodes/links using a useMemo. This is what gets passed to BeadsGraph when timeline is active:\n\n```typescript\nconst timelineFilteredData = useMemo(() => {\n if (!timelineActive || !data) return null;\n return filterDataAtTime(\n data.graphData.nodes,\n data.graphData.links,\n timelineTime\n );\n}, [timelineActive, data, timelineTime]);\n```\n\n### Change 7: page.tsx — Stamp _spawnTime on newly visible nodes\n\nTo get pop-in animations as nodes appear during playback, track previously visible node IDs and stamp _spawnTime on new ones:\n\n```typescript\nconst prevTimelineNodeIdsRef = useRef<Set<string>>(new Set());\n\nconst timelineNodes = useMemo(() => {\n if (!timelineFilteredData) return null;\n const prevIds = prevTimelineNodeIdsRef.current;\n const now = Date.now();\n const nodes = timelineFilteredData.nodes.map(node => {\n if (!prevIds.has(node.id)) {\n return { ...node, _spawnTime: now } as GraphNode;\n }\n return node;\n });\n // Update prev set for next frame\n prevTimelineNodeIdsRef.current = new Set(timelineFilteredData.nodes.map(n => n.id));\n return nodes;\n}, [timelineFilteredData]);\n\nconst timelineLinks = useMemo(() => {\n if (!timelineFilteredData) return null;\n return timelineFilteredData.links;\n}, [timelineFilteredData]);\n```\n\n**IMPORTANT**: This runs in useMemo which is a pure computation. The ref update inside useMemo is a known pattern but impure. An alternative: use useEffect to update the ref. Choose whichever approach doesn't cause visual glitches. If useMemo causes double-stamping in StrictMode, move to useEffect with a separate state.\n\n### Change 8: page.tsx — Pass filtered or full data to BeadsGraph\n\nCurrently (line 863-864):\n```tsx\n<BeadsGraph\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n```\n\nChange to:\n```tsx\n<BeadsGraph\n nodes={timelineActive && timelineNodes ? timelineNodes : data.graphData.nodes}\n links={timelineActive && timelineLinks ? timelineLinks : data.graphData.links}\n```\n\n### Change 9: page.tsx — Timeline pill button in header\n\nAdd a pill button next to the Comments pill (before the `<span className=\"w-px h-4 bg-zinc-200\" />` separator before AuthButton). Same styling as Comments/layout pills:\n\n```tsx\n<span className=\"w-px h-4 bg-zinc-200\" />\n{/* Timeline pill */}\n<div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\">\n <button\n onClick={handleTimelineToggle}\n className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n timelineActive\n ? \"bg-emerald-500 text-white\"\n : \"text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n >\n <span className=\"flex items-center gap-1.5\">\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n {/* Clock/replay icon */}\n <circle cx=\"8\" cy=\"8\" r=\"6\" />\n <polyline points=\"8,4 8,8 11,10\" />\n </svg>\n <span className=\"hidden sm:inline\">Replay</span>\n </span>\n </button>\n</div>\n```\n\nPlace this BEFORE the Comments pill separator.\n\n### Change 10: page.tsx — Render TimelineBar\n\nAdd TimelineBar inside the graph area div (after BeadsGraph, before the closing </div> of the graph area). It should render only when timeline is active:\n\n```tsx\n{timelineActive && timelineRange && (\n <div className=\"absolute bottom-4 right-4 z-10\">\n <TimelineBar\n minTime={timelineRange.minTime}\n maxTime={timelineRange.maxTime}\n currentTime={timelineTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onTimeChange={setTimelineTime}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n />\n </div>\n)}\n```\n\n### Change 11: BeadsGraph.tsx — Hide legend hint when timeline is active\n\nAdd a new prop to BeadsGraphProps:\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n timelineActive?: boolean; // <-- ADD THIS\n}\n```\n\nChange the legend hint conditional (lines 1227-1234) from:\n```tsx\n{!selectedNode && !hoveredNode && (\n```\nto:\n```tsx\n{!selectedNode && !hoveredNode && !timelineActiveRef.current && (\n```\n\nAdd a ref for timelineActive (same pattern as selectedNodeRef etc):\n```typescript\nconst timelineActiveRef = useRef(false);\nuseEffect(() => { timelineActiveRef.current = timelineActive ?? false; }, [timelineActive]);\n```\n\nWait — actually the legend hint is in the JSX return, not in paintNode, so we can use the prop directly:\n```tsx\n{!selectedNode && !hoveredNode && !props.timelineActive && (\n```\n\nBut BeadsGraph destructures props at the top. Add `timelineActive` to the destructured props and use it directly in the JSX conditional. No ref needed for this since it's in JSX, not in a useCallback.\n\n### Change 12: page.tsx — Pass timelineActive to BeadsGraph\n\n```tsx\n<BeadsGraph\n ...\n timelineActive={timelineActive} // <-- ADD THIS\n/>\n```\n\n### Summary of files to edit\n- `app/page.tsx` — state, imports, memos, pill button, TimelineBar render, data filtering\n- `components/BeadsGraph.tsx` — timelineActive prop, hide legend when active\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt)\n- beads-map-21c.3 (lib/timeline.ts)\n- beads-map-21c.4 (TimelineBar component)\n\n### Acceptance criteria\n- \"Replay\" pill button in header toggles timeline mode\n- When active, graph shows only nodes/links that exist at the virtual clock time\n- Pressing play animates nodes appearing over time with pop-in animations\n- Scrubbing the slider immediately updates visible nodes\n- Speed toggle cycles 1x/2x/4x\n- Legend hint hidden when timeline is active (TimelineBar replaces it)\n- When timeline is deactivated, full live data is shown again\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:49:28.829389+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:56:10.694555+13:00","closed_at":"2026-02-11T01:56:10.694555+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:28.830802+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.557476+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.3","type":"blocks","created_at":"2026-02-11T01:51:32.669716+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.4","type":"blocks","created_at":"2026-02-11T01:51:32.780421+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.6","title":"Build verification, edge cases, and polish for timeline replay","description":"## Build verification and polish\n\n### What\nFinal task: verify the build passes, test edge cases, and polish any rough edges.\n\n### Build gate\n```bash\npnpm build\n```\nMust pass with zero errors. If there are type errors, fix them.\n\n### Edge cases to verify\n\n1. **Empty graph**: If no nodes have timestamps, timeline should gracefully handle minTime === maxTime (slider disabled or shows single point)\n2. **Single node**: Timeline with one node should still work (slider shows one point in time)\n3. **All nodes already closed**: Scrubbing to maxTime should show all nodes as closed\n4. **Scrubbing backward**: Moving slider left should remove nodes (they should just disappear, no exit animation needed for scrub-back — only forward playback gets spawn animations)\n5. **Rapid scrubbing**: Fast slider movement should not cause performance issues. The filterDataAtTime function should be fast (O(n) where n = total nodes + links)\n6. **Toggle off during playback**: Turning off timeline while playing should stop playback and restore full data\n7. **Node selection during timeline**: Clicking a node during timeline playback should work normally (open NodeDetail sidebar)\n8. **Comments during timeline**: Comment badges should still work on visible nodes (commentedNodeIds filtering still applies)\n\n### Polish items\n- Ensure TimelineBar doesn't overlap with minimap on small screens\n- Verify the rAF loop cleans up properly on unmount\n- Check that prevTimelineNodeIdsRef resets when timeline is deactivated\n- Verify nodes retain their force-simulation positions when switching between timeline and live mode (x/y should be preserved since we're using the same node objects from data.graphData.nodes)\n\n### Stale .next cache\nIf you see module resolution errors, run:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Files potentially needing fixes\n- `app/page.tsx`\n- `components/BeadsGraph.tsx`\n- `components/TimelineBar.tsx`\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- All edge cases handled gracefully\n- No console errors during timeline playback\n- Clean git status after commit","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:49:44.214947+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:56:40.00756+13:00","closed_at":"2026-02-11T01:56:40.00756+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:44.216474+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c.5","type":"blocks","created_at":"2026-02-11T01:51:32.89299+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.7","title":"Rewrite timeline to event-step playback using diff/merge pipeline","description":"## Rewrite timeline to event-step playback using diff/merge pipeline\n\n### Problem\nTwo bugs in the current timeline replay:\n1. **Too fast**: rAF loop maps real time to project time (1 sec = 1 day), so months of history play in seconds\n2. **Graph mess**: Timeline bypasses the diff/merge pipeline, so nodes appear without x/y positions and the force simulation doesn't organize them. Links and nodes are disconnected.\n\n### Root cause\nThe current timeline creates a parallel data path: filterDataAtTime() returns raw nodes (no x/y), stamps _spawnTime manually in useMemo, and passes them directly to BeadsGraph. This skips:\n- mergeBeadsData() which preserves x/y from old nodes and places new ones near neighbors\n- diffBeadsData() which detects added/removed/changed for proper animation stamps\n- The force simulation reheat that react-force-graph does when graphData changes\n\n### Fix: event-step model + diff/merge pipeline\n\n**Playback model change:**\n- Replace continuous time scrubber with discrete event steps\n- Each step = one event from the sorted events array\n- Playback advances one step every 5 seconds (at 1x), giving force simulation time to settle\n- Speed: 1x = 5s/step, 2x = 2.5s/step, 4x = 1.25s/step\n- Slider maps to step index (0 to events.length-1), not unix timestamps\n\n**Data pipeline change:**\n- Maintain `timelineData: BeadsApiResponse | null` state (the \"current timeline snapshot\")\n- On each step change:\n 1. Get timestamp from events[step].time\n 2. Call filterDataAtTime(allNodes, allLinks, timestamp) to get visible nodes/links\n 3. Wrap as BeadsApiResponse-shaped object\n 4. Call diffBeadsData(prevTimelineData, newSnapshot) to get the diff\n 5. Call mergeBeadsData(prevTimelineData, newSnapshot, diff) to get positioned + animated data\n 6. Set timelineData = merged result\n- Pass timelineData.graphData.nodes/.links to BeadsGraph\n- Force simulation naturally reheats when the node/link arrays change\n\n### Files to edit\n\n**app/page.tsx** — The big one. Replace the entire timeline section (lines ~196-410):\n\nState changes:\n- REMOVE: timelineTime (unix ms)\n- REMOVE: prevTimelineNodeIdsRef\n- ADD: timelineStep (number, 0-based index into events array)\n- ADD: timelineData (BeadsApiResponse | null)\n\nRemove these memos/computations:\n- timelineFilteredData useMemo\n- timelineNodes useMemo\n- timelineLinks useMemo\n\nReplace rAF playback loop with setInterval:\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n const intervalMs = 5000 / timelineSpeed;\n const interval = setInterval(() => {\n setTimelineStep(prev => {\n const next = prev + 1;\n if (next >= timelineRange.events.length) {\n setTimelinePlaying(false);\n return prev;\n }\n return next;\n });\n }, intervalMs);\n return () => clearInterval(interval);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\nAdd effect to compute timelineData when step changes:\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange || timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n\n const filtered = filterDataAtTime(data.graphData.nodes, data.graphData.links, event.time);\n const newSnapshot: BeadsApiResponse = {\n ...data,\n graphData: { nodes: filtered.nodes, links: filtered.links },\n };\n\n setTimelineData(prev => {\n if (!prev) return newSnapshot; // first frame — no merge needed\n const diff = diffBeadsData(prev, newSnapshot);\n if (!diff.hasChanges) return prev;\n return mergeBeadsData(prev, newSnapshot, diff);\n });\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChange BeadsGraph props:\n```tsx\nnodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}\nlinks={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}\n```\n\nChange TimelineBar props:\n```tsx\n<TimelineBar\n totalSteps={timelineRange.events.length}\n currentStep={timelineStep}\n currentTime={timelineRange.events[timelineStep]?.time ?? timelineRange.minTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onStepChange={setTimelineStep}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n/>\n```\n\nUpdate handleTimelineToggle:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next) {\n setTimelineStep(0);\n setTimelinePlaying(false);\n setTimelineData(null);\n } else {\n setTimelinePlaying(false);\n setTimelineData(null);\n }\n return next;\n });\n}, []);\n```\n\n**components/TimelineBar.tsx** — Change from time-based to step-based props:\n\nNew props:\n```typescript\ninterface TimelineBarProps {\n totalSteps: number; // events.length\n currentStep: number; // 0-based index\n currentTime: number; // unix ms of current event (for date display)\n isPlaying: boolean;\n speed: number; // 1, 2, 4\n onStepChange: (step: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\nSlider: min=0, max=totalSteps-1, value=currentStep, onChange calls onStepChange\nDate label: formatTimelineDate(currentTime)\nAdd step counter: \"3 / 47\" next to date\nhasRange = totalSteps > 1\n\n**lib/timeline.ts** — No changes needed. buildTimelineEvents and filterDataAtTime work as-is.\n\n**components/BeadsGraph.tsx** — No changes needed. Force simulation reheats naturally.\n\n### Depends on\nNothing new — this replaces parts of beads-map-21c.5\n\n### Acceptance criteria\n- Playing timeline advances one event at a time, 5 seconds between events at 1x\n- 2x = 2.5s between events, 4x = 1.25s between events\n- Nodes appear with pop-in animation and get properly positioned by force simulation\n- Links connect to their nodes correctly\n- Scrubbing the slider jumps between event steps\n- Graph layout matches the active layout mode (Force or DAG)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:00:49.845818+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:03:04.087128+13:00","closed_at":"2026-02-11T02:03:04.087128+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.7","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:49.847169+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.8","title":"Build verify and push timeline event-step rewrite","description":"## Build verify and push\n\nRun pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\npnpm build\ngit add -A\ngit commit -m \"Rewrite timeline to event-step playback with diff/merge pipeline (beads-map-21c.7)\"\nbd sync\ngit push\n```\n\n### Edge cases to check\n- Empty events array (no timestamps) — slider disabled, play disabled\n- Single event — slider shows one point\n- Scrubbing backward — nodes removed via diff/merge exit animation\n- Toggle off during playback — stops interval, clears timelineData\n- Speed change during playback — interval restarts with new timing\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:00:58.650469+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:03:27.972704+13:00","closed_at":"2026-02-11T02:03:27.972704+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:58.652019+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c.7","type":"blocks","created_at":"2026-02-11T02:01:02.543151+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.9","title":"Fix timeline: links appear with both nodes, empty preamble, 2s per event","description":"## Fix timeline: links appear with both nodes, empty preamble, 2s per event\n\n### Three issues to fix\n\n#### Issue 1: Links don't appear when both nodes are visible\n**Root cause:** filterDataAtTime() in lib/timeline.ts lines 148-151 checks link.createdAt independently:\n```typescript\nif (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n}\n```\nEven when both endpoints are on canvas, the link is hidden until its own timestamp.\n\n**Fix in lib/timeline.ts:** Remove the link.createdAt check entirely (lines 147-151). A link should appear the moment both endpoints are visible. The visibleNodeIds check on line 145 is sufficient:\n```typescript\n// Both endpoints must be visible\nif (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n// REMOVE the link.createdAt check below this\n```\n\nAlso remove link-created events from buildTimelineEvents() (lines 54-73) since link timing is now derived from node visibility, not link timestamps. This simplifies the event list to just node-created and node-closed.\n\n#### Issue 2: Zoom crash into first node on play\n**Root cause:** BeadsGraph.tsx line 432-440 has a zoomToFit effect:\n```typescript\nuseEffect(() => {\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n}, [nodes.length]);\n```\nWhen timeline starts at step 0 (1 node), this zooms to fit that single node = extreme zoom in.\n\n**Fix — two parts:**\n\n**Part A: Prevent zoomToFit during timeline mode.**\nThe timelineActive prop is already passed to BeadsGraph. Use it to skip the zoomToFit:\n```typescript\nuseEffect(() => {\n if (timelineActive) return; // skip during timeline replay\n if (graphRef.current && nodes.length > 0) {\n ...\n }\n}, [nodes.length, timelineActive]);\n```\n\n**Part B: Add 2-second empty preamble before first event.**\nIn the playback setInterval in page.tsx, when play starts and timelineStep is -1 (a new \"preamble\" step), show zero nodes for 2 seconds, then advance to step 0.\n\nImplementation approach: Use step index -1 as the preamble. When timeline is activated or play starts from the beginning, set step to -1. The effect that computes timelineData should check: if step === -1, set timelineData to an empty snapshot (no nodes, no links). The setInterval advances from -1 to 0, then 0 to 1, etc.\n\nChanges in app/page.tsx:\n- handleTimelineToggle: setTimelineStep(-1) instead of 0\n- The setInterval already does prev + 1, so -1 + 1 = 0 (first real event). Works naturally.\n- The timelineData effect: add check for timelineStep === -1 -> empty snapshot\n- TimelineBar: totalSteps stays as events.length (preamble is \"step -1\", not counted in steps)\n- TimelineBar slider: min stays 0, but current step shows as 0 when preamble is active\n\nChanges in page.tsx effect that computes timelineData (lines 367-389):\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange) return;\n \n // Preamble step: empty canvas\n if (timelineStep === -1) {\n setTimelineData({\n ...data,\n graphData: { nodes: [], links: [] },\n });\n return;\n }\n \n if (timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n // ... rest of diff/merge logic\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChanges in page.tsx TimelineBar rendering:\n```tsx\ncurrentStep={Math.max(timelineStep, 0)}\ncurrentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}\n```\n\n#### Issue 3: 5 seconds per event is too slow\n**Fix in app/page.tsx line 353:** Change 5000 to 2000:\n```typescript\nconst intervalMs = 2000 / timelineSpeed;\n```\nThis gives 2s per event at 1x, 1s at 2x, 0.5s at 4x.\n\n### Files to edit\n- lib/timeline.ts — remove link.createdAt check in filterDataAtTime, remove link-created events from buildTimelineEvents\n- app/page.tsx — step -1 preamble, 2s interval, TimelineBar prop adjustments \n- components/BeadsGraph.tsx — skip zoomToFit during timeline mode\n\n### Also remove link-created from TimelineEventType\nSince links no longer have their own timeline events, simplify:\n- TimelineEventType becomes \"node-created\" | \"node-closed\" (remove \"link-created\")\n- buildTimelineEvents() removes the link loop (lines 54-73)\n\n### Acceptance criteria\n- Links appear the instant both connected nodes are on canvas\n- Pressing play shows empty canvas for 2 seconds (preamble), then first node appears\n- Each event takes 2 seconds at 1x speed\n- No zoom-crash into a single node when timeline starts\n- Scrubbing slider still works (slider min=0, preamble is before slider range)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:07:25.357205+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:09:24.013992+13:00","closed_at":"2026-02-11T02:09:24.013992+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.9","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:25.358814+13:00","created_by":"daviddao"}]},{"id":"beads-map-2fk","title":"Create lib/diff-beads.ts — diff engine for nodes and links","description":"Create a new file: lib/diff-beads.ts\n\nPURPOSE: Compare two BeadsApiResponse objects and identify what changed — which nodes/links were added, removed, or modified. The diff output drives animation metadata stamping in the merge logic (task .5).\n\nINTERFACE:\n\n```typescript\nimport type { BeadsApiResponse, GraphNode, GraphLink } from \"./types\";\n\nexport interface NodeChange {\n field: string; // e.g. \"status\", \"priority\", \"title\"\n from: string; // previous value (stringified)\n to: string; // new value (stringified)\n}\n\nexport interface BeadsDiff {\n addedNodeIds: Set<string>; // IDs of nodes not in old data\n removedNodeIds: Set<string>; // IDs of nodes not in new data\n changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes\n addedLinkKeys: Set<string>; // \"source->target:type\" keys\n removedLinkKeys: Set<string>; // \"source->target:type\" keys\n hasChanges: boolean; // true if anything changed at all\n}\n\n/**\n * Build a stable key for a link.\n * Links may have string or object source/target (after force-graph mutation).\n */\nexport function linkKey(link: GraphLink): string;\n\n/**\n * Compute the diff between old and new beads data.\n * Compares nodes by ID and links by source->target:type key.\n */\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff;\n```\n\nIMPLEMENTATION:\n\n```typescript\nexport function linkKey(link: GraphLink): string {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n return `${src}->${tgt}:${link.type}`;\n}\n\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff {\n // If no old data, everything is \"added\"\n if (!oldData) {\n return {\n addedNodeIds: new Set(newData.graphData.nodes.map(n => n.id)),\n removedNodeIds: new Set(),\n changedNodes: new Map(),\n addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),\n removedLinkKeys: new Set(),\n hasChanges: true,\n };\n }\n\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const newNodeMap = new Map(newData.graphData.nodes.map(n => [n.id, n]));\n\n // Node diffs\n const addedNodeIds = new Set<string>();\n const removedNodeIds = new Set<string>();\n const changedNodes = new Map<string, NodeChange[]>();\n\n for (const [id, node] of newNodeMap) {\n if (!oldNodeMap.has(id)) {\n addedNodeIds.add(id);\n } else {\n const old = oldNodeMap.get(id)!;\n const changes: NodeChange[] = [];\n if (old.status !== node.status) {\n changes.push({ field: \"status\", from: old.status, to: node.status });\n }\n if (old.priority !== node.priority) {\n changes.push({ field: \"priority\", from: String(old.priority), to: String(node.priority) });\n }\n if (old.title !== node.title) {\n changes.push({ field: \"title\", from: old.title, to: node.title });\n }\n if (changes.length > 0) {\n changedNodes.set(id, changes);\n }\n }\n }\n for (const id of oldNodeMap.keys()) {\n if (!newNodeMap.has(id)) {\n removedNodeIds.add(id);\n }\n }\n\n // Link diffs\n const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));\n const newLinkKeys = new Set(newData.graphData.links.map(linkKey));\n\n const addedLinkKeys = new Set<string>();\n const removedLinkKeys = new Set<string>();\n\n for (const key of newLinkKeys) {\n if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);\n }\n for (const key of oldLinkKeys) {\n if (!newLinkKeys.has(key)) removedLinkKeys.add(key);\n }\n\n const hasChanges =\n addedNodeIds.size > 0 ||\n removedNodeIds.size > 0 ||\n changedNodes.size > 0 ||\n addedLinkKeys.size > 0 ||\n removedLinkKeys.size > 0;\n\n return { addedNodeIds, removedNodeIds, changedNodes, addedLinkKeys, removedLinkKeys, hasChanges };\n}\n```\n\nWHY linkKey() HANDLES OBJECTS:\nreact-force-graph-2d mutates link.source and link.target from string IDs to node objects during simulation. When we compare old links (which have been mutated) against new links (which have string IDs from the server), we need to handle both cases.\n\nDEPENDS ON: task .1 (animation timestamp types in GraphNode/GraphLink)\n\nACCEPTANCE CRITERIA:\n- lib/diff-beads.ts exports diffBeadsData and linkKey\n- Correctly identifies added/removed/changed nodes\n- Correctly identifies added/removed links\n- Handles null oldData (initial load — everything is \"added\")\n- Handles object-form source/target in links (post-simulation mutation)\n- hasChanges is false when data is identical\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:16:20.792858+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:25:49.501958+13:00","closed_at":"2026-02-10T23:25:49.501958+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.39819+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.995145+13:00","created_by":"daviddao"}]},{"id":"beads-map-2qg","title":"Integration testing — live update end-to-end verification","description":"Final verification that the live update system works end-to-end with all animations.\n\nSETUP:\n Terminal 1: cd to any beads project (e.g. ~/Projects/gainforest/gainforest-beads)\n Terminal 2: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev (in beads-map)\n Browser: http://localhost:3000\n\nTEST MATRIX:\n\n1. NEW NODE (bd create):\n Terminal 1: bd create --title \"Live test node\" --priority 2\n Browser: Within ~300ms, a new node should POP IN with:\n - Scale animation from 0 to 1 (bouncy easeOutBack)\n - Brief green glow ring around it\n - Node placed near its connected neighbors (or at random if standalone)\n - Force simulation gently incorporates it into the layout\n VERIFY: No graph position reset, existing nodes stay where they are\n\n2. STATUS CHANGE (bd update):\n Terminal 1: bd update <id-from-step-1> --status in_progress\n Browser: The node should show:\n - Expanding ripple ring in amber (in_progress color)\n - Node body color transitions from emerald (open) to amber\n - Ripple fades out over ~800ms\n VERIFY: No position change, other nodes unaffected\n\n3. NEW LINK (bd link):\n Terminal 1: bd link <id1> blocks <id2>\n Browser: A new link should appear:\n - Fades in over 500ms\n - Brief emerald flash along the link path (300ms)\n - Starts thicker, settles to normal width\n - Flow particles appear on it\n VERIFY: Both endpoints stay in position\n\n4. CLOSE ISSUE (bd close):\n Terminal 1: bd close <id-from-step-1>\n Browser: The node should SHRINK OUT:\n - Scale animation from 1 to 0 (400ms)\n - Opacity fades to 0\n - Connected links also fade out\n - After ~600ms, the ghost node/links are removed from the array\n VERIFY: Stats update (total count decreases)\n\n5. RAPID CHANGES (debounce test):\n Terminal 1: for i in 1 2 3 4 5; do bd create --title \"Rapid $i\" --priority 3; done\n Browser: Nodes should NOT pop in one-by-one with 300ms delays. They should all appear in a single batch after the debounce settles (~300ms after the last command).\n VERIFY: All 5 nodes spawn simultaneously with pop-in animations\n\n6. MULTI-REPO (if using gainforest-beads hub):\n Terminal 1: cd ../audiogoat && bd create --title \"Cross-repo test\" --priority 3\n Browser: The new audiogoat node should appear in the graph\n VERIFY: Node has audiogoat prefix color ring\n\n7. RECONNECTION:\n Stop and restart the dev server.\n Browser: EventSource should auto-reconnect and load fresh data.\n VERIFY: No stale data, no duplicate nodes\n\n8. EPIC COLLAPSE VIEW:\n Switch to \"Epics\" view mode, then create a child task.\n Terminal 1: bd create --title \"Child of epic\" --priority 2 --parent <epic-id>\n Browser: In Epics mode, the parent epic node should update:\n - Child count badge increments\n - Epic node briefly flashes (change animation)\n - No child node appears (it's collapsed)\n Switch to Full mode: child node should be visible (already in data)\n\n9. BUILD CHECK:\n pnpm build — must pass with zero errors\n\n10. CLEANUP:\n Delete test issues: bd delete <id> for each test issue created\n Browser: Nodes shrink out on deletion\n\nFUNCTIONAL CHECKS:\n- Force/DAG layout toggle still works during/after animations\n- Full/Epics toggle still works\n- Search still finds nodes (including newly spawned ones)\n- Minimap updates with new nodes\n- Click node -> sidebar shows correct data (including newly added nodes)\n- Header stats update in real-time (issue count, dep count, project count)\n- No memory leaks (EventSource properly cleaned up on page navigation)\n- No console errors during any test\n\nPERFORMANCE CHECKS:\n- Animation frame rate stays smooth (60fps) during spawn/exit\n- No jitter or \"graph explosion\" when new data merges\n- File watcher doesn't cause excessive CPU usage during idle\n\nDEPENDS ON: All previous tasks (.1-.7) must be complete\n\nACCEPTANCE CRITERIA:\n- All 10 test scenarios pass\n- All functional checks pass\n- All performance checks pass\n- pnpm build clean\n- No console errors","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:18:51.905378+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:40:49.415522+13:00","closed_at":"2026-02-10T23:40:49.415522+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.706041+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-mq9","type":"blocks","created_at":"2026-02-10T23:19:29.394542+13:00","created_by":"daviddao"}]},{"id":"beads-map-3jy","title":"Live updates via SSE with animated node/link transitions","description":"Add real-time live updates to beads-map so that when .beads/issues.jsonl changes on disk (via bd create, bd close, bd link, bd update, etc.), the graph automatically updates with smooth animations — new nodes pop in, removed nodes shrink out, status changes flash, and new links fade in.\n\nARCHITECTURE:\n- Server: New SSE endpoint (/api/beads/stream) watches all JSONL files with fs.watch()\n- Server: On file change, re-parses all data and pushes the full dataset over SSE\n- Client: EventSource in page.tsx receives updates, diffs against current state\n- Client: Diff metadata (added/removed/changed) drives animations in paintNode/paintLink\n- Animations: spawn pop-in (easeOutBack), exit shrink-out, status change ripple, link fade-in\n\nKEY DESIGN DECISIONS:\n1. SSE over polling: true push, instant updates, no wasted requests\n2. Full data push (not incremental diffs): simpler, avoids sync issues, JSONL files are small\n3. Debounce 300ms: bd often writes multiple times per command (flush + sync)\n4. Position preservation: merge new data while keeping existing node x/y/fx/fy positions\n5. Animation via timestamps: stamp _spawnTime/_removeTime/_changedAt on items, animate in paintNode/paintLink based on elapsed time\n\nFILES TO CREATE:\n- lib/watch-beads.ts — file watcher utility wrapping fs.watch with debounce\n- lib/diff-beads.ts — diff engine comparing old vs new BeadsApiResponse\n- app/api/beads/stream/route.ts — SSE endpoint\n\nFILES TO MODIFY:\n- lib/parse-beads.ts — export getAdditionalRepoPaths (currently private)\n- lib/types.ts — add animation timestamp fields to GraphNode/GraphLink\n- app/page.tsx — replace one-shot fetch with EventSource + merge logic\n- components/BeadsGraph.tsx — spawn/exit/change animations in paintNode + paintLink\n\nDEPENDENCY CHAIN:\n.1 (types + parse-beads exports) → .2 (watch-beads.ts) → .3 (SSE endpoint) → .5 (page.tsx EventSource)\n.1 → .4 (diff-beads.ts) → .5\n.5 → .6 (paintNode animations) → .7 (paintLink animations) → .8 (integration test)","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-10T23:14:54.798302+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:40:49.541566+13:00","closed_at":"2026-02-10T23:40:49.541566+13:00","close_reason":"Closed"},{"id":"beads-map-3qb","title":"Filter out tombstoned issues from graph","status":"closed","priority":1,"issue_type":"bug","owner":"david@gainforest.net","created_at":"2026-02-10T23:48:26.859412+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:48:54.69868+13:00","closed_at":"2026-02-10T23:48:54.69868+13:00","close_reason":"Closed"},{"id":"beads-map-48c","title":"Show full description with markdown rendering in NodeDetail sidebar","description":"## Show full description with markdown rendering in NodeDetail sidebar\n\n### Summary\nTwo changes to the description section in `components/NodeDetail.tsx`:\n\n1. **Remove truncation** — Stop calling `truncateDescription()` so the full description text is shown. The scrollable container (`max-h-40 overflow-y-auto`) already handles long content elegantly — keep that.\n\n2. **Render markdown** — Descriptions are written in markdown (headings, code blocks, lists, links, bold/italic). Currently rendered as plain `<pre>` text. Install `react-markdown` + `remark-gfm` and render the description as formatted markdown inside the scrollable box, with appropriate typography styles for the small text size (text-xs base).\n\n### Tasks\n- .1 Install react-markdown and remark-gfm\n- .2 Remove truncation, add markdown rendering with styled prose in the scrollable box\n- .3 Build verification\n\n### Files to modify\n- `package.json` — add react-markdown, remark-gfm\n- `components/NodeDetail.tsx` — replace `<pre>{truncateDescription(...)}</pre>` with `<ReactMarkdown>` component, remove `truncateDescription` function\n- `app/globals.css` — possibly add small prose styling overrides for the description box\n\n### Key constraint\nKeep the scrollable container (`max-h-40 overflow-y-auto custom-scrollbar`) — that's good UX. Just show the full content inside it and render it as markdown instead of plain text.","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:11:03.062054+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:12:31.404312+13:00","closed_at":"2026-02-11T01:12:31.404312+13:00","close_reason":"Closed"},{"id":"beads-map-7j2","title":"Create SSE endpoint /api/beads/stream","description":"Create a new file: app/api/beads/stream/route.ts\n\nPURPOSE: Server-Sent Events endpoint that streams beads data to the client. On initial connection, sends the full dataset. Then watches all JSONL files for changes and pushes updated data whenever files change. This replaces the one-shot GET /api/beads fetch for live use.\n\nIMPLEMENTATION:\n\n```typescript\nimport { discoverBeadsDir } from \"@/lib/discover\";\nimport { loadBeadsData } from \"@/lib/parse-beads\";\nimport { watchBeadsFiles } from \"@/lib/watch-beads\";\n\n// Prevent Next.js from statically optimizing this route\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(request: Request) {\n let cleanup: (() => void) | null = null;\n\n const stream = new ReadableStream({\n start(controller) {\n const encoder = new TextEncoder();\n\n function send(data: unknown) {\n try {\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`)\n );\n } catch {\n // Stream closed — cleanup will handle\n }\n }\n\n try {\n const { beadsDir } = discoverBeadsDir();\n\n // Send initial data\n const initialData = loadBeadsData(beadsDir);\n send(initialData);\n\n // Watch for changes and push updates\n cleanup = watchBeadsFiles(beadsDir, () => {\n try {\n const newData = loadBeadsData(beadsDir);\n send(newData);\n } catch (err) {\n console.error(\"Failed to reload beads data:\", err);\n }\n });\n\n // Heartbeat every 30s to keep connection alive through proxies/firewalls\n const heartbeat = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(\": heartbeat\\n\\n\"));\n } catch {\n clearInterval(heartbeat);\n }\n }, 30000);\n\n // Clean up when client disconnects\n request.signal.addEventListener(\"abort\", () => {\n clearInterval(heartbeat);\n if (cleanup) cleanup();\n try { controller.close(); } catch { /* already closed */ }\n });\n\n } catch (err: any) {\n // Discovery failed — send error and close\n send({ error: err.message });\n controller.close();\n }\n },\n\n cancel() {\n if (cleanup) cleanup();\n },\n });\n\n return new Response(stream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\", // Disable Nginx buffering\n },\n });\n}\n```\n\nKEY DESIGN DECISIONS:\n- export const dynamic = \"force-dynamic\": tells Next.js not to statically optimize this route\n- Full data push on each change: JSONL files are small (10-100 issues), so re-parsing is fast (<5ms). Sending full data avoids incremental diff sync complexity on the server.\n- Heartbeat every 30s: prevents proxies and load balancers from closing idle connections\n- request.signal.addEventListener(\"abort\"): proper cleanup when client disconnects (browser tab close, navigation away, EventSource reconnect)\n- TextEncoder for SSE format: controller.enqueue requires Uint8Array\n- X-Accel-Buffering: no: prevents Nginx from buffering SSE responses\n\nSSE MESSAGE FORMAT:\nEach message is a complete BeadsApiResponse JSON object:\n data: {\"issues\":[...],\"dependencies\":[...],\"graphData\":{\"nodes\":[...],\"links\":[...]},\"stats\":{...}}\n\nThe client (task .5) will parse this and diff against current state.\n\nERROR HANDLING:\n- Discovery failure: sends { error: \"...\" } then closes stream\n- Parse failure during watch: logs error, does NOT close stream (transient file write state)\n- Client disconnect: cleanup function closes all watchers\n\nTESTING:\n1. Start dev server: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n2. Open in browser: http://localhost:3000/api/beads/stream\n3. You should see SSE data flowing (initial payload, then updates when JSONL changes)\n4. In another terminal: cd ~/Projects/gainforest/gainforest-beads && bd create --title \"test live\" --priority 3\n5. Within ~300ms, the SSE stream should push a new message with the updated data\n6. Ctrl+C the stream — check no watcher leaks in the server process\n\nDEPENDS ON: task .1 (types), task .2 (watch-beads.ts)\n\nACCEPTANCE CRITERIA:\n- GET /api/beads/stream returns Content-Type: text/event-stream\n- Initial data sent immediately on connection\n- Updates pushed when any watched JSONL file changes\n- Heartbeat keeps connection alive\n- Proper cleanup on client disconnect\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:15:56.921088+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:26:39.409649+13:00","closed_at":"2026-02-10T23:26:39.409649+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.316735+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-m1o","type":"blocks","created_at":"2026-02-10T23:19:28.909987+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6","title":"Activity feed: real-time + historical event log with compact overlay and expandable panel","description":"Add a comprehensive activity feed to beads-map showing both historical events and real-time updates.\n\n**Features:**\n- Historical feed from existing data (node creation/closure, links, comments, claims, likes)\n- Real-time events from SSE diffs (status/priority/title/owner changes, new comments, etc.)\n- Compact overlay (top-right) showing latest 5 events\n- Full panel (slide-in sidebar) with search and category filters\n- 13 event types with color-coded icons\n- Mutual exclusivity with other sidebars\n\n**Components created:**\n- lib/activity.ts: Event types, builders, diff-to-events converter\n- ActivityItem.tsx: Reusable event row (compact + full variants)\n- ActivityOverlay.tsx: Always-visible card with collapsible state\n- ActivityPanel.tsx: Full sidebar with search/filters\n\n**Integration:**\n- Activity pill in header navbar\n- SSE handler pipes diffs into activity feed\n- Event deduplication and 200-event cap\n","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:13.943245+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:21.787516+13:00","closed_at":"2026-02-11T12:05:23.135577+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.410329+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.1","title":"Create lib/activity.ts: ActivityEvent type and historical feed builder","description":"Create the core activity feed infrastructure in lib/activity.ts.\n\n**Implemented:**\n- ActivityEventType enum: 13 event types (node-created, node-closed, node-status-changed, node-priority-changed, node-title-changed, node-owner-changed, link-added, link-removed, comment-added, reply-added, task-claimed, task-unclaimed, like-added)\n- ActivityEvent interface: { id, type, time, nodeId, nodeTitle?, actor?, detail?, meta? }\n- ActivityActor interface: { handle, avatar?, did? }\n- ActivityFilterCategory type: \"issues\" | \"deps\" | \"comments\" | \"claims\" | \"likes\"\n- getEventCategory(): maps event types to filter categories\n- buildHistoricalFeed(nodes, links, allComments): extracts events from existing data\n- diffToActivityEvents(diff, nodes): converts real-time BeadsDiff into events\n- mergeFeedEvents(existing, incoming): deduplicates by event ID, sorts newest-first, caps at 200\n- Event ID format: \"${type}:${nodeId}:${time}\" for deduplication\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:21.793982+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:25.528565+13:00","closed_at":"2026-02-11T12:05:22.084119+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.1","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.795118+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.2","title":"Wire activity feed state in page.tsx: accumulate historical + SSE events","description":"Wire activity feed state management in app/page.tsx to accumulate historical and real-time events.\n\n**Implemented:**\n- Added state: activityFeed (ActivityEvent[]), activityPanelOpen (boolean), activityOverlayCollapsed (boolean)\n- useEffect to rebuild historical feed when data or allComments change via buildHistoricalFeed()\n- SSE onmessage handler: after computing diff, calls diffToActivityEvents() and merges into feed via mergeFeedEvents()\n- Feed accumulation with deduplication by event ID\n- Max 200 events retained (newest first)\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:21.921951+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:29.04198+13:00","closed_at":"2026-02-11T12:05:22.216634+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.923002+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:24.073985+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.7","type":"blocks","created_at":"2026-02-11T12:12:27.830152+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.3","title":"Create components/ActivityItem.tsx: single event row (compact + full variants)","description":"Create ActivityItem.tsx component for rendering individual activity events.\n\n**Implemented:**\n- Two variants: \"compact\" (single-line for overlay) and \"full\" (rich with avatar for panel)\n- Per-type SVG icons with color coding:\n - Emerald: positive actions (created, claimed, liked)\n - Amber: changes (status, priority, title, owner)\n - Red: removals (closed, link removed, unclaimed)\n - Blue: comments and replies\n- describeEvent() function: maps event types to human-readable text\n- Clickable node ID pills calling onNodeClick prop\n- Displays actor handle and avatar in full variant\n- Timestamp formatting (relative time)\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.046526+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:32.556229+13:00","closed_at":"2026-02-11T12:05:22.350628+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.048183+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:12.799635+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.4","title":"Create components/ActivityOverlay.tsx: compact always-visible top-right card","description":"Create ActivityOverlay.tsx: compact always-visible card in the top-right of the graph area.\n\n**Implemented:**\n- Position: absolute top-3 right-3 z-10 (inside graph area div)\n- Frosted glass styling: bg-white/90 backdrop-blur-sm rounded-lg border shadow-sm\n- Width: w-64 (256px)\n- Shows latest 5 events in compact variant\n- Collapsible to small pill with recent event count badge (events in last 5 min)\n- \"See all activity\" link opens full ActivityPanel\n- Hidden when: NodeDetail sidebar open, ActivityPanel open, or timeline active\n- Smooth transitions between expanded/collapsed states\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.173537+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:35.718366+13:00","closed_at":"2026-02-11T12:05:22.484041+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.174711+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:16.524399+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.5","title":"Create components/ActivityPanel.tsx: full slide-in sidebar with search and filters","description":"Create ActivityPanel.tsx: full slide-in sidebar with search and category filters.\n\n**Implemented:**\n- Layout: desktop w-[360px] absolute top-0 right-0 z-30, mobile bottom drawer\n- Search bar: filters by nodeId, title, actor handle, detail (case-insensitive substring)\n- 5 filter chips: Issues, Deps, Comments, Claims, Likes\n - All active by default, toggleable (min 1 active required)\n - Active chip styling: bg-emerald-50 text-emerald-700 border-emerald-200\n- Scrollable event list with full-variant ActivityItems\n- Footer: shows filtered event count\n- Same slide-in pattern as AllCommentsPanel\n- Close button with X icon\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.301972+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:38.561844+13:00","closed_at":"2026-02-11T12:05:22.615127+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.303116+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:20.162124+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.6","title":"Add Activity pill to header and wire overlay + panel in page.tsx","description":"Wire Activity pill button in header navbar and render ActivityOverlay + ActivityPanel.\n\n**Implemented:**\n- Added \"Activity\" pill button in header (between Comments and Auth divider)\n- Active state styling when activityPanelOpen\n- Rendered ActivityOverlay inside graph area div\n- Rendered ActivityPanel after AllCommentsPanel\n- Mutual exclusivity: opening ActivityPanel closes NodeDetail and AllCommentsPanel\n- ActivityOverlay hides when any sidebar or timeline is active\n- Props wired: feed, onNodeClick, onOpenPanel, onToggleCollapse, visibility flags\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.426965+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:42.290477+13:00","closed_at":"2026-02-11T12:05:22.74963+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.428287+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.2","type":"blocks","created_at":"2026-02-11T12:12:31.588158+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.4","type":"blocks","created_at":"2026-02-11T12:12:35.542205+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.5","type":"blocks","created_at":"2026-02-11T12:12:39.650845+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.7","title":"Extend diff engine to track owner and assignee changes","description":"Extend the diff engine in lib/diff-beads.ts to track owner and assignee field changes.\n\n**Implemented:**\n- Added owner field comparison at line 84: `if ((old.owner || \"\") !== (node.owner || \"\"))`\n- Generates node-owner-changed diff when owner field changes\n- Enables activity feed to show \"owner changed\" events in real-time\n","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.551848+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:45.586883+13:00","closed_at":"2026-02-11T12:05:22.877032+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.552867+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:09.2907+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.8","title":"Build, verify, and push activity feed feature","description":"Build, verify, and commit the complete activity feed feature.\n\n**Implemented:**\n- Ran pnpm build to verify no TypeScript errors\n- Tested activity overlay and panel in dev mode\n- Verified historical feed generation from existing data\n- Confirmed real-time event updates from SSE\n- Verified search and filter functionality in ActivityPanel\n- Committed changes with message: \"Activity feed: historical + real-time event log with compact overlay and expandable panel\"\n- Commit hash: ea51cb7\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.674715+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:48.579538+13:00","closed_at":"2026-02-11T12:05:23.005815+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.675891+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6.6","type":"blocks","created_at":"2026-02-11T12:12:44.251892+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np","title":"Epic: Surface owner/assignee in tooltip and search","description":"Two enhancements: (1) Show owner and assignee in the node hover tooltip (BeadTooltip) when present. (2) Make the search bar match on owner and assignee names so typing 'daviddao' finds all nodes assigned to or owned by that person.","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:33:49.054947+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.891487+13:00","closed_at":"2026-02-12T10:35:36.891487+13:00","close_reason":"Completed: 0c7b4e1 — all tasks done","dependencies":[{"issue_id":"beads-map-8np","depends_on_id":"beads-map-9d3","type":"blocks","created_at":"2026-02-12T10:39:55.489578+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np.1","title":"Add assignee and createdBy to GraphNode and buildGraphData","description":"In lib/types.ts: add 'assignee?: string' and 'createdBy?: string' fields to GraphNode interface. In lib/parse-beads.ts buildGraphData() (line ~140): map 'assignee: issue.assignee' and 'createdBy: issue.created_by' into the GraphNode object. In lib/diff-beads.ts: if assignee/createdBy changes should trigger _changedAt, add them to the diff comparison (optional — they're display-only so probably not needed).","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:33:56.34265+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.639771+13:00","closed_at":"2026-02-12T10:35:36.639771+13:00","close_reason":"Completed: 0c7b4e1","dependencies":[{"issue_id":"beads-map-8np.1","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:33:56.34421+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np.2","title":"Show owner and assignee in BeadTooltip","description":"In components/BeadTooltip.tsx: add two new metadata rows between 'Created' and 'Blocked by'. (1) 'Owner' row showing node.owner if present. (2) 'Assignee' row showing node.assignee if present. Both should be conditionally rendered — only show when the value exists. Style: same labelStyle/valueStyle as existing rows.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:34:01.699002+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.724586+13:00","closed_at":"2026-02-12T10:35:36.724586+13:00","close_reason":"Completed: 0c7b4e1","dependencies":[{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:01.699953+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.820355+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np.3","title":"Extend search bar to match on owner and assignee","description":"In app/page.tsx searchResults useMemo (line ~756): extend the searchable string from 'n.id n.title n.prefix' to include 'n.owner n.assignee n.createdBy' (with fallback to empty string for undefined values). This lets users type 'daviddao' and see all nodes owned by or assigned to that person. No UI changes to the result rendering needed — the existing display (id, title, prefix badge) is sufficient.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:34:07.488931+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.807758+13:00","closed_at":"2026-02-12T10:35:36.807758+13:00","close_reason":"Completed: 0c7b4e1","dependencies":[{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:07.490637+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.951842+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1","title":"Epic: Per-epic collapse/uncollapse via right-click context menu","description":"Add ability to collapse/uncollapse individual epics via right-click context menu while in Full view. A new collapsedEpicIds Set<string> state in page.tsx tracks which epics are individually collapsed. The viewNodes/viewLinks memo in BeadsGraph.tsx reads this set: in Full view, only children of collapsed epics are hidden; in Epics view, all children are hidden (existing behavior, unchanged). Context menu shows 'Collapse epic' on expanded epic nodes and 'Uncollapse epic' on collapsed ones. Collapse/uncollapse only available in Full view mode (Epics view forces all collapsed). collapsedEpicIds is independent of the Full/Epics toggle — switching modes preserves per-epic state.","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:08.902101+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:38.122465+13:00","closed_at":"2026-02-12T10:56:38.122465+13:00","close_reason":"All 4 subtasks completed in 74d70b0: per-epic collapse/uncollapse via right-click context menu"},{"id":"beads-map-8z1.1","title":"Add collapsedEpicIds state and toggle handler in page.tsx","description":"In app/page.tsx: (1) Add state: const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set()). (2) Add handler: const handleToggleEpicCollapse = useCallback((epicId: string) => { setCollapsedEpicIds(prev => { const next = new Set(prev); if (next.has(epicId)) next.delete(epicId); else next.add(epicId); return next; }); }, []). (3) Pass collapsedEpicIds as prop to <BeadsGraph>. Place state near other graph-related state (around line 188 near hoveredNode). Acceptance: prop is passed, handler exists, pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:17.434106+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:30.552955+13:00","closed_at":"2026-02-12T10:56:30.552955+13:00","close_reason":"Completed in 74d70b0: collapsedEpicIds state + handleToggleEpicCollapse in page.tsx","dependencies":[{"issue_id":"beads-map-8z1.1","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:17.43527+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1.2","title":"Integrate collapsedEpicIds into viewNodes/viewLinks memo in BeadsGraph","description":"In components/BeadsGraph.tsx: (1) Add collapsedEpicIds?: Set<string> to BeadsGraphProps (line ~36). (2) Destructure from props (line ~204). (3) Modify viewNodes/viewLinks useMemo (line ~296): Change the early return at line 297 from 'if (viewMode === \"full\") return ...' to 'if (viewMode === \"full\" && (!collapsedEpicIds || collapsedEpicIds.size === 0)) return ...'. (4) In the collapse logic, add a shouldCollapse helper: when viewMode === \"epics\" collapse all children (existing); when viewMode === \"full\" only collapse children whose parent is in collapsedEpicIds. Replace the line 'const childIds = new Set(childToParent.keys())' with a filtered set using shouldCollapse. (5) Add collapsedEpicIds to the useMemo dependency array. The rest of the memo (aggregate stats, filter nodes, remap links) stays identical — it already operates on the childIds set. Acceptance: individually collapsed epics fold their children in Full view; Epics view unchanged.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:27.393645+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:31.606551+13:00","closed_at":"2026-02-12T10:56:31.606551+13:00","close_reason":"Completed in 74d70b0: viewNodes/viewLinks memo supports per-epic collapse","dependencies":[{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:27.399923+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.363125+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1.3","title":"Add Collapse/Uncollapse epic menu items to ContextMenu","description":"In components/ContextMenu.tsx: (1) Add two new optional props to ContextMenuProps: onCollapseEpic?: () => void and onUncollapseEpic?: () => void. (2) Destructure them in the component. (3) Add 'Collapse epic' button after 'Add comment' (before Claim/Unclaim): conditionally rendered when onCollapseEpic is defined. Icon: inward-pointing chevrons/arrows (collapse visual). Style: same as other menu items (w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50). (4) Add 'Uncollapse epic' button: conditionally rendered when onUncollapseEpic is defined. Icon: outward-pointing chevrons/arrows (expand visual). (5) Adjust border-b logic on 'Add comment' button: it should show border-b when any of onCollapseEpic, onUncollapseEpic, onClaimTask, onUnclaimTask follow. Acceptance: menu items render correctly when props are provided, pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:36.641477+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:32.590331+13:00","closed_at":"2026-02-12T10:56:32.590331+13:00","close_reason":"Completed in 74d70b0: Collapse/Uncollapse epic menu items in ContextMenu","dependencies":[{"issue_id":"beads-map-8z1.3","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:36.643125+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1.4","title":"Wire collapse/uncollapse props in ContextMenu JSX in page.tsx","description":"In app/page.tsx, in the <ContextMenu> JSX (line ~1249): (1) Pass onCollapseEpic: set when ALL of: viewMode is 'full' (need viewMode from BeadsGraph — see note), node.issueType === 'epic', and !collapsedEpicIds.has(node.id). Calls handleToggleEpicCollapse(contextMenu.node.id) then setContextMenu(null). (2) Pass onUncollapseEpic: set when ALL of: viewMode is 'full', node.issueType === 'epic', and collapsedEpicIds.has(node.id). Same handler. NOTE on viewMode: viewMode currently lives inside BeadsGraph as internal state. Options: (a) Lift viewMode to page.tsx (cleanest but larger refactor), (b) Expose viewMode from BeadsGraph via the imperative handle (BeadsGraphHandle), (c) Add an onViewModeChange callback + viewMode prop to sync it up. Recommended: option (b) — add viewMode to the existing BeadsGraphHandle ref. Then page.tsx reads graphRef.current?.viewMode to decide. Alternatively, simpler approach: always show collapse/uncollapse on epic nodes in Full view — since the user explicitly chose the action, it's fine. We can track whether we're in epics view by checking the viewMode ref. Acceptance: right-clicking an epic in Full view shows Collapse/Uncollapse; in Epics view these items don't appear.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:48.968686+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:33.727649+13:00","closed_at":"2026-02-12T10:56:33.727649+13:00","close_reason":"Completed in 74d70b0: viewMode exposed via BeadsGraphHandle, props wired in page.tsx","dependencies":[{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:48.970023+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.478692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.2","type":"blocks","created_at":"2026-02-12T10:50:56.600112+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.3","type":"blocks","created_at":"2026-02-12T10:50:56.718812+13:00","created_by":"daviddao"}]},{"id":"beads-map-9d3","title":"Epic: Add hover tooltip to graph nodes showing title, creation date, blockers, and priority","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:25.175725+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.956317+13:00","closed_at":"2026-02-12T10:30:56.956317+13:00","close_reason":"Completed: 6d96fa3 — all tasks done"},{"id":"beads-map-9d3.2","title":"Create BeadTooltip component","description":"New file: components/BeadTooltip.tsx. React component inspired by plresearch.org DependencyGraph Tooltip design. White card, fade-in animation (0.2s translateY), colored accent bar (node prefix color), pointerEvents:none, position:fixed. Shows: (1) Title 14px semibold, (2) Created date via formatRelativeTime from lib/utils.ts, (3) Blocked by section listing dependentIds as short IDs or 'None', (4) Priority with PRIORITY_COLORS dot + PRIORITY_LABELS from lib/types.ts. Smart viewport clamping: prefer above cursor, flip below if no room. Width ~280px, border-radius 8px, shadow 0 8px 32px rgba(0,0,0,0.08). Props: node:GraphNode, x:number, y:number, prefixColor:string, allNodes:GraphNode[] (resolve blocker IDs to titles).","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:40.268918+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.779274+13:00","closed_at":"2026-02-12T10:30:56.779274+13:00","close_reason":"Completed: 6d96fa3","dependencies":[{"issue_id":"beads-map-9d3.2","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:40.269874+13:00","created_by":"daviddao"}]},{"id":"beads-map-9d3.3","title":"Wire hover tooltip state in page.tsx","description":"In app/page.tsx: (1) Add nodeTooltip state: { node: GraphNode; x: number; y: number } | null. (2) Modify handleNodeHover to accept (node, x, y) from BeadsGraph. (3) Render <BeadTooltip> in the graph area (alongside existing avatar tooltip) when nodeTooltip is set. Pass allNodes from data.graphData.nodes so tooltip can resolve blocker IDs to titles. Use getPrefixColor(node.prefix) for the accent color.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:46.061095+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.86785+13:00","closed_at":"2026-02-12T10:30:56.86785+13:00","close_reason":"Completed: 6d96fa3","dependencies":[{"issue_id":"beads-map-9d3.3","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:46.062066+13:00","created_by":"daviddao"}]},{"id":"beads-map-9d3.4","title":"Pass mouse position from BeadsGraph on hover","description":"In components/BeadsGraph.tsx: (1) Track last mouse position via mousemove listener on the container div (same pattern as avatar hover hit-testing). Store in a ref: lastMouseRef = useRef({x:0,y:0}). (2) Update onNodeHover prop type from (node: GraphNode | null) => void to (node: GraphNode | null, x: number, y: number) => void. (3) In the ForceGraph2D onNodeHover handler, read lastMouseRef.current and pass clientX/clientY along with the node. (4) Update BeadsGraphProps interface accordingly.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:51.724328+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.688848+13:00","closed_at":"2026-02-12T10:30:56.688848+13:00","close_reason":"Completed: 6d96fa3","dependencies":[{"issue_id":"beads-map-9d3.4","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:51.725102+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm","title":"Epic: Add radial, cluster-by-prefix, and spread graph layouts inspired by beads_viewer reference project","description":"Add three new graph layout modes to the beads-map force graph, inspired by the beads_viewer_for_agentic_coding_flywheel_setup reference project. Currently we have Force (physics-based) and DAG (topological top-down). This epic adds: (1) Radial — arranges nodes in concentric rings by dependency depth using d3.forceRadial, centered on origin. Root nodes (no incoming blockers) sit at center, deeper nodes in outer rings. (2) Cluster — groups nodes spatially by their project prefix (e.g., beads-map, beads, etc.) using d3.forceX/forceY pulling nodes toward prefix-specific center points arranged in a circle. Useful for multi-repo graphs to see project boundaries. (3) Spread — same physics as Force but with much stronger repulsion (charge -300), larger link distances (180), and weaker center pull. Maximizes spacing for readability and screenshot exports. All three are implemented purely via d3-force configuration in the existing layout useEffect in BeadsGraph.tsx — no new components or files needed. The dagMode prop on ForceGraph2D is only 'td' for DAG; all other modes use undefined (physics-only). Reference: beads_viewer_for_agentic_coding_flywheel_setup/graph.js lines 2420-2465 (applyRadialLayout, applyClusterLayout, applyForceLayout functions).","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:23:43.98936+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:30:38.998185+13:00","closed_at":"2026-02-12T11:30:38.998185+13:00","close_reason":"All 5 subtasks completed: imports (0137bf2), radial+cluster+spread forces (cee4c87), UI buttons (8d08e1c)"},{"id":"beads-map-9lm.1","title":"Add d3-force imports and extend LayoutMode type","description":"In components/BeadsGraph.tsx, make two changes: (1) Line 12 — extend the d3-force import from 'import { forceCollide } from \"d3-force\"' to 'import { forceCollide, forceRadial, forceX, forceY } from \"d3-force\"'. All four are exported from d3-force (verified: node_modules/d3-force/src/ contains radial.js, x.js, y.js alongside collide.js). (2) Line 21 — extend the LayoutMode type from 'type LayoutMode = \"force\" | \"dag\"' to 'type LayoutMode = \"force\" | \"dag\" | \"radial\" | \"cluster\" | \"spread\"'. This is a prerequisite for all three layout tasks — the new imports are used by radial (forceRadial) and cluster (forceX, forceY) layouts, and the type extension lets all five layouts be assigned to layoutMode state. Note: the existing dagMode prop logic (layoutMode === \"dag\" ? \"td\" : undefined at ~line 1876) automatically handles the new modes correctly since none equal \"dag\". Acceptance: pnpm build passes with zero errors. The new LayoutMode values are usable in useState and the switch branches.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:23:50.369669+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:28:17.095657+13:00","closed_at":"2026-02-12T11:28:17.095657+13:00","close_reason":"Completed: 0137bf2","dependencies":[{"issue_id":"beads-map-9lm.1","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:23:50.371471+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.3","title":"Implement radial layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"radial\")' branch after the existing 'dag' and 'force' branches. Implementation steps: (1) COMPUTE BFS DEPTH: Build an incoming-edges map from viewLinks — only count 'blocks' edges (skip 'parent-child'). Find root nodes (those with no incoming blocks edges). BFS outward from roots: for each node reached, set depth = parent_depth + 1. Store depth transiently as node._depth on each viewNode object (underscore prefix = transient animation metadata convention per AGENTS.md). Nodes unreachable from any root get _depth = 0. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as the force branch ~line 642) — these may be left over from DAG mode which sets fixed positions. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-100).distanceMax(300); fg.d3Force('link')?.distance(80).strength(0.5); fg.d3Force('center')?.strength(0.01); fg.d3Force('radial', forceRadial((node: any) => ((node as any)._depth || 0) * 80, 0, 0).strength(0.5)); fg.d3Force('x', forceX(0).strength(0.05)); fg.d3Force('y', forceY(0).strength(0.05)); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 5).strength(0.6)). (4) CROSS-TASK CLEANUP: The existing 'dag' and 'force' branches must be updated to clear the new custom forces — add fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); at the start of each non-radial branch. This prevents stale radial/x/y forces from persisting when switching away from radial mode. (5) ADD viewLinks TO USEEFFECT DEPS: Currently the deps are [layoutMode, viewNodes.length]. The radial BFS reads viewLinks, so add viewLinks to the dependency array. Edge cases: (a) If graph has cycles, BFS may not reach all nodes — default _depth=0 is fine, they cluster at center. (b) If all nodes are roots (no blocks edges), all get _depth=0 and cluster at center ring — this is correct behavior for a flat graph. Acceptance: selecting Radial layout arranges nodes in concentric rings by dependency depth. Root nodes at center, leaf nodes on outer rings. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:08.116392+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:29:23.576116+13:00","closed_at":"2026-02-12T11:29:23.576116+13:00","close_reason":"Completed: cee4c87","dependencies":[{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:08.11827+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.58504+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.4","title":"Implement cluster-by-prefix layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"cluster\")' branch. Implementation steps: (1) COMPUTE PREFIX CENTERS: Get unique prefixes from viewNodes via new Set(viewNodes.map(n => n.prefix)). Arrange center positions in a circle: radius = Math.max(200, prefixes.length * 50). For each prefix at index i, center = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius } where angle = (2 * Math.PI * i / count) - Math.PI / 2 (start from top). Store in a local Map<string, {x: number, y: number}>. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same as force/radial branches). (3) CLEAR STALE FORCES: fg.d3Force('radial', null) to remove any leftover radial force from a previous layout. (4) CONFIGURE FORCES: fg.d3Force('x', forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.3)); fg.d3Force('y', forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.3)); fg.d3Force('charge')?.strength(-40).distanceMax(250); fg.d3Force('link')?.distance(60).strength(0.3); fg.d3Force('center')?.strength(0.01); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 6).strength(0.7)). Design notes: Uses forceX/forceY per-node accessors to pull each node toward its prefix cluster center. Weaker charge (-40) keeps nodes within their cluster rather than repelling to distant positions. Cross-prefix links will stretch across clusters, making inter-project dependencies visually obvious. Edge cases: (a) Single-prefix graph — all nodes cluster at one position, which is fine (effectively same as force). (b) node.prefix is always set per lib/types.ts:51 so the Map lookup always succeeds. Acceptance: selecting Cluster layout spatially groups nodes by project prefix. Multi-repo graphs show distinct clusters. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:14.896035+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:29:23.709649+13:00","closed_at":"2026-02-12T11:29:23.709649+13:00","close_reason":"Completed: cee4c87","dependencies":[{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:14.898283+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.764197+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.5","title":"Implement spread layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"spread\")' branch. This is the simplest layout — identical to the existing force branch but with tuned parameters for maximum spacing and readability. Implementation steps: (1) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as force branch ~line 642). (2) CLEAR STALE FORCES: fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); — remove any custom forces from radial/cluster modes. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-300).distanceMax(500); fg.d3Force('link')?.distance(180).strength(0.4); fg.d3Force('center')?.strength(0.02); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 8).strength(0.8)). Key differences from force mode: charge is -300 vs -180 (much stronger repulsion), link distance is 180 vs 90-120 (wider gaps), center is 0.02 vs 0.03 (weaker pull so graph can spread), collision radius is +8 vs +6 (more buffer). Inspired by beads_viewer reference: LAYOUT_PRESETS.spread uses linkDistance 180, chargeStrength -300, centerStrength 0.02. No link distance per-connection intelligence needed (unlike force mode which varies by connection count) — uniform spacing is the point. Acceptance: selecting Spread layout produces a well-spaced graph suitable for screenshots and exports. Nodes should not overlap. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:20.51105+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:29:23.830149+13:00","closed_at":"2026-02-12T11:29:23.830149+13:00","close_reason":"Completed: cee4c87","dependencies":[{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:20.513717+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.949509+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.6","title":"Add layout toggle buttons for Radial, Cluster, Spread","description":"In components/BeadsGraph.tsx, expand the existing 2-button layout segmented control (Force/DAG) to 5 buttons: Force, DAG, Radial, Cluster, Spread. The existing buttons are at ~line 1609 inside a div with className 'flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden'. Each button follows the exact same pattern: (a) onClick={() => setLayoutMode('radial')} etc., (b) active state: bg-emerald-500 text-white, inactive: text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50, (c) inner span with SVG icon (16x16 viewBox) + hidden sm:inline text label, (d) w-px bg-zinc-200 divider between each button. Existing buttons to keep: Force (scattered dots with connections icon, ~line 1610-1638) and DAG (top-down tree icon, ~line 1641-1672). New buttons to add after DAG: (1) RADIAL: icon = concentric circles (e.g., circle cx=8 cy=8 r=2 filled + circle r=5 stroke-only + circle r=7 stroke-only opacity=0.4), label 'Radial'. (2) CLUSTER: icon = grouped dots (e.g., 3 dots upper-left clustered + 3 dots lower-right clustered, suggesting two groups), label 'Cluster'. (3) SPREAD: icon = scattered dots with ample spacing (e.g., 5 small dots spread across the 16x16 viewBox with no connections, suggesting maximum spacing), label 'Spread'. Each new button needs a divider (w-px bg-zinc-200) before it. The layoutMode state variable is at ~line 255: useState<LayoutMode>('dag'). The bootstrap trick (~line 666) auto-switches from DAG to force on initial load — this should remain unchanged (new layouts are only activated by user click). Acceptance: all 5 buttons render correctly, clicking each switches the layout. Active button is visually highlighted. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:29.190312+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:30:38.877928+13:00","closed_at":"2026-02-12T11:30:38.877928+13:00","close_reason":"Completed: 8d08e1c","dependencies":[{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:29.192269+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.3","type":"blocks","created_at":"2026-02-12T11:24:36.145483+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.4","type":"blocks","created_at":"2026-02-12T11:24:36.312505+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.5","type":"blocks","created_at":"2026-02-12T11:24:36.484971+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh","title":"ATProto login with identity display and annotation support","description":"Add ATProto (Bluesky) OAuth login to beads-map, porting the auth infrastructure from Hyperscan. Users can sign in with their Bluesky handle, see their avatar/name in the header, and (in future work) leave annotations on issues in the graph.\n\nARCHITECTURE:\n- OAuth 2.0 Authorization Code flow with PKCE via @atproto/oauth-client-node\n- Encrypted cookie sessions via iron-session (no client-side token storage)\n- React Context (AuthProvider + useAuth hook) for client-side auth state\n- 6 API routes (login, callback, client-metadata, jwks, status, logout)\n- Sign-in modal + avatar dropdown in header top-right (next to stats)\n- Support both confidential (production) and public (dev) OAuth client modes\n\nWHY HYPERSCAN'S APPROACH:\nHyperscan already solved this for ATProto in a Next.js App Router context. Their implementation is production-grade, handles all edge cases (reconnection, network errors, session restoration), and follows OAuth best practices. We'll port the core auth infrastructure verbatim, then adapt the UI to match beads-map's design.\n\nDEPENDENCY ON PAST WORK:\nThis modifies app/page.tsx (header) and app/layout.tsx (AuthProvider wrapper), which were last touched by the live-update epic (beads-map-3jy). The two features are independent but touch the same files.\n\nSCOPE:\nThis epic covers ONLY the auth infrastructure and identity display (avatar in header). Annotation features (writing comments to ATProto) will be a separate follow-up epic that builds on the authenticated agent helper (task .7).\n\nTASK BREAKDOWN:\n.1 - Install deps + env setup\n.2 - Session management (iron-session)\n.3 - OAuth client factory (confidential + public modes)\n.4 - Auth API routes (login, callback, status, logout, metadata, jwks)\n.5 - AuthProvider + useAuth hook\n.6 - AuthButton component (sign-in modal + avatar dropdown)\n.7 - Authenticated agent helper (for future annotation writes)\n.8 - Build + integration test\n\nFILES TO CREATE (13 files):\n- .env.example\n- scripts/generate-jwk.js\n- lib/env.ts\n- lib/session.ts\n- lib/auth/client.ts\n- lib/auth.tsx\n- lib/agent.ts\n- app/api/login/route.ts\n- app/api/oauth/callback/route.ts\n- app/api/oauth/client-metadata.json/route.ts\n- app/api/oauth/jwks.json/route.ts\n- app/api/status/route.ts\n- app/api/logout/route.ts\n- components/AuthButton.tsx\n\nFILES TO MODIFY (2 files):\n- package.json (add 5 dependencies)\n- app/layout.tsx (wrap in AuthProvider)\n- app/page.tsx (add <AuthButton /> to header)","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-10T23:56:19.74299+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:12.831198+13:00","closed_at":"2026-02-11T00:06:12.831198+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.001081+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.1","title":"Install ATProto auth dependencies and environment setup","description":"Foundation task: install npm packages, create environment variable template, add JWK generation script, and create env validation utility.\n\nPART 1: Install dependencies\n\nAdd to package.json:\n pnpm add @atproto/oauth-client-node@^0.3.15 @atproto/api@^0.18.16 @atproto/jwk-jose@^0.1.11 @atproto/syntax@^0.4.2 iron-session@^8.0.4\n\nPART 2: Create .env.example\n\nFile: /Users/david/Projects/gainforest/beads-map/.env.example\nContent:\n# ATProto OAuth Authentication\n# Copy this file to .env.local and fill in the values\n\n# Required for all modes: Session encryption key (32+ chars)\nCOOKIE_SECRET=development-secret-at-least-32-chars!!\n\n# Required for production: Your app's public URL\n# Leave empty for localhost dev mode (uses public OAuth client)\nPUBLIC_URL=\n\n# Optional: Dev server port (default 3000)\nPORT=3000\n\n# Required for production confidential client: ES256 JWK private key\n# Generate with: node scripts/generate-jwk.js\n# Leave empty for localhost dev mode\nATPROTO_JWK_PRIVATE=\n\nPART 3: Create scripts/generate-jwk.js\n\nCopy verbatim from Hyperscan: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\nMake executable: chmod +x scripts/generate-jwk.js\n\nPART 4: Create lib/env.ts\n\nFile: /Users/david/Projects/gainforest/beads-map/lib/env.ts\nPort from Hyperscan's /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n- Validates COOKIE_SECRET, PUBLIC_URL, PORT, ATPROTO_JWK_PRIVATE\n- Provides defaults for dev mode\n- Exports typed env object\n\nREFERENCE FILES:\n- Hyperscan package.json: /Users/david/Projects/gainforest/hyperscan/package.json\n- Hyperscan generate-jwk.js: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\n- Hyperscan env.ts: /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n\nACCEPTANCE CRITERIA:\n- All 5 packages installed in package.json dependencies\n- .env.example exists with all 4 env vars documented\n- scripts/generate-jwk.js exists and is executable\n- lib/env.ts exists and validates env vars\n- pnpm build passes (env.ts may not be used yet, but must compile)\n- .gitignore already has .env* (from earlier work)\n\nNOTES:\n- Do NOT create .env.local -- user will do that manually\n- Do NOT commit any actual secrets\n- The env.ts validation will allow missing values for dev mode (defaults kick in)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:56:38.692755+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.670005+13:00","closed_at":"2026-02-11T00:06:08.670005+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.1","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:56:38.694406+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.2","title":"Create session management with iron-session","description":"Port Hyperscan's iron-session setup for encrypted cookie-based authentication sessions.\n\nPURPOSE: iron-session encrypts session data into cookies (no database needed). The session stores user identity (did, handle, displayName, avatar) and OAuth session tokens for authenticated API calls.\n\nCREATE FILE: lib/session.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nKey changes when porting:\n1. Cookie name: 'impact_indexer_sid' -> 'beads_map_sid'\n2. Import env from our lib/env.ts (not Hyperscan's path)\n3. Keep the same session shape:\n interface Session {\n did?: string\n handle?: string\n displayName?: string\n avatar?: string\n returnTo?: string\n oauthSession?: string\n }\n4. Keep the same exports:\n - getSession(request/cookies)\n - getRawSession(request/cookies)\n - clearSession(request/cookies)\n\nIMPLEMENTATION NOTES:\n- Use env.COOKIE_SECRET for encryption\n- secure: true only when env.PUBLIC_URL is set (production)\n- maxAge: 30 days (same as Hyperscan)\n- Support both Next.js Request and cookies() from next/headers (for Server Components vs API routes)\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nACCEPTANCE CRITERIA:\n- lib/session.ts exists\n- Exports getSession, getRawSession, clearSession\n- Session type matches Hyperscan's shape\n- Cookie is secure in production (when PUBLIC_URL set), insecure in dev\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:01.110787+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.757647+13:00","closed_at":"2026-02-11T00:06:08.757647+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:01.112211+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh.1","type":"blocks","created_at":"2026-02-10T23:57:01.113311+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.3","title":"Create OAuth client factory with dual mode support","description":"Port Hyperscan's OAuth client setup with support for both confidential (production) and public (localhost dev) client modes.\n\nPURPOSE: The OAuth client handles the full Authorization Code flow with PKCE. It needs two modes:\n- Public client (dev): loopback client_id, no secrets, works on localhost\n- Confidential client (prod): uses ES256 JWK for private_key_jwt auth, requires PUBLIC_URL\n\nCREATE FILE: lib/auth/client.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nKey adaptations:\n1. Import Session type from our lib/session.ts\n2. Import env from our lib/env.ts\n3. clientName: 'Beads Map' (not 'Impact Indexer')\n4. The sessionStore must sync OAuth session data between in-memory Map and iron-session cookies (critical for serverless)\n\nIMPLEMENTATION NOTES:\n- Export getGlobalOAuthClient() as the main API\n- If PUBLIC_URL is set: confidential mode (load JWK from env.ATPROTO_JWK_PRIVATE, publish client-metadata.json and jwks.json)\n- If PUBLIC_URL is empty: public mode (loopback client_id, use 127.0.0.1 not localhost, no JWK)\n- The client is cached globally per process (singleton pattern)\n- Session store serializes OAuth session (tokens, DPoP keys) to/from cookie.oauthSession field\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nACCEPTANCE CRITERIA:\n- lib/auth/client.ts exists (create lib/auth/ dir)\n- Exports getGlobalOAuthClient()\n- Supports both confidential and public modes based on env.PUBLIC_URL\n- Session store syncs with iron-session cookie\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:16.261043+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.840672+13:00","closed_at":"2026-02-11T00:06:08.840672+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:16.26232+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh.2","type":"blocks","created_at":"2026-02-10T23:57:16.263416+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.4","title":"Create 6 authentication API routes","description":"Port all 6 auth-related API routes from Hyperscan.\n\nCREATE 6 ROUTE FILES:\n\n1. app/api/login/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n - POST handler\n - Validates handle with @atproto/syntax isValidHandle\n - Calls client.authorize(handle)\n - Stores returnTo in session cookie\n - Returns { redirectUrl }\n\n2. app/api/oauth/callback/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n - GET handler\n - Calls client.callback(params) to exchange code for tokens\n - Fetches profile via @atproto/api Agent\n - Saves { did, handle, displayName, avatar, oauthSession } to session cookie\n - Redirects to returnTo (303 redirect)\n - Has retry logic for network errors\n\n3. app/api/oauth/client-metadata.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n - GET handler (only in confidential mode)\n - Returns OAuth client metadata JSON per ATProto spec\n\n4. app/api/oauth/jwks.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n - GET handler (only in confidential mode)\n - Returns JWKS public keys\n\n5. app/api/status/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n - GET handler\n - Reads session cookie\n - Returns { authenticated, did, handle, displayName, avatar } or { authenticated: false }\n\n6. app/api/logout/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n - POST handler\n - Calls clearSession()\n - Returns { success: true }\n\nKEY NOTES:\n- All routes use export const dynamic = 'force-dynamic'\n- Import from our lib/ paths (not Hyperscan's src/lib/)\n- The callback route should handle both OAuth success and error states\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n\nACCEPTANCE CRITERIA:\n- All 6 route files exist in correct paths\n- Each exports the correct HTTP method handler (GET or POST)\n- All routes compile and pnpm build passes\n- All routes use dynamic = 'force-dynamic'\n- Imports use beads-map paths (not Hyperscan's)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:32.923191+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.921439+13:00","closed_at":"2026-02-11T00:06:08.921439+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:32.924539+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:57:32.926286+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.5","title":"Create AuthProvider and useAuth hook","description":"Port Hyperscan's client-side auth state management: React Context provider and useAuth hook. Wrap the app in AuthProvider.\n\nCREATE FILE: lib/auth.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n\nKey pieces:\n1. AuthContext with shape: state, login, logout\n2. AuthProvider component:\n - Manages auth status: idle, authorizing, authenticated, error\n - On mount: checks /api/status to restore session\n - Exposes login(handle) and logout() functions\n3. useAuth() hook:\n - Returns status, session, isLoading, isAuthenticated, login, logout\n - session shape: did, handle, displayName, avatar or null\n\nLOGIN FLOW client-side:\n1. User calls login(handle)\n2. POST to /api/login with handle and returnTo\n3. Server returns redirectUrl\n4. window.location.href = redirectUrl (redirect to PDS)\n5. After OAuth callback completes, browser redirects back to returnTo\n6. AuthProvider re-checks /api/status and updates context\n\nLOGOUT FLOW:\n1. User calls logout()\n2. POST to /api/logout\n3. Clear local state\n4. Optionally reload or redirect\n\nMODIFY FILE: app/layout.tsx\n\nWrap children in AuthProvider from lib/auth.tsx\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n/Users/david/Projects/gainforest/hyperscan/src/app/layout.tsx (for wrapper example)\n\nACCEPTANCE CRITERIA:\n- lib/auth.tsx exists\n- Exports AuthProvider, useAuth\n- useAuth returns correct shape\n- app/layout.tsx wraps children in AuthProvider\n- pnpm build passes\n- No client-side token storage, all session state comes from /api/status reading cookies","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:56.261859+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.003714+13:00","closed_at":"2026-02-11T00:06:09.003714+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:56.263692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh.4","type":"blocks","created_at":"2026-02-10T23:57:56.264726+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.6","title":"Create AuthButton component and integrate into header","description":"Port Hyperscan's AuthButton component (sign-in modal + avatar dropdown) and add it to the page.tsx header top-right, next to the stats.\n\nCREATE FILE: components/AuthButton.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n\nKey UI elements:\n1. When logged out: Sign in text link\n2. Click opens modal with:\n - Title: Sign in with ATProto\n - Handle input field with placeholder alice.bsky.social\n - Helper text: Just a username? We will add .bsky.social for you\n - Cancel + Connect buttons (emerald-600 green for Connect)\n - Error display area\n - Backdrop blur overlay\n3. When logged in: Avatar (24x24 rounded) + display name/handle (truncated)\n4. Click avatar opens dropdown with:\n - Profile link (to /profile if we add that page, or just show did for now)\n - Divider\n - Sign out button\n\nThe component uses useAuth() hook from lib/auth.tsx for status, session, login, logout.\n\nADAPT STYLING:\n- Use beads-map's design tokens: emerald-500/600, zinc colors, same border-radius, same shadows\n- Match the existing header style (text-xs, simple, clean)\n- Modal should be centered with 20vh from top (same as Hyperscan)\n\nMODIFY FILE: app/page.tsx\n\nAdd AuthButton to header right section (line 644-662). Current structure:\n Left: Logo + title\n Center: Search\n Right: Stats (total issues, deps, projects)\n\nNew structure:\n Right: Stats + vertical divider + <AuthButton />\n\nThe stats div stays, just add:\n <span className=\"w-px h-4 bg-zinc-200\" />\n <AuthButton />\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n/Users/david/Projects/gainforest/hyperscan/src/components/Header.tsx (for placement example)\n\nACCEPTANCE CRITERIA:\n- components/AuthButton.tsx exists\n- Shows sign-in text link when logged out\n- Shows avatar + name/handle when logged in\n- Modal opens on click when logged out, dropdown on click when logged in\n- Integrated into page.tsx header right section\n- Matches beads-map visual style (emerald, zinc, text-xs)\n- pnpm build passes\n- Dev server shows the button (no visual regression on existing header)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:58:15.698478+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.086644+13:00","closed_at":"2026-02-11T00:06:09.086644+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:15.699689+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh.5","type":"blocks","created_at":"2026-02-10T23:58:15.700911+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.7","title":"Create authenticated agent helper for ATProto API calls","description":"Create a server-side utility that restores an authenticated ATProto Agent from the session cookie. This is the foundation for future annotation writes.\n\nCREATE FILE: lib/agent.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nPurpose:\n- Server-side only (API routes, Server Components, Server Actions)\n- Reads session cookie to get oauthSession data\n- Calls client.restore(did, oauthSession) to rebuild OAuth session\n- Returns @atproto/api Agent instance for making authenticated ATProto API calls\n\nKey function:\nexport async function getAuthenticatedAgent(request: Request): Promise<Agent>\n - Reads session via getSession(request)\n - If no session.did or session.oauthSession: throw Error(Unauthorized)\n - Deserialize oauthSession\n - Call client.restore(did, sessionData)\n - Return new Agent(restoredOAuthSession)\n\nUSAGE EXAMPLE (for future annotation API):\n// In app/api/annotations/route.ts\nimport { getAuthenticatedAgent } from '@/lib/agent'\n\nexport async function POST(request: Request) {\n const agent = await getAuthenticatedAgent(request)\n // agent.com.atproto.repo.createRecord(...)\n}\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nACCEPTANCE CRITERIA:\n- lib/agent.ts exists\n- Exports getAuthenticatedAgent(request)\n- Throws clear error if not authenticated\n- Returns Agent instance ready for ATProto API calls\n- pnpm build passes\n- NOT YET USED (will be used in future annotation epic), but must compile","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:58:28.649345+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.166246+13:00","closed_at":"2026-02-11T00:06:09.166246+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:28.65065+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:58:28.65195+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.8","title":"Build verification and integration test","description":"Final verification that the ATProto login system works end-to-end in dev mode (public OAuth client).\n\nPART 1: Build check\n pnpm build -- must pass with zero errors\n\nPART 2: Dev server smoke test\n\nStart dev server with existing beads project:\n BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n\nBrowser tests:\n1. Open http://localhost:3000\n2. Header should show: Logo | Search | Stats | Sign in (new!)\n3. Click Sign in -> modal opens\n4. Enter a Bluesky handle (e.g. alice.bsky.social)\n5. Click Connect -> redirects to bsky.social OAuth consent screen\n6. Approve -> redirects back to beads-map\n7. Header should now show: Logo | Search | Stats | Avatar + name (alice.bsky.social)\n8. Click avatar -> dropdown opens with Sign out\n9. Click Sign out -> back to unauthenticated state\n10. Refresh page -> session persists (avatar still shows)\n11. Open devtools Network tab -> no client-side token storage, only encrypted cookies\n\nServer logs should show:\n- Watching N files for changes (from file watcher, unrelated)\n- No OAuth client errors\n- If using localhost: should see public client mode log\n\nPART 3: Session persistence check\n- Login\n- Close browser tab\n- Reopen http://localhost:3000\n- Should still be logged in (session cookie persists)\n\nPART 4: Error handling check\n- Click Sign in\n- Enter invalid handle (e.g. test.invalid)\n- Should show error message in modal (not crash)\n\nFUNCTIONAL CHECKS:\n- Graph still works (nodes, links, search, layout toggle)\n- Stats still update in real-time\n- No console errors during login/logout flow\n- No memory leaks (EventSource from earlier work still cleans up)\n\nPERFORMANCE CHECKS:\n- Page load time not significantly affected by auth check\n- No jank during modal open/close animations\n\nKNOWN LIMITATIONS (OK for this epic):\n- /profile route does not exist yet (clicking Profile in dropdown would 404)\n- No annotation features yet (will be follow-up epic)\n- Confidential client mode not tested (requires PUBLIC_URL + JWK in production)\n\nACCEPTANCE CRITERIA:\n- pnpm build passes\n- Can log in via localhost OAuth in dev mode\n- Avatar + name display correctly when logged in\n- Session persists across page refresh\n- Logout works\n- No console errors\n- All existing features (graph, search, live updates) still work","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:58:49.014769+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.245646+13:00","closed_at":"2026-02-11T00:06:09.245646+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:49.015822+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.6","type":"blocks","created_at":"2026-02-10T23:58:49.016931+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.7","type":"blocks","created_at":"2026-02-10T23:58:49.017826+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi","title":"Right-click comment tooltip on graph nodes with ATProto annotations","description":"## Right-click comment tooltip on graph nodes with ATProto annotations\n\n### Summary\nAdd right-click context menu on graph nodes that opens a beautiful floating tooltip (inspired by plresearch.org dependency graph) with a text input to post ATProto comments using the `org.impactindexer.review.comment` lexicon. Fetch existing comments from the Hypergoat indexer, show comment icon badge on nodes with comments, and display a full comment section in the NodeDetail sidebar panel.\n\n### Subject URI Convention\nComments target beads issues using: `{ uri: 'beads:<issue-id>', type: 'record' }`\nExample: `{ uri: 'beads:beads-map-cvh', type: 'record' }`\n\n### Architecture\n```\n[User right-clicks node] → CommentTooltip appears (positioned near cursor)\n → [User types + clicks Send]\n → POST /api/records → getAuthenticatedAgent() → agent.com.atproto.repo.createRecord()\n → Record written to user's PDS as org.impactindexer.review.comment\n → refetch() → Hypergoat GraphQL indexer → updated commentsByNode Map\n → [Comment badge appears on node] + [Comments shown in NodeDetail sidebar]\n```\n\n### Task dependency chain\n- .1 (API route) and .2 (comments hook) are independent — can be done in parallel\n- .3 (right-click + tooltip) is independent but uses auth awareness\n- .4 (comment badge) depends on .2 (needs commentedNodeIds)\n- .5 (NodeDetail comments) depends on .2 (needs comments data)\n- .6 (wiring) depends on ALL of .1-.5\n\n### Key reference files\n- Hyperscan API route: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`\n- Hypergoat indexer: `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts`\n- Tooltip design: `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Subject ref defs: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/defs.json`\n\n### New files to create\n1. `app/api/records/route.ts` — generic ATProto record CRUD route\n2. `hooks/useBeadsComments.ts` — fetch + parse comments from indexer\n3. `components/CommentTooltip.tsx` — floating right-click comment tooltip\n\n### Files to modify\n1. `components/BeadsGraph.tsx` — add onNodeRightClick prop + comment badge in paintNode\n2. `components/NodeDetail.tsx` — add Comments section at bottom\n3. `app/page.tsx` — wire everything together\n\n### Build & test\n```bash\npnpm build # Must pass with zero errors\nBEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev # Manual test\n```\n\n### Design specification (from plresearch.org)\n- White bg, border `1px solid #E5E7EB`, border-radius 8px\n- Shadow: `0 8px 32px rgba(0,0,0,0.08)`\n- Padding: 18px 20px\n- Colored accent bar (24px x 2px) using node prefix color\n- Fade-in animation: 0.2s ease from opacity:0 translateY(4px)\n- Comment badge on nodes: blue (#3b82f6) speech bubble at top-right","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:11.044718+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:47:32.504475+13:00","closed_at":"2026-02-11T00:47:32.504475+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi","depends_on_id":"beads-map-cvh","type":"blocks","created_at":"2026-02-12T10:39:55.083326+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.1","title":"Create /api/records route for ATProto record CRUD","description":"## Create /api/records route for ATProto record CRUD\n\n### Goal\nCreate `app/api/records/route.ts` — a generic server-side API route that allows authenticated users to create, update, and delete ATProto records on their PDS (Personal Data Server). This is the foundational route that the comment feature will use to write `org.impactindexer.review.comment` records.\n\n### What to create\n**New file:** `app/api/records/route.ts`\n\n### Source to port from\nCopy almost verbatim from Hyperscan: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts` (136 lines). The only changes needed are import paths (`@/lib/agent` → `@/lib/agent`, `@/lib/session` → `@/lib/session` — these are actually identical since beads-map uses the same structure).\n\n### Implementation details\n\nThe file exports three HTTP handlers:\n\n**POST /api/records** — Create a new record:\n```typescript\nimport { NextRequest, NextResponse } from 'next/server'\nimport { getAuthenticatedAgent } from '@/lib/agent'\nimport { getSession } from '@/lib/session'\n\nexport const dynamic = 'force-dynamic'\n\nexport async function POST(request: NextRequest) {\n // 1. Check session.did from iron-session cookie → 401 if missing\n // 2. Call getAuthenticatedAgent() → 401 if null\n // 3. Parse body: { collection: string, rkey?: string, record: object }\n // 4. Validate collection (required, string) and record (required, object)\n // 5. Call agent.com.atproto.repo.createRecord({ repo: session.did, collection, rkey: rkey || undefined, record })\n // 6. Return { success: true, uri: res.data.uri, cid: res.data.cid }\n}\n```\n\n**PUT /api/records** — Update an existing record:\n- Same auth checks\n- Body: { collection, rkey (required), record }\n- Calls agent.com.atproto.repo.putRecord(...)\n- Returns { success: true }\n\n**DELETE /api/records?collection=...&rkey=...** — Delete a record:\n- Same auth checks\n- Params from URL searchParams\n- Calls agent.com.atproto.repo.deleteRecord(...)\n- Returns { success: true }\n\nAll methods wrap in try/catch, returning { error: message } with status 500 on failure.\n\n### Dependencies already in place\n- `lib/agent.ts` — exports `getAuthenticatedAgent()` which returns an `Agent` from `@atproto/api` (already created in previous session)\n- `lib/session.ts` — exports `getSession()` returning `Session` with optional `did` field (already created)\n- `@atproto/api` — already installed in package.json\n\n### Testing\nAfter creating the file, run `pnpm build` to verify it compiles. The route should appear in the build output as `ƒ /api/records` (dynamic route).\n\n### Acceptance criteria\n- [ ] File exists at `app/api/records/route.ts`\n- [ ] Exports POST, PUT, DELETE handlers\n- [ ] Uses `export const dynamic = 'force-dynamic'`\n- [ ] All three methods check `session.did` and `getAuthenticatedAgent()`\n- [ ] `pnpm build` passes with no type errors","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:20.159813+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:02.816953+13:00","closed_at":"2026-02-11T00:44:02.816953+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.1","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:20.161533+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.2","title":"Create useBeadsComments hook to fetch comments from Hypergoat indexer","description":"## Create useBeadsComments hook to fetch comments from Hypergoat indexer\n\n### Goal\nCreate `hooks/useBeadsComments.ts` — a React hook that fetches all `org.impactindexer.review.comment` records from the Hypergoat GraphQL indexer, filters them to only those whose subject URI starts with `beads:`, resolves commenter profiles, and returns structured data for the UI.\n\n### What to create\n**New file:** `hooks/useBeadsComments.ts`\n\n### Hypergoat GraphQL API\n- **Endpoint:** `https://hypergoat-app-production.up.railway.app/graphql`\n- **Query pattern** (from `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts` line 80-100):\n```graphql\nquery FetchRecords($collection: String!, $first: Int, $after: String) {\n records(collection: $collection, first: $first, after: $after) {\n edges {\n node {\n cid\n collection\n did\n rkey\n uri\n value\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n```\n- Call with `collection: 'org.impactindexer.review.comment'`, `first: 100`\n- The `value` field is a JSON object containing the record data including `subject`, `text`, `createdAt`\n\n### Comment record shape (from `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`):\n```typescript\n{\n subject: { uri: string, type: string }, // e.g. { uri: 'beads:beads-map-cvh', type: 'record' }\n text: string,\n createdAt: string, // ISO 8601\n replyTo?: string, // AT-URI of parent comment (not used yet but good to preserve)\n}\n```\n\n### Profile resolution\nFor each unique `did` in comments, resolve to display info via the Bluesky public API:\n```typescript\n// Resolve DID to profile (handle, displayName, avatar)\nconst res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`)\nconst profile = await res.json()\n// Returns: { did, handle, displayName?, avatar? }\n```\nCache results in a module-level Map<string, ResolvedProfile> to avoid redundant fetches. Deduplicate in-flight requests.\n\n### Hook interface\n```typescript\ninterface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string; // AT-URI of the comment record itself\n rkey: string;\n}\n\ninterface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // key = beads issue ID (e.g. 'beads-map-cvh')\n commentedNodeIds: Set<string>; // for quick badge lookup in paintNode\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n\nexport function useBeadsComments(): UseBeadsCommentsResult\n```\n\n### Implementation steps\n1. On mount, call the GraphQL endpoint to fetch comments\n2. Parse each record's `value.subject.uri` — only keep those starting with `beads:`\n3. Extract the beads issue ID by stripping the `beads:` prefix (e.g. `beads:beads-map-cvh` → `beads-map-cvh`)\n4. Group comments by issue ID into a Map\n5. Build `commentedNodeIds` Set from the Map keys\n6. Resolve all unique DIDs to profiles in parallel (with caching)\n7. Merge profile data into each BeadsComment object\n8. Return the result with a `refetch()` function that re-runs the whole pipeline\n9. Comments within each node should be sorted newest-first by `createdAt`\n\n### Error handling\n- Silent failure on profile resolution (show DID prefix as fallback)\n- Set error state on GraphQL fetch failure\n- Use `cancelled` flag pattern for cleanup (matches existing codebase convention)\n\n### No dependencies to add\nThis hook only uses `fetch()` and React hooks — no new npm packages needed.\n\n### Acceptance criteria\n- [ ] File exists at `hooks/useBeadsComments.ts`\n- [ ] Fetches from Hypergoat GraphQL endpoint\n- [ ] Filters comments to only `beads:*` subject URIs\n- [ ] Groups by issue ID, provides `commentedNodeIds` Set\n- [ ] Resolves commenter profiles (handle, avatar)\n- [ ] Provides `refetch()` method\n- [ ] `pnpm build` passes (even if hook isn't wired up yet — it should have no import errors)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:28.751791+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:02.935547+13:00","closed_at":"2026-02-11T00:44:02.935547+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.2","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:28.754207+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.3","title":"Add right-click handler to BeadsGraph and context menu tooltip component","description":"## Add right-click handler to BeadsGraph and context menu tooltip component\n\n### Goal\nEnable right-clicking on graph nodes to open a beautiful floating comment tooltip. Create the `CommentTooltip` component and wire the right-click event through BeadsGraph to the parent page.\n\n### Part A: Modify `components/BeadsGraph.tsx`\n\n**1. Add `onNodeRightClick` to the props interface** (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n nodes: GraphNode[];\n links: GraphLink[];\n selectedNode: GraphNode | null;\n hoveredNode: GraphNode | null;\n onNodeClick: (node: GraphNode) => void;\n onNodeHover: (node: GraphNode | null) => void;\n onBackgroundClick: () => void;\n onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void; // NEW\n}\n```\n\n**2. Destructure the new prop** in the component function (around line 145):\n```typescript\nconst { nodes, links, selectedNode, hoveredNode, onNodeClick, onNodeHover, onBackgroundClick, onNodeRightClick } = props;\n```\nNote: BeadsGraph uses `forwardRef` — the props are the first argument.\n\n**3. Add `onNodeRightClick` to ForceGraph2D** (around line 1231-1235, after `onNodeClick`):\n```typescript\nonNodeRightClick={(node: any, event: MouseEvent) => {\n event.preventDefault();\n onNodeRightClick?.(node as GraphNode, event);\n}}\n```\nThe `react-force-graph-2d` library supports `onNodeRightClick` as a built-in prop. The `event.preventDefault()` prevents the browser's default context menu.\n\n### Part B: Create `components/CommentTooltip.tsx`\n\n**New file:** `components/CommentTooltip.tsx`\n\nThis is a `'use client'` component that renders an absolutely-positioned floating tooltip near the right-click location.\n\n**Design inspiration:** The tooltip from `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx` (lines 237-274). Key design elements:\n- White background (`#FFFFFF`)\n- Subtle border: `1px solid #E5E7EB`\n- Border radius: `8px`\n- Padding: `18px 20px`\n- Box shadow: `0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08)`\n- Fade-in animation: `0.2s ease` from `opacity: 0; translateY(4px)` to `opacity: 1; translateY(0)`\n- Colored accent bar at top: `width: 24px, height: 2px` using the node's prefix color\n\n**Props:**\n```typescript\ninterface CommentTooltipProps {\n node: GraphNode;\n x: number; // screen X from MouseEvent.clientX\n y: number; // screen Y from MouseEvent.clientY\n onClose: () => void;\n onSubmit: (text: string) => Promise<void>;\n isAuthenticated: boolean;\n existingComments?: BeadsComment[]; // show recent comments in tooltip too\n}\n```\n\n**Layout (top to bottom):**\n1. **Colored accent bar** — 24px wide, 2px tall, using `PREFIX_COLORS[node.prefix]` from `@/lib/types`\n2. **Node info** — ID in mono font (`text-emerald-600`), title in `font-semibold text-sm text-zinc-800`\n3. **Existing comments preview** — if any, show count like '3 comments' as a subtle label, with the most recent 1-2 comments abbreviated\n4. **Textarea** — if authenticated: `<textarea>` with placeholder 'Leave a comment...', 3 rows, matching zinc style. If not authenticated: show `<p>Sign in to comment</p>` with a muted style.\n5. **Action row** — Send button (emerald bg, white text, rounded, small) + Cancel button (text-only, zinc). Send button disabled when textarea empty. Shows spinner during submission.\n\n**Positioning logic:**\n- Position at `(x + 14, y - tooltipHeight - 14)` relative to viewport\n- If overflows right: clamp to `window.innerWidth - tooltipWidth - 16`\n- If overflows left: clamp to `16`\n- If overflows top: flip below cursor at `(x + 14, y + 28)`\n- Use a `useRef` + `useEffect` to measure tooltip dimensions after first render and adjust position (same pattern as plresearch.org Tooltip component, lines 244-256)\n- Fixed positioning (`position: fixed`) since it's relative to the viewport, not a container\n\n**Interaction:**\n- Closes on Escape key (add keydown listener in useEffect)\n- Closes on click outside (add mousedown listener, check if event.target is outside tooltip ref)\n- Auto-focuses the textarea on mount\n- After successful submit: calls `onSubmit(text)`, clears textarea, calls `onClose()`\n\n**Tailwind animation:** Add a CSS class or inline style for the fade-in:\n```css\n@keyframes tooltipFadeIn {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: translateY(0); }\n}\n```\nUse inline style `animation: 'tooltipFadeIn 0.2s ease'` or a Tailwind animate class.\n\n### Acceptance criteria\n- [ ] `BeadsGraphProps` includes `onNodeRightClick`\n- [ ] ForceGraph2D has `onNodeRightClick` handler with `preventDefault()`\n- [ ] `components/CommentTooltip.tsx` exists with the design described above\n- [ ] Tooltip positions near cursor, clamped to viewport\n- [ ] Closes on Escape, click outside\n- [ ] Shows auth-gated textarea vs 'Sign in to comment' message\n- [ ] Send button calls `onSubmit` with text, shows loading state\n- [ ] `pnpm build` passes (component may not be rendered yet — that's task .6)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:39.225841+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.051623+13:00","closed_at":"2026-02-11T00:44:03.051623+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.3","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:39.227376+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.4","title":"Add comment icon badge to nodes with comments in paintNode","description":"## Add comment icon badge to nodes with comments in paintNode\n\n### Goal\nDraw a small speech-bubble comment icon on graph nodes that have ATProto comments. This provides at-a-glance visual feedback about which issues have been discussed.\n\n### What to modify\n**File:** `components/BeadsGraph.tsx`\n\n### Step 1: Add `commentedNodeIds` prop\n\nAdd to `BeadsGraphProps` interface (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n commentedNodeIds?: Set<string>; // NEW — node IDs that have comments\n}\n```\n\nDestructure in the component (around line 145 where other props are destructured):\n```typescript\nconst { ..., commentedNodeIds } = props;\n```\n\n### Step 2: Create a ref for commentedNodeIds\n\nFollowing the established ref pattern in BeadsGraph (lines 181-185 where `selectedNodeRef`, `hoveredNodeRef`, `connectedNodesRef` are declared):\n\n```typescript\nconst commentedNodeIdsRef = useRef<Set<string>>(commentedNodeIds || new Set());\n```\n\nAdd a sync effect (near lines 263-293 where selectedNode/hoveredNode ref syncs happen):\n```typescript\nuseEffect(() => {\n commentedNodeIdsRef.current = commentedNodeIds || new Set();\n // Trigger a canvas redraw so the badge appears/disappears\n refreshGraph(graphRef);\n}, [commentedNodeIds]);\n```\n\n**Why a ref?** The `paintNode` callback has an empty dependency array (`[]`) — it reads all visual state from refs, not props. This avoids recreating the callback and re-rendering ForceGraph2D. This is the same pattern used for `selectedNodeRef`, `hoveredNodeRef`, and `connectedNodesRef` (see lines 181-185, 263-293).\n\n### Step 3: Draw the comment badge in paintNode\n\nIn the `paintNode` callback (lines 456-631), add the badge drawing AFTER the label section (around line 625, before `ctx.restore()` on line 628):\n\n```typescript\n// Comment badge — small speech bubble at top-right of node\nif (commentedNodeIdsRef.current.has(graphNode.id) && globalScale > 0.5) {\n const badgeSize = Math.min(6, Math.max(3, 8 / globalScale));\n // Position at ~45 degrees from center, just outside the node circle\n const badgeX = node.x + animatedSize * 0.7;\n const badgeY = node.y - animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = opacity * 0.85;\n\n // Speech bubble body (rounded rect)\n const bw = badgeSize * 1.6; // bubble width\n const bh = badgeSize * 1.2; // bubble height\n const br = badgeSize * 0.3; // border radius\n ctx.beginPath();\n ctx.moveTo(badgeX - bw/2 + br, badgeY - bh/2);\n ctx.lineTo(badgeX + bw/2 - br, badgeY - bh/2);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY - bh/2, badgeX + bw/2, badgeY - bh/2 + br);\n ctx.lineTo(badgeX + bw/2, badgeY + bh/2 - br);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY + bh/2, badgeX + bw/2 - br, badgeY + bh/2);\n // Small triangle pointer at bottom-left\n ctx.lineTo(badgeX - bw/4, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/3, badgeY + bh/2 + badgeSize * 0.4);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY + bh/2, badgeX - bw/2, badgeY + bh/2 - br);\n ctx.lineTo(badgeX - bw/2, badgeY - bh/2 + br);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY - bh/2, badgeX - bw/2 + br, badgeY - bh/2);\n ctx.closePath();\n\n ctx.fillStyle = '#3b82f6'; // blue-500\n ctx.fill();\n\n ctx.restore();\n}\n```\n\nThe exact canvas drawing can be simplified/refined — the key requirements are:\n- Small speech-bubble shape (recognizable as a comment icon)\n- Positioned at top-right of the node circle\n- Blue fill (`#3b82f6`) at ~0.85 opacity\n- Only drawn when `globalScale > 0.5` (same threshold as labels on line 598)\n- Scales with zoom level like other indicators\n\n### Acceptance criteria\n- [ ] `commentedNodeIds` prop added to `BeadsGraphProps`\n- [ ] Ref created and synced with effect + `refreshGraph()` call\n- [ ] Speech bubble badge drawn in `paintNode` for nodes in the set\n- [ ] Badge scales with zoom, only visible at reasonable zoom levels\n- [ ] No ForceGraph re-render triggered (ref pattern maintained)\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:47.743964+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.169692+13:00","closed_at":"2026-02-11T00:44:03.169692+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:47.745514+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.253835+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.5","title":"Add comment section to NodeDetail panel","description":"## Add comment section to NodeDetail panel\n\n### Goal\nAdd a 'Comments' section at the bottom of the NodeDetail sidebar panel that shows existing ATProto comments for the selected node and provides an inline compose area for authenticated users.\n\n### What to modify\n**File:** `components/NodeDetail.tsx`\n\n### Current file structure (304 lines)\n- Line 1-18: imports and props interface\n- Line 20-247: main `NodeDetail` component\n - Line 25-46: null state (no node selected)\n - Line 48-245: node detail rendering\n - Lines 229-245: 'Blocked by' section (LAST section before closing div)\n - Line 246: closing `</div>`\n- Lines 250-298: helper components (`MetricCard`, `DependencyLink`)\n- Lines 300-303: `truncateDescription`\n\n### Changes needed\n\n**1. Expand the props interface** (line 14-18):\n```typescript\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // NEW import\n\ninterface NodeDetailProps {\n node: GraphNode | null;\n allNodes: GraphNode[];\n onNodeNavigate: (nodeId: string) => void;\n comments?: BeadsComment[]; // NEW — comments for selected node\n onPostComment?: (text: string) => Promise<void>; // NEW — submit callback\n isAuthenticated?: boolean; // NEW — auth state for compose area\n}\n```\n\n**2. Destructure new props** (line 20-24):\n```typescript\nexport default function NodeDetail({\n node, allNodes, onNodeNavigate, comments, onPostComment, isAuthenticated,\n}: NodeDetailProps) {\n```\n\n**3. Add Comments section** (after the 'Blocked by' section, around line 245, before the closing `</div>`):\n\n```tsx\n{/* Comments */}\n<div className=\"mb-4\">\n <h4 className=\"text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2\">\n Comments {comments && comments.length > 0 && (\n <span className=\"ml-1 px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full text-[10px] font-medium\">\n {comments.length}\n </span>\n )}\n </h4>\n\n {/* Comment list */}\n {comments && comments.length > 0 ? (\n <div className=\"space-y-3\">\n {comments.map((comment) => (\n <CommentItem key={comment.uri} comment={comment} />\n ))}\n </div>\n ) : (\n <p className=\"text-xs text-zinc-400 italic\">No comments yet</p>\n )}\n\n {/* Compose area */}\n {isAuthenticated && onPostComment ? (\n <CommentCompose onSubmit={onPostComment} />\n ) : !isAuthenticated ? (\n <p className=\"text-xs text-zinc-400 mt-2\">Sign in to leave a comment</p>\n ) : null}\n</div>\n```\n\n**4. Create helper sub-components** (after `DependencyLink`, before `truncateDescription`):\n\n**CommentItem** — displays a single comment:\n```tsx\nfunction CommentItem({ comment }: { comment: BeadsComment }) {\n return (\n <div className=\"flex gap-2\">\n {/* Avatar */}\n <div className=\"shrink-0 w-6 h-6 rounded-full bg-zinc-100 overflow-hidden\">\n {comment.avatar ? (\n <img src={comment.avatar} alt=\"\" className=\"w-full h-full object-cover\" />\n ) : (\n <div className=\"w-full h-full flex items-center justify-center text-[10px] font-medium text-zinc-400\">\n {(comment.handle || comment.did).charAt(0).toUpperCase()}\n </div>\n )}\n </div>\n {/* Content */}\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-xs font-medium text-zinc-600 truncate\">\n {comment.displayName || comment.handle || comment.did.slice(0, 16) + '...'}\n </span>\n <span className=\"text-[10px] text-zinc-300 shrink-0\">\n {formatRelativeTime(comment.createdAt)}\n </span>\n </div>\n <p className=\"text-xs text-zinc-500 mt-0.5 whitespace-pre-wrap break-words\">{comment.text}</p>\n </div>\n </div>\n );\n}\n```\n\n**CommentCompose** — inline textarea + send button:\n```tsx\nfunction CommentCompose({ onSubmit }: { onSubmit: (text: string) => Promise<void> }) {\n const [text, setText] = useState('');\n const [sending, setSending] = useState(false);\n\n const handleSubmit = async () => {\n if (!text.trim() || sending) return;\n setSending(true);\n try {\n await onSubmit(text.trim());\n setText('');\n } catch (err) {\n console.error('Failed to post comment:', err);\n } finally {\n setSending(false);\n }\n };\n\n return (\n <div className=\"mt-3 space-y-2\">\n <textarea\n value={text}\n onChange={(e) => setText(e.target.value)}\n placeholder=\"Leave a comment...\"\n rows={2}\n className=\"w-full px-2.5 py-1.5 text-xs border border-zinc-200 rounded-md bg-zinc-50 text-zinc-700 placeholder-zinc-400 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500\"\n />\n <button\n onClick={handleSubmit}\n disabled={!text.trim() || sending}\n className=\"px-3 py-1 text-xs font-medium text-white bg-emerald-500 rounded-md hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {sending ? 'Sending...' : 'Comment'}\n </button>\n </div>\n );\n}\n```\n\n**formatRelativeTime** helper:\n```typescript\nfunction formatRelativeTime(isoString: string): string {\n const date = new Date(isoString);\n const now = new Date();\n const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);\n if (seconds < 60) return 'just now';\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return minutes + 'm ago';\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return hours + 'h ago';\n const days = Math.floor(hours / 24);\n if (days < 7) return days + 'd ago';\n return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\n}\n```\n\n**5. Add `useState` import** — needed for `CommentCompose`. Add to the existing React import at line 1 or import separately.\n\n### Styling notes\n- Matches existing NodeDetail style: `text-xs`, zinc palette, `mb-4` section spacing\n- Avatar uses plain `<img>` (not `next/image`) — consistent with AuthButton.tsx pattern\n- Count badge uses blue accent to match the comment badge on the graph nodes\n- Compose area uses emerald accent for the submit button (matches the app's primary color)\n\n### Acceptance criteria\n- [ ] 'Comments' section appears after 'Blocked by' in NodeDetail\n- [ ] Shows comment count badge when comments exist\n- [ ] Each comment shows avatar, handle/name, relative time, text\n- [ ] Empty state shows 'No comments yet' placeholder\n- [ ] Authenticated users see compose textarea + submit button\n- [ ] Unauthenticated users see 'Sign in to leave a comment'\n- [ ] Submit clears textarea and calls `onPostComment`\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:54.777115+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.284193+13:00","closed_at":"2026-02-11T00:44:03.284193+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:54.778714+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.395175+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.6","title":"Wire everything together in page.tsx and build verification","description":"## Wire everything together in page.tsx and build verification\n\n### Goal\nConnect all the pieces created in tasks .1-.5 in the main `app/page.tsx` orchestration file. This is the final integration task.\n\n### What to modify\n**File:** `app/page.tsx` (currently 811 lines)\n\n### Current file structure (key sections)\n- Line 1: `'use client'`\n- Lines 3-12: imports\n- Lines 14-67: helper functions (`findNeighborPosition`, etc.)\n- Lines 69-811: main `Home` component\n - Lines 73-90: state declarations\n - Lines 175-250: SSE/fetch data loading\n - Lines 280-320: event handlers (`handleNodeClick`, `handleNodeHover`, etc.)\n - Lines 680-695: BeadsGraph rendering\n - Lines 697-765: Desktop sidebar with NodeDetail\n - Lines 767-806: Mobile drawer with NodeDetail\n\n### Step 1: Add imports (near lines 3-12)\n\n```typescript\nimport { CommentTooltip } from '@/components/CommentTooltip'; // task .3\nimport { useBeadsComments } from '@/hooks/useBeadsComments'; // task .2\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // task .2\nimport { useAuth } from '@/lib/auth'; // already importable\n```\n\n### Step 2: Add state and hooks (near lines 73-90, after existing state declarations)\n\n```typescript\n// Auth state\nconst { isAuthenticated, session } = useAuth();\n\n// Comments from ATProto indexer\nconst { commentsByNode, commentedNodeIds, refetch: refetchComments } = useBeadsComments();\n\n// Context menu state for right-click tooltip\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n### Step 3: Create event handlers (near lines 280-320)\n\n**Right-click handler:**\n```typescript\nconst handleNodeRightClick = useCallback((node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n}, []);\n```\n\n**Post comment callback:**\n```typescript\nconst handlePostComment = useCallback(async (nodeId: string, text: string) => {\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n $type: 'org.impactindexer.review.comment',\n subject: {\n uri: `beads:${nodeId}`,\n type: 'record',\n },\n text,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n\n if (!response.ok) {\n const data = await response.json();\n throw new Error(data.error || 'Failed to post comment');\n }\n\n // Refetch comments to update the UI\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 4: Pass props to BeadsGraph (around lines 680-695)\n\nAdd the new props to the `<BeadsGraph>` component:\n```tsx\n<BeadsGraph\n ref={graphRef}\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n selectedNode={selectedNode}\n hoveredNode={hoveredNode}\n onNodeClick={handleNodeClick}\n onNodeHover={handleNodeHover}\n onBackgroundClick={handleBackgroundClick}\n onNodeRightClick={handleNodeRightClick} // NEW\n commentedNodeIds={commentedNodeIds} // NEW\n/>\n```\n\n### Step 5: Pass props to NodeDetail (desktop sidebar, around line 733-737)\n\n```tsx\n<NodeDetail\n node={selectedNode}\n allNodes={data.graphData.nodes}\n onNodeNavigate={handleNodeNavigate}\n comments={selectedNode ? commentsByNode.get(selectedNode.id) : undefined} // NEW\n onPostComment={selectedNode ? (text: string) => handlePostComment(selectedNode.id, text) : undefined} // NEW\n isAuthenticated={isAuthenticated} // NEW\n/>\n```\n\nDo the same for the mobile drawer NodeDetail (around line 799-803).\n\n### Step 6: Render CommentTooltip (after BeadsGraph, before the sidebar, around line 695)\n\n```tsx\n{/* Right-click comment tooltip */}\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n### Step 7: Close tooltip on background click\n\nModify `handleBackgroundClick` to also close the context menu:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null); // NEW — close tooltip too\n}, []);\n```\n\n### Step 8: Build and fix errors\n\nRun `pnpm build` and fix any type errors. Common issues to watch for:\n- Import path typos\n- Missing exports (e.g., `BeadsComment` type not exported from hook)\n- `useAuth` must be called inside `AuthProvider` (it already is — layout.tsx wraps children)\n- The `CommentTooltip` component must be exported as named export (check consistency with import)\n\n### Acceptance criteria\n- [ ] `useBeadsComments` hook called at top level of Home component\n- [ ] `useAuth` provides `isAuthenticated` state\n- [ ] `contextMenu` state manages right-click tooltip position + node\n- [ ] `handleNodeRightClick` creates context menu state\n- [ ] `handlePostComment` POSTs to `/api/records` with correct record shape\n- [ ] BeadsGraph receives `onNodeRightClick` and `commentedNodeIds`\n- [ ] Both desktop and mobile NodeDetail receive comments + postComment + isAuthenticated\n- [ ] CommentTooltip renders when `contextMenu` is set\n- [ ] Background click closes both selection and context menu\n- [ ] `pnpm build` passes with zero errors\n- [ ] All auth API routes visible in build output (`ƒ /api/records`, etc.)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:32:01.724819+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.398953+13:00","closed_at":"2026-02-11T00:44:03.398953+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:32:01.725925+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.1","type":"blocks","created_at":"2026-02-11T00:38:43.522633+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.647344+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.3","type":"blocks","created_at":"2026-02-11T00:38:43.773371+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.4","type":"blocks","created_at":"2026-02-11T00:38:43.895718+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.5","type":"blocks","created_at":"2026-02-11T00:38:44.013093+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.7","title":"Add delete button for own comments","description":"## Add delete button for own comments\n\n### Goal\nAllow authenticated users to delete comments they have authored. Show a small trash/X icon on comments where the logged-in user's DID matches the comment's DID. Clicking it calls DELETE /api/records to remove the record from their PDS, then refetches comments.\n\n### What to modify\n\n#### 1. `components/NodeDetail.tsx` — CommentItem sub-component\n\nAdd a delete button that appears only when the comment's `did` matches the current user's DID.\n\n**Props change for CommentItem:**\n```typescript\nfunction CommentItem({ comment, currentDid, onDelete }: {\n comment: BeadsComment;\n currentDid?: string;\n onDelete?: (comment: BeadsComment) => Promise<void>;\n})\n```\n\n**UI:** A small X or trash icon button, only visible when `currentDid === comment.did`. Positioned at the top-right of the comment row. On hover, it becomes visible (use `group` + `group-hover:opacity-100` pattern or always-visible is fine for simplicity). Shows a confirmation or just deletes immediately. While deleting, show a subtle spinner or disabled state.\n\n**Delete call pattern** (from Hyperscan `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`):\n```typescript\n// The rkey is extracted from the comment's AT-URI: at://did/collection/rkey\n// comment.rkey is already available in BeadsComment\nawait fetch(`/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`, {\n method: 'DELETE',\n});\n```\n\n#### 2. `components/NodeDetail.tsx` — NodeDetailProps\n\nAdd `currentDid` to props:\n```typescript\ninterface NodeDetailProps {\n // ... existing props ...\n currentDid?: string; // NEW — the authenticated user's DID for ownership checks\n}\n```\n\n#### 3. `components/CommentTooltip.tsx` — existing comments preview\n\nOptionally add delete to the tooltip preview too, or skip for simplicity (tooltip is compact). Recommended: skip delete in tooltip, only in NodeDetail.\n\n#### 4. `app/page.tsx` — pass currentDid and onDeleteComment\n\nPass `session?.did` as `currentDid` to both desktop and mobile `<NodeDetail>` instances.\n\nCreate a `handleDeleteComment` callback:\n```typescript\nconst handleDeleteComment = useCallback(async (comment: BeadsComment) => {\n const response = await fetch(\n `/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`,\n { method: 'DELETE' }\n );\n if (!response.ok) {\n const errData = await response.json();\n throw new Error(errData.error || 'Failed to delete comment');\n }\n await refetchComments();\n}, [refetchComments]);\n```\n\nPass it to NodeDetail:\n```tsx\n<NodeDetail\n ...existing props...\n currentDid={session?.did}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n#### 5. `components/NodeDetail.tsx` — wire onDeleteComment\n\nAdd to NodeDetailProps:\n```typescript\nonDeleteComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nPass to CommentItem:\n```tsx\n<CommentItem\n key={comment.uri}\n comment={comment}\n currentDid={currentDid}\n onDelete={onDeleteComment}\n/>\n```\n\n### Existing infrastructure\n- `DELETE /api/records?collection=...&rkey=...` already exists (created in beads-map-dyi.1)\n- `BeadsComment` type has `rkey` and `did` fields (from useBeadsComments hook)\n- `useAuth()` provides `session.did` (from lib/auth.tsx)\n- `refetchComments()` from `useBeadsComments` hook refreshes the comment list\n\n### Acceptance criteria\n- [ ] Delete icon/button appears only on comments authored by the current user\n- [ ] Clicking delete calls `DELETE /api/records` with correct collection and rkey\n- [ ] After successful delete, comments list refreshes automatically\n- [ ] Shows loading/disabled state during deletion\n- [ ] `pnpm build` passes with zero errors","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:45:37.231167+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:47:32.38037+13:00","closed_at":"2026-02-11T00:47:32.38037+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.7","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:45:37.232842+13:00","created_by":"daviddao"}]},{"id":"beads-map-ecl","title":"Wire EventSource in page.tsx with merge logic","description":"Modify: app/page.tsx\n\nPURPOSE: Replace the one-shot fetch(\"/api/beads\") with an EventSource connected to /api/beads/stream. On each SSE message, diff the new data against current state, stamp animation metadata, and update React state. This is the central coordination point where server data meets client state.\n\nCHANGES TO page.tsx:\n\n1. ADD IMPORTS at top:\n import { diffBeadsData, linkKey } from \"@/lib/diff-beads\";\n import type { BeadsDiff } from \"@/lib/diff-beads\";\n\n2. ADD a ref to track the previous data for diffing:\n const prevDataRef = useRef<BeadsApiResponse | null>(null);\n\n3. REPLACE the existing fetch useEffect (lines 38-53) with EventSource logic:\n\n```typescript\n // Live-streaming beads data via SSE\n useEffect(() => {\n let eventSource: EventSource | null = null;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n function connect() {\n eventSource = new EventSource(\"/api/beads/stream\");\n\n eventSource.onmessage = (event) => {\n try {\n const newData = JSON.parse(event.data) as BeadsApiResponse;\n if ((newData as any).error) {\n setError((newData as any).error);\n setLoading(false);\n return;\n }\n\n const oldData = prevDataRef.current;\n const diff = diffBeadsData(oldData, newData);\n\n if (!oldData) {\n // Initial load — no animations, just set data\n prevDataRef.current = newData;\n setData(newData);\n setLoading(false);\n return;\n }\n\n if (!diff.hasChanges) return; // No-op if nothing changed\n\n // Merge: stamp animation metadata and preserve positions\n const mergedData = mergeBeadsData(oldData, newData, diff);\n prevDataRef.current = mergedData;\n setData(mergedData);\n } catch (err) {\n console.error(\"Failed to parse SSE message:\", err);\n }\n };\n\n eventSource.onerror = () => {\n // EventSource auto-reconnects, but we handle the gap\n if (eventSource?.readyState === EventSource.CLOSED) {\n // Permanent failure — try manual reconnect after delay\n reconnectTimer = setTimeout(connect, 5000);\n }\n };\n\n // If still loading after 5s, fall back to one-shot fetch\n setTimeout(() => {\n if (loading) {\n fetch(\"/api/beads\")\n .then(res => res.json())\n .then(data => {\n if (!prevDataRef.current) {\n prevDataRef.current = data;\n setData(data);\n setLoading(false);\n }\n })\n .catch(() => {});\n }\n }, 5000);\n }\n\n connect();\n\n return () => {\n eventSource?.close();\n if (reconnectTimer) clearTimeout(reconnectTimer);\n };\n }, []);\n```\n\n4. ADD the mergeBeadsData function (above the component or as a module-level function):\n\n```typescript\nfunction mergeBeadsData(\n oldData: BeadsApiResponse,\n newData: BeadsApiResponse,\n diff: BeadsDiff\n): BeadsApiResponse {\n const now = Date.now();\n\n // Build position map from old nodes (preserves x/y/fx/fy from simulation)\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));\n\n // Merge nodes: carry over positions, stamp animation metadata\n const mergedNodes = newData.graphData.nodes.map(node => {\n const oldNode = oldNodeMap.get(node.id);\n\n if (!oldNode) {\n // NEW NODE — stamp spawn time, place near a connected neighbor\n const neighbor = findNeighborPosition(node.id, newData.graphData.links, oldNodeMap);\n return {\n ...node,\n _spawnTime: now,\n x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,\n y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,\n };\n }\n\n // EXISTING NODE — preserve position, check for changes\n const merged = {\n ...node,\n x: oldNode.x,\n y: oldNode.y,\n fx: oldNode.fx,\n fy: oldNode.fy,\n };\n\n // Stamp change metadata if status changed\n if (diff.changedNodes.has(node.id)) {\n const changes = diff.changedNodes.get(node.id)!;\n const statusChange = changes.find(c => c.field === \"status\");\n if (statusChange) {\n merged._changedAt = now;\n merged._prevStatus = statusChange.from;\n }\n }\n\n return merged;\n });\n\n // Handle removed nodes: keep them briefly for exit animation\n for (const removedId of diff.removedNodeIds) {\n const oldNode = oldNodeMap.get(removedId);\n if (oldNode) {\n mergedNodes.push({\n ...oldNode,\n _removeTime: now,\n });\n }\n }\n\n // Merge links: stamp spawn time on new links\n const mergedLinks = newData.graphData.links.map(link => {\n const key = linkKey(link);\n if (!oldLinkKeySet.has(key)) {\n return { ...link, _spawnTime: now };\n }\n return link;\n });\n\n // Handle removed links: keep briefly for exit animation\n for (const removedKey of diff.removedLinkKeys) {\n const oldLink = oldData.graphData.links.find(l => linkKey(l) === removedKey);\n if (oldLink) {\n mergedLinks.push({\n source: typeof oldLink.source === \"object\" ? (oldLink.source as any).id : oldLink.source,\n target: typeof oldLink.target === \"object\" ? (oldLink.target as any).id : oldLink.target,\n type: oldLink.type,\n _removeTime: now,\n });\n }\n }\n\n return {\n ...newData,\n graphData: {\n nodes: mergedNodes as any,\n links: mergedLinks as any,\n },\n };\n}\n\n// Find position of a neighbor node (for placing new nodes near connections)\nfunction findNeighborPosition(\n nodeId: string,\n links: GraphLink[],\n nodeMap: Map<string, GraphNode>\n): { x: number; y: number } | null {\n for (const link of links) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n if (src === nodeId && nodeMap.has(tgt)) {\n const n = nodeMap.get(tgt)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n if (tgt === nodeId && nodeMap.has(src)) {\n const n = nodeMap.get(src)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n }\n return null;\n}\n```\n\n5. ADD cleanup of expired animation items.\n After a timeout, remove nodes/links that have _removeTime older than 600ms:\n\n```typescript\n // Clean up expired exit animations\n useEffect(() => {\n if (!data) return;\n const timer = setTimeout(() => {\n const now = Date.now();\n const EXPIRE_MS = 600;\n const nodes = data.graphData.nodes.filter(\n n => !n._removeTime || now - n._removeTime < EXPIRE_MS\n );\n const links = data.graphData.links.filter(\n l => !(l as any)._removeTime || now - (l as any)._removeTime < EXPIRE_MS\n );\n if (nodes.length !== data.graphData.nodes.length || links.length !== data.graphData.links.length) {\n setData(prev => prev ? {\n ...prev,\n graphData: { nodes, links },\n } : prev);\n }\n }, 700); // slightly after animation duration\n return () => clearTimeout(timer);\n }, [data]);\n```\n\n6. KEEP the existing /api/config fetch useEffect unchanged.\n\n7. UPDATE the stats display in the header to exclude nodes/links with _removeTime (so counts reflect real data, not animated ghosts).\n\nWHY FULL MERGE IN page.tsx:\nThe merge logic lives here because it's where we have access to both the old React state (with simulation positions) and the new server data. BeadsGraph.tsx just receives nodes/links props and renders — it doesn't need to know about the merge.\n\nPOSITION PRESERVATION IS CRITICAL:\nreact-force-graph-2d mutates node objects in-place, setting x/y/vx/vy during simulation. If we replace nodes with fresh objects from the server (which have no x/y), the entire graph layout resets. The mergeBeadsData function copies x/y/fx/fy from old nodes to preserve positions.\n\nDEPENDS ON: task .3 (SSE endpoint), task .4 (diff-beads.ts)\n\nACCEPTANCE CRITERIA:\n- EventSource connects to /api/beads/stream on mount\n- Initial data loads correctly (same as before)\n- When JSONL changes, new data streams in and state updates\n- New nodes get _spawnTime stamped\n- Changed nodes get _changedAt + _prevStatus stamped\n- Removed nodes/links kept briefly with _removeTime for exit animation\n- Existing node positions preserved across updates\n- New nodes placed near their connected neighbors\n- Expired animation items cleaned up after 600ms\n- Fallback to one-shot fetch if SSE fails after 5s\n- EventSource cleaned up on unmount\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:17:01.615466+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:36:14.609896+13:00","closed_at":"2026-02-10T23:36:14.609896+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.476318+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-7j2","type":"blocks","created_at":"2026-02-10T23:19:29.07598+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-2fk","type":"blocks","created_at":"2026-02-10T23:19:29.155362+13:00","created_by":"daviddao"}]},{"id":"beads-map-gjo","title":"Add animation timestamp fields to types + export getAdditionalRepoPaths","description":"Foundation task: add animation metadata fields to GraphNode/GraphLink types and export a currently-private function from parse-beads.ts.\n\nFILE 1: lib/types.ts\n\nAdd optional animation timestamp fields to GraphNode interface (after the fx/fy fields, around line 61):\n\n // Animation metadata (set by live-update merge logic, consumed by paintNode)\n _spawnTime?: number; // Date.now() when this node first appeared (for pop-in animation)\n _removeTime?: number; // Date.now() when this node was marked for removal (for shrink-out)\n _changedAt?: number; // Date.now() when status/priority changed (for ripple animation)\n _prevStatus?: string; // Previous status value before the change (for color transition)\n\nAdd optional animation timestamp fields to GraphLink interface (after the type field, around line 67):\n\n // Animation metadata (set by live-update merge logic, consumed by paintLink)\n _spawnTime?: number; // Date.now() when this link first appeared (for fade-in animation)\n _removeTime?: number; // Date.now() when this link was marked for removal (for fade-out)\n\nIMPORTANT: These fields use the underscore prefix convention to signal they are transient metadata not persisted to JSONL. They are set by the merge logic in page.tsx and consumed by paintNode/paintLink in BeadsGraph.tsx.\n\nIMPORTANT: GraphNode has an index signature [key: string]: unknown at line 37. The new fields must be declared as optional properties within the interface body (not via the index signature) so TypeScript knows their types.\n\nFILE 2: lib/parse-beads.ts\n\nThe function getAdditionalRepoPaths(beadsDir: string): string[] at line 26 is currently private (no export keyword). Change it to:\n\n export function getAdditionalRepoPaths(beadsDir: string): string[]\n\nThis is needed by lib/watch-beads.ts (task .2) to discover which JSONL files to watch.\n\nNo other changes to parse-beads.ts.\n\nACCEPTANCE CRITERIA:\n- GraphNode has _spawnTime, _removeTime, _changedAt, _prevStatus optional fields\n- GraphLink has _spawnTime, _removeTime optional fields\n- getAdditionalRepoPaths is exported from parse-beads.ts\n- pnpm build passes with zero errors\n- No runtime behavior changes (animation fields are just type declarations, unused until task .5/.6/.7)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:15:11.332936+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:24:57.300177+13:00","closed_at":"2026-02-10T23:24:57.300177+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-gjo","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.148777+13:00","created_by":"daviddao"}]},{"id":"beads-map-iyn","title":"Add spawn/exit/change animations to paintNode","description":"Modify: components/BeadsGraph.tsx — paintNode() callback\n\nPURPOSE: Animate nodes based on the _spawnTime, _removeTime, and _changedAt timestamps set by the merge logic (task .5). New nodes pop in with a bouncy scale-up, removed nodes shrink out, and status-changed nodes flash a ripple effect.\n\nCHANGES TO paintNode (currently at line ~435, inside the useCallback):\n\n1. ADD EASING FUNCTIONS (above the component, near the helper functions around line 50):\n\n```typescript\n// Animation duration constants\nconst SPAWN_DURATION = 500; // ms for pop-in animation\nconst REMOVE_DURATION = 400; // ms for shrink-out animation\nconst CHANGE_DURATION = 800; // ms for status change ripple\n\n/**\n * easeOutBack: overshoots slightly then settles — gives \"pop\" feel.\n * t is 0..1, returns 0..~1.05 (overshoots before settling at 1)\n */\nfunction easeOutBack(t: number): number {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n}\n\n/**\n * easeOutQuad: smooth deceleration\n */\nfunction easeOutQuad(t: number): number {\n return 1 - (1 - t) * (1 - t);\n}\n```\n\n2. MODIFY paintNode() to add animation effects:\n\nAt the BEGINNING of paintNode, before any drawing, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (pop-in) ---\n let spawnScale = 1;\n const spawnTime = (graphNode as any)._spawnTime as number | undefined;\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n spawnScale = easeOutBack(elapsed / SPAWN_DURATION);\n }\n // After animation completes, _spawnTime is ignored (scale stays 1)\n }\n\n // --- Remove animation (shrink-out) ---\n let removeScale = 1;\n let removeOpacity = 1;\n const removeTime = (graphNode as any)._removeTime as number | undefined;\n if (removeTime) {\n const elapsed = now - removeTime;\n if (elapsed < REMOVE_DURATION) {\n const progress = elapsed / REMOVE_DURATION;\n removeScale = 1 - easeOutQuad(progress);\n removeOpacity = 1 - progress;\n } else {\n removeScale = 0; // fully gone\n removeOpacity = 0;\n }\n }\n\n const animScale = spawnScale * removeScale;\n if (animScale <= 0.01) return; // skip drawing invisible nodes\n\n const animatedSize = size * animScale;\n```\n\nREPLACE all references to `size` in the drawing code with `animatedSize`:\n- ctx.arc(node.x, node.y, size + 2, ...) → ctx.arc(node.x, node.y, animatedSize + 2, ...)\n- ctx.arc(node.x, node.y, size, ...) → ctx.arc(node.x, node.y, animatedSize, ...)\n- node.y + size + 3 → node.y + animatedSize + 3\n- node.y - size - 2 → node.y - animatedSize - 2\n\nAlso multiply the base opacity by removeOpacity:\n- ctx.globalAlpha = opacity → ctx.globalAlpha = opacity * removeOpacity\n\n3. ADD STATUS CHANGE RIPPLE after drawing the node body but before the label:\n\n```typescript\n // --- Status change ripple animation ---\n const changedAt = (graphNode as any)._changedAt as number | undefined;\n if (changedAt) {\n const elapsed = now - changedAt;\n if (elapsed < CHANGE_DURATION) {\n const progress = elapsed / CHANGE_DURATION;\n const rippleRadius = animatedSize + 4 + progress * 20;\n const rippleOpacity = (1 - progress) * 0.6;\n const newStatusColor = STATUS_COLORS[graphNode.status] || \"#a1a1aa\";\n\n ctx.beginPath();\n ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);\n ctx.strokeStyle = newStatusColor;\n ctx.lineWidth = 2 * (1 - progress);\n ctx.globalAlpha = rippleOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n4. ADD SPAWN GLOW: during the spawn animation, add a brief emerald glow ring:\n\n```typescript\n // --- Spawn glow ---\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n const glowProgress = elapsed / SPAWN_DURATION;\n const glowOpacity = (1 - glowProgress) * 0.4;\n const glowRadius = animatedSize + 6 + glowProgress * 8;\n ctx.beginPath();\n ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);\n ctx.strokeStyle = \"#10b981\";\n ctx.lineWidth = 3 * (1 - glowProgress);\n ctx.globalAlpha = glowOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n5. ADD CONTINUOUS REDRAW during active animations.\n\nThe canvas only redraws when the force simulation is active or when React state changes. During animations, we need continuous redraws. Add a useEffect that requests animation frames while animations are active:\n\n```typescript\n // Drive continuous canvas redraws during active animations\n useEffect(() => {\n let rafId: number;\n let active = true;\n\n function tick() {\n if (!active) return;\n const now = Date.now();\n const hasActiveAnimations = viewNodes.some((n: any) => {\n if (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) return true;\n if (n._removeTime && now - n._removeTime < REMOVE_DURATION) return true;\n if (n._changedAt && now - n._changedAt < CHANGE_DURATION) return true;\n return false;\n }) || viewLinks.some((l: any) => {\n if (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) return true;\n if (l._removeTime && now - l._removeTime < REMOVE_DURATION) return true;\n return false;\n });\n\n if (hasActiveAnimations) {\n refreshGraph(graphRef);\n }\n rafId = requestAnimationFrame(tick);\n }\n\n tick();\n return () => { active = false; cancelAnimationFrame(rafId); };\n }, [viewNodes, viewLinks]);\n```\n\nIMPORTANT: refreshGraph() already exists at line ~105 — it does an imperceptible zoom jitter to force canvas redraw. This is the exact right mechanism for animation frames.\n\nIMPORTANT: The paintNode callback has [] (empty) dependency array. This is correct and must NOT change — it reads from refs, not props. The animation timestamps are on the node objects themselves (passed as the first argument to paintNode by react-force-graph), so they're always current.\n\nDEPENDS ON: task .5 (page.tsx must stamp _spawnTime/_removeTime/_changedAt on nodes)\n\nACCEPTANCE CRITERIA:\n- New nodes pop in with easeOutBack scale animation (500ms)\n- New nodes show brief emerald glow ring during spawn\n- Removed nodes shrink to zero with fade-out (400ms)\n- Status-changed nodes show expanding ripple ring in new status color (800ms)\n- Animations are smooth (requestAnimationFrame drives redraws)\n- No visual glitches when multiple animations overlap\n- Non-animated nodes render identically to before (no regression)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:17:37.790522+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:39:22.776735+13:00","closed_at":"2026-02-10T23:39:22.776735+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.553429+13:00","created_by":"daviddao"},{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-ecl","type":"blocks","created_at":"2026-02-10T23:19:29.234083+13:00","created_by":"daviddao"}]},{"id":"beads-map-m1o","title":"Create lib/watch-beads.ts — file watcher with debounce","description":"Create a new file: lib/watch-beads.ts\n\nPURPOSE: Watch all issues.jsonl files (primary + additional repos from config.yaml) for changes using Node.js fs.watch(). When any file changes, fire a debounced callback. This is the server-side foundation for the SSE endpoint (task .3).\n\nINTERFACE:\n```typescript\n/**\n * Watch all issues.jsonl files for a beads project.\n * Discovers files from the primary .beads dir and config.yaml repos.additional.\n * Debounces rapid changes (bd often writes multiple times per command).\n *\n * @param beadsDir - Absolute path to the primary .beads/ directory\n * @param onChange - Callback fired when any watched file changes (after debounce)\n * @param debounceMs - Debounce interval in milliseconds (default: 300)\n * @returns Cleanup function that closes all watchers\n */\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs?: number\n): () => void;\n\n/**\n * Get all issues.jsonl file paths that should be watched.\n * Returns the primary path plus any additional repo paths from config.yaml.\n */\nexport function getWatchPaths(beadsDir: string): string[];\n```\n\nIMPLEMENTATION:\n\n```typescript\nimport { watch, existsSync } from \"fs\";\nimport { join } from \"path\";\nimport { getAdditionalRepoPaths } from \"./parse-beads\";\n\nexport function getWatchPaths(beadsDir: string): string[] {\n const paths: string[] = [];\n\n // Primary JSONL\n const primary = join(beadsDir, \"issues.jsonl\");\n if (existsSync(primary)) paths.push(primary);\n\n // Additional repo JSONLs\n const additionalRepos = getAdditionalRepoPaths(beadsDir);\n for (const repoPath of additionalRepos) {\n const jsonlPath = join(repoPath, \".beads\", \"issues.jsonl\");\n if (existsSync(jsonlPath)) paths.push(jsonlPath);\n }\n\n return paths;\n}\n\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs = 300\n): () => void {\n const paths = getWatchPaths(beadsDir);\n let timer: ReturnType<typeof setTimeout> | null = null;\n const watchers: ReturnType<typeof watch>[] = [];\n\n const debouncedOnChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(onChange, debounceMs);\n };\n\n for (const filePath of paths) {\n try {\n const watcher = watch(filePath, { persistent: false }, (eventType) => {\n if (eventType === \"change\") {\n debouncedOnChange();\n }\n });\n watchers.push(watcher);\n } catch (err) {\n console.warn(`Failed to watch ${filePath}:`, err);\n }\n }\n\n if (paths.length === 0) {\n console.warn(\"No issues.jsonl files found to watch\");\n }\n\n // Return cleanup function\n return () => {\n if (timer) clearTimeout(timer);\n for (const w of watchers) {\n w.close();\n }\n };\n}\n```\n\nKEY DESIGN DECISIONS:\n- persistent: false — so the watcher doesn't prevent Node.js from exiting\n- Only watches for \"change\" events (not \"rename\") since bd writes in-place\n- 300ms debounce: bd typically does flush→sync→write in rapid succession\n- If a watched file disappears (repo deleted), the watcher silently dies — acceptable\n\nEDGE CASES:\n- No additional repos: only watches primary issues.jsonl\n- Empty project (no issues.jsonl yet): returns empty paths array, logs warning\n- File deleted while watching: fs.watch fires an event, but next re-parse returns empty — handled gracefully by parse-beads.ts\n\nDEPENDS ON: task .1 (getAdditionalRepoPaths must be exported from parse-beads.ts)\n\nACCEPTANCE CRITERIA:\n- lib/watch-beads.ts exports watchBeadsFiles and getWatchPaths\n- Debounces rapid changes correctly (only one onChange call per burst)\n- Watches all JSONL files (primary + additional repos)\n- Cleanup function closes all watchers\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:15:32.448347+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:25:49.410672+13:00","closed_at":"2026-02-10T23:25:49.410672+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.23277+13:00","created_by":"daviddao"},{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.823723+13:00","created_by":"daviddao"}]},{"id":"beads-map-mfw","title":"Epic: Search comments by commenter username","description":"Allow searching for nodes by commenter username. Typing a Bluesky handle (e.g. 'daviddao') in the search bar should also surface nodes where that person has left comments. This extends the existing node-field search to include comment author handles.","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:37:59.835891+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:39:16.820832+13:00","closed_at":"2026-02-12T10:39:16.820832+13:00","close_reason":"Completed: e2a49e1 — all tasks done","dependencies":[{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-8np","type":"blocks","created_at":"2026-02-12T10:39:55.570556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.652022+13:00","created_by":"daviddao"}]},{"id":"beads-map-mfw.1","title":"Include comment author handles in search matching","description":"In app/page.tsx: (1) Add a useMemo that builds a Map<string, string> from allComments — maps each nodeId to a space-joined string of unique commenter handles for that node. (2) In the searchResults useMemo, append the commenter handles string to the existing searchable string. This way typing 'daviddao.bsky.social' or just 'daviddao' surfaces nodes where that user commented. The searchResults useMemo needs allComments (or the derived map) in its dependency array.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:38:08.454443+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:39:16.735264+13:00","closed_at":"2026-02-12T10:39:16.735264+13:00","close_reason":"Completed: e2a49e1","dependencies":[{"issue_id":"beads-map-mfw.1","depends_on_id":"beads-map-mfw","type":"parent-child","created_at":"2026-02-12T10:38:08.455822+13:00","created_by":"daviddao"}]},{"id":"beads-map-mq9","title":"Add spawn/exit animations to paintLink","description":"Modify: components/BeadsGraph.tsx — paintLink() callback\n\nPURPOSE: Animate links based on _spawnTime and _removeTime timestamps. New links fade in smoothly, removed links fade out. This complements the node animations (task .6).\n\nCHANGES TO paintLink (currently at line ~544, inside the useCallback):\n\n1. At the BEGINNING of paintLink, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (fade-in + thickness) ---\n let linkSpawnAlpha = 1;\n let linkSpawnWidth = 1;\n const linkSpawnTime = (link as any)._spawnTime as number | undefined;\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < SPAWN_DURATION) {\n const progress = elapsed / SPAWN_DURATION;\n linkSpawnAlpha = easeOutQuad(progress);\n linkSpawnWidth = 1 + (1 - progress) * 1.5; // starts 2.5x thick, settles to 1x\n }\n }\n\n // --- Remove animation (fade-out) ---\n let linkRemoveAlpha = 1;\n const linkRemoveTime = (link as any)._removeTime as number | undefined;\n if (linkRemoveTime) {\n const elapsed = now - linkRemoveTime;\n if (elapsed < REMOVE_DURATION) {\n linkRemoveAlpha = 1 - easeOutQuad(elapsed / REMOVE_DURATION);\n } else {\n return; // fully gone, skip drawing\n }\n }\n\n const linkAnimAlpha = linkSpawnAlpha * linkRemoveAlpha;\n if (linkAnimAlpha <= 0.01) return; // skip invisible links\n```\n\n2. MULTIPLY the existing opacity by linkAnimAlpha:\n\nCurrently (line ~574-580), the opacity is computed as:\n```typescript\n const opacity = isParentChild\n ? hasHighlight\n ? isConnectedLink ? 0.5 : 0.05\n : 0.2\n : hasHighlight\n ? isConnectedLink ? 0.8 : 0.08\n : 0.35;\n```\n\nAfter this, multiply:\n```typescript\n ctx.globalAlpha = opacity * linkAnimAlpha;\n```\n\n3. MULTIPLY the line width by linkSpawnWidth:\n\nCurrently the line width is set separately for parent-child and blocks links. Multiply each by linkSpawnWidth:\n```typescript\n // For parent-child:\n ctx.lineWidth = Math.max(0.6, 1.5 / globalScale) * linkSpawnWidth;\n // For blocks:\n ctx.lineWidth = (isConnectedLink\n ? Math.max(2, 2.5 / globalScale)\n : Math.max(0.8, 1.2 / globalScale)) * linkSpawnWidth;\n```\n\n4. ADD SPAWN FLASH for new links (optional but nice):\n\nAfter drawing the link curve, if it's spawning, draw a brief bright flash along the path:\n\n```typescript\n // Brief bright flash for new links\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < 300) {\n const flashProgress = elapsed / 300;\n const flashAlpha = (1 - flashProgress) * 0.5;\n ctx.save();\n ctx.globalAlpha = flashAlpha;\n ctx.strokeStyle = \"#10b981\"; // emerald\n ctx.lineWidth = (isParentChild ? 3 : 4) / globalScale;\n ctx.beginPath();\n ctx.moveTo(start.x, start.y);\n ctx.quadraticCurveTo(cx, cy, end.x, end.y);\n ctx.stroke();\n ctx.restore();\n }\n }\n```\n\nThis creates a bright emerald line that fades out over 300ms, overlaid on the normal link.\n\nIMPORTANT: The paintLink callback has [] (empty) dependency array. Keep it that way. Animation timestamps are on the link objects themselves.\n\nIMPORTANT: The SPAWN_DURATION, REMOVE_DURATION, easeOutQuad constants are shared with paintNode (task .6). They should be declared at module level (above the component), not inside the callbacks. If task .6 is implemented first, they'll already exist.\n\nDEPENDS ON: task .5 (links must have _spawnTime/_removeTime), task .6 (shared animation constants + easing functions)\n\nACCEPTANCE CRITERIA:\n- New links fade in over 500ms with initial thickness burst\n- New links show brief emerald flash (300ms)\n- Removed links fade out over 400ms\n- Flow particles on new links are also affected by spawn alpha (not critical, nice-to-have)\n- No visual regression for non-animated links\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:18:20.715649+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:39:22.858151+13:00","closed_at":"2026-02-10T23:39:22.858151+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.630363+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-iyn","type":"blocks","created_at":"2026-02-10T23:19:29.312556+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg","title":"Enhanced comments: all-comments panel, likes, and threaded replies","description":"## Enhanced comments: all-comments panel, likes, and threaded replies\n\n### Summary\nThree major enhancements to the beads-map comment system, modeled after Hyperscan's ReviewSection:\n\n1. **All Comments panel** — A pill button in the top-right header area that opens a sidebar showing ALL comments across all nodes, sorted newest-first. Each comment links to its target node. This gives users a global activity feed.\n\n2. **Likes on comments** — Heart toggle on each comment (rose-500 when liked, zinc-300 when not), using the `org.impactindexer.review.like` lexicon. The like subject URI is the comment's AT-URI. Likes fetched from Hypergoat indexer. Same create/delete pattern as Hyperscan.\n\n3. **Threaded replies** — Reply button on each comment that shows an inline reply form. Uses the `replyTo` field on `org.impactindexer.review.comment` lexicon. Replies are indented with a left border (Hyperscan pattern: `ml-4 pl-3 border-l border-zinc-100`). Thread tree built client-side from flat comment list.\n\n### Architecture\n\n**Data fetching changes (`hooks/useBeadsComments.ts`):**\n- Fetch BOTH `org.impactindexer.review.comment` AND `org.impactindexer.review.like` from Hypergoat\n- Extend `BeadsComment` type: add `replyTo?: string`, `likes: BeadsLike[]`, `replies: BeadsComment[]`\n- Add `BeadsLike` type: `{ did, handle, displayName?, avatar?, createdAt, uri, rkey }`\n- Build thread tree: flat comments with `replyTo` assembled into nested `replies` arrays\n- Attach likes to their target comments (like subject.uri === comment AT-URI)\n- Export `allComments: BeadsComment[]` (flat list, newest first, for the All Comments panel)\n\n**New component (`components/AllCommentsPanel.tsx`):**\n- Slide-in sidebar from right (same pattern as NodeDetail sidebar)\n- Header: 'All Comments' title + close button\n- List of all comments sorted newest-first\n- Each comment shows: avatar, handle, time, text, target node ID (clickable to navigate)\n- Like button + reply count shown per comment\n\n**NodeDetail comment section enhancements (`components/NodeDetail.tsx`):**\n- Add HeartIcon component (Hyperscan pattern: filled/outline toggle)\n- Add like button on each CommentItem (heart + count, rose-500 when liked)\n- Add 'reply' text button on each CommentItem\n- Add InlineReplyForm (appears below comment being replied to)\n- Render threaded replies with recursive CommentItem (depth-based indentation)\n\n**page.tsx wiring:**\n- Add `allCommentsPanelOpen` state\n- Add pill button in header area\n- Add like/reply handlers\n- Render AllCommentsPanel component\n- Wire node navigation from AllCommentsPanel\n\n### Subject URI conventions\n- Comment on a beads issue: `{ uri: 'beads:<issue-id>', type: 'record' }`\n- Like on a comment: `{ uri: 'at://<did>/org.impactindexer.review.comment/<rkey>', type: 'record' }`\n- Reply to a comment: comment record with `replyTo: 'at://<did>/org.impactindexer.review.comment/<rkey>'`\n\n### Dependency chain\n- .1 (extend hook) is independent — foundational data layer\n- .2 (likes on comments in NodeDetail) depends on .1\n- .3 (threaded replies in NodeDetail) depends on .1\n- .4 (AllCommentsPanel component) depends on .1\n- .5 (page.tsx wiring + pill button) depends on .2, .3, .4\n- .6 (build verification) depends on .5\n\n### Reference files\n- Hyperscan ReviewSection: `/Users/david/Projects/gainforest/hyperscan/src/components/ReviewSection.tsx`\n- Like lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/like.json`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Current hook: `hooks/useBeadsComments.ts`\n- Current NodeDetail: `components/NodeDetail.tsx`\n- Current page.tsx: `app/page.tsx`","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T01:24:13.04637+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:37:27.139182+13:00","closed_at":"2026-02-11T01:37:27.139182+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg","depends_on_id":"beads-map-dyi","type":"blocks","created_at":"2026-02-11T01:26:33.09446+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.1","title":"Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees","description":"## Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees\n\n### Goal\nExtend `hooks/useBeadsComments.ts` to fetch likes from Hypergoat, parse the `replyTo` field on comments, and build threaded comment trees.\n\n### File to modify\n`hooks/useBeadsComments.ts` (currently 289 lines)\n\n### Step 1: Add new types\n\n```typescript\nexport interface BeadsLike {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n createdAt: string;\n uri: string; // AT-URI of the like record\n rkey: string;\n}\n\n// Extend BeadsComment:\nexport interface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string;\n rkey: string;\n replyTo?: string; // NEW — AT-URI of parent comment\n likes: BeadsLike[]; // NEW — likes on this comment\n replies: BeadsComment[]; // NEW — nested child comments\n}\n```\n\n### Step 2: Fetch likes from Hypergoat\n\nUse the same `FETCH_COMMENTS_QUERY` GraphQL query but with `collection: 'org.impactindexer.review.like'`. Create a `fetchLikeRecords()` function (same pagination pattern as `fetchCommentRecords()`).\n\n### Step 3: Parse replyTo from comment records\n\nIn the comment processing loop (currently line 217-238), extract `replyTo` from the record value:\n```typescript\nconst replyTo = (value.replyTo as string) || undefined;\n```\nAdd it to the BeadsComment object.\n\n### Step 4: Attach likes to comments\n\nAfter fetching both comments and likes:\n1. Filter likes to those whose `subject.uri` is an AT-URI of a comment (starts with `at://`)\n2. Build a `Map<commentUri, BeadsLike[]>` \n3. Attach likes to their target comments\n\n### Step 5: Build thread trees\n\nAfter grouping comments by node:\n1. For each node's comments, put all in a `Map<uri, BeadsComment>`\n2. For each comment with `replyTo`, push into `parent.replies`\n3. Root comments = those without `replyTo` (or whose parent is missing)\n4. Sort: root comments newest-first, replies oldest-first (chronological conversation)\n\n### Step 6: Export allComments\n\nAdd to the return value:\n```typescript\nallComments: BeadsComment[] // flat list of all root+reply comments, newest-first, for All Comments panel\n```\n\n### Step 7: Update UseBeadsCommentsResult interface\n\n```typescript\nexport interface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // now threaded trees\n commentedNodeIds: Map<string, number>;\n allComments: BeadsComment[]; // NEW — flat list, newest-first\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n```\n\n### Testing\n- `pnpm build` must pass\n- Verify that comments with `replyTo` fields are correctly nested\n- Verify that likes are attached to the correct comments\n- `allComments` should contain all comments (including replies) sorted newest-first","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:24:33.393775+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.516403+13:00","closed_at":"2026-02-11T01:33:11.516403+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.1","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:33.395429+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.2","title":"Add heart-toggle likes on comments in NodeDetail","description":"## Add heart-toggle likes on comments in NodeDetail\n\n### Goal\nAdd a heart icon like button on each comment in `components/NodeDetail.tsx`, following the Hyperscan pattern exactly.\n\n### File to modify\n`components/NodeDetail.tsx` (currently 511 lines)\n\n### Dependencies\n- beads-map-vdg.1 (likes data available on BeadsComment objects)\n\n### Step 1: Add HeartIcon component\n\nPort from Hyperscan — two variants (filled/outline):\n```typescript\nfunction HeartIcon({ className = 'w-3 h-3', filled = false }: { className?: string; filled?: boolean }) {\n if (filled) {\n return (\n <svg className={className} viewBox='0 0 24 24' fill='currentColor'>\n <path d='M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z' />\n </svg>\n );\n }\n return (\n <svg className={className} fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z' />\n </svg>\n );\n}\n```\n\n### Step 2: Add like button to CommentItem actions row\n\nIn the `CommentItem` component, add a like button in the actions area alongside the existing delete button.\n\nThe actions row for each comment should be: heart-like button | reply button | delete button (own only)\n\n```tsx\n// In CommentItem actions area\n<div className='flex items-center gap-2 mt-1 text-[10px]'>\n <button\n onClick={() => onLike?.(comment)}\n disabled={!isAuthenticated || isLiking}\n className={`flex items-center gap-0.5 transition-colors ${\n hasLiked ? 'text-rose-500' : 'text-zinc-300 hover:text-rose-500'\n } disabled:opacity-50`}\n >\n <HeartIcon className='w-3 h-3' filled={hasLiked} />\n {comment.likes.length > 0 && <span>{comment.likes.length}</span>}\n </button>\n {/* ... reply button (task .3) ... */}\n {/* ... delete button (existing) ... */}\n</div>\n```\n\n### Step 3: Add like handler props\n\nAdd to `NodeDetailProps`:\n```typescript\nonLikeComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nAdd to `CommentItem` props:\n```typescript\nonLike?: (comment: BeadsComment) => Promise<void>;\nisAuthenticated?: boolean;\n```\n\n### Step 4: Determine `hasLiked` state\n\nIn CommentItem, check if the current user has liked:\n```typescript\nconst hasLiked = currentDid ? comment.likes.some(l => l.did === currentDid) : false;\n```\n\n### Like create/delete will be wired in page.tsx (task .5)\nThe actual API calls (POST/DELETE to /api/records with org.impactindexer.review.like) will be handled by callbacks passed from page.tsx.\n\n### Testing\n- `pnpm build` must pass\n- Heart icon renders in outline state by default\n- Heart icon renders filled + rose-500 when liked by current user\n- Like count shows next to heart when > 0","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:24:55.516758+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.647637+13:00","closed_at":"2026-02-11T01:33:11.647637+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:55.518315+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.248408+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.3","title":"Add threaded replies with inline reply form in NodeDetail","description":"## Add threaded replies with inline reply form in NodeDetail\n\n### Goal\nAdd reply functionality to comments in `components/NodeDetail.tsx`: a 'reply' text button on each comment, an inline reply form, and recursive threaded rendering with indentation.\n\n### File to modify\n`components/NodeDetail.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (BeadsComment now has `replies: BeadsComment[]` and `replyTo?: string`)\n\n### Step 1: Add InlineReplyForm component\n\nPort from Hyperscan ReviewSection:\n```tsx\nfunction InlineReplyForm({\n replyingTo,\n replyText,\n onTextChange,\n onSubmit,\n onCancel,\n isSubmitting,\n}: {\n replyingTo: BeadsComment;\n replyText: string;\n onTextChange: (text: string) => void;\n onSubmit: () => void;\n onCancel: () => void;\n isSubmitting: boolean;\n}) {\n return (\n <div className='mt-2 ml-4 pl-3 border-l border-emerald-200 space-y-1.5'>\n <div className='flex items-center gap-1.5 text-[10px] text-zinc-400'>\n <span>Replying to</span>\n <span className='font-medium text-zinc-600'>\n {replyingTo.displayName || replyingTo.handle}\n </span>\n </div>\n <div className='flex gap-2'>\n <input\n type='text'\n value={replyText}\n onChange={(e) => onTextChange(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && onSubmit()}\n placeholder='Write a reply...'\n disabled={isSubmitting}\n autoFocus\n className='flex-1 px-2 py-1 text-xs bg-white border border-zinc-200 rounded placeholder-zinc-400 focus:outline-none focus:border-emerald-400 disabled:opacity-50'\n />\n <button onClick={onSubmit} disabled={!replyText.trim() || isSubmitting}\n className='px-2 py-1 text-[10px] font-medium text-emerald-600 hover:text-emerald-700 disabled:opacity-50'>\n {isSubmitting ? '...' : 'Reply'}\n </button>\n <button onClick={onCancel} disabled={isSubmitting}\n className='px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-600 disabled:opacity-50'>\n Cancel\n </button>\n </div>\n </div>\n );\n}\n```\n\n### Step 2: Add reply button to CommentItem actions row\n\nAdd a 'reply' text button (Hyperscan pattern):\n```tsx\n<button\n onClick={() => onStartReply?.(comment)}\n disabled={!isAuthenticated}\n className={`transition-colors disabled:opacity-50 ${\n isReplyingToThis ? 'text-emerald-500' : 'text-zinc-300 hover:text-zinc-500'\n }`}\n>\n reply\n</button>\n```\n\n### Step 3: Make CommentItem recursive for threading\n\nAdd `depth` prop (default 0). When `depth > 0`, add indentation:\n```tsx\n<div className={`${depth > 0 ? 'ml-4 pl-3 border-l border-zinc-100' : ''}`}>\n```\n\nAfter the comment content, render replies recursively:\n```tsx\n{comment.replies.length > 0 && (\n <div className='space-y-0'>\n {comment.replies.map((reply) => (\n <CommentItem key={reply.uri} comment={reply} depth={depth + 1} ... />\n ))}\n </div>\n)}\n```\n\n### Step 4: Add reply state management props\n\nAdd to `NodeDetailProps`:\n```typescript\nonReplyComment?: (parentComment: BeadsComment, text: string) => Promise<void>;\n```\n\nAdd reply state to the Comments section (managed locally in NodeDetail):\n```typescript\nconst [replyingToUri, setReplyingToUri] = useState<string | null>(null);\nconst [replyText, setReplyText] = useState('');\nconst [isSubmittingReply, setIsSubmittingReply] = useState(false);\n```\n\n### Step 5: Wire InlineReplyForm rendering\n\nIn CommentItem, show InlineReplyForm when `replyingToUri === comment.uri`:\n```tsx\n{isReplyingToThis && (\n <InlineReplyForm\n replyingTo={comment}\n replyText={replyText}\n onTextChange={onReplyTextChange}\n onSubmit={onSubmitReply}\n onCancel={onCancelReply}\n isSubmitting={isSubmittingReply}\n />\n)}\n```\n\n### Testing\n- `pnpm build` must pass \n- Reply button appears on each comment\n- Clicking reply shows inline form below that comment\n- Replies render indented with left border\n- Nested replies (reply to a reply) indent further","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:25:16.081672+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.780553+13:00","closed_at":"2026-02-11T01:33:11.780553+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:16.083107+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.371142+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.4","title":"Create AllCommentsPanel sidebar component","description":"## Create AllCommentsPanel sidebar component\n\n### Goal\nCreate `components/AllCommentsPanel.tsx` — a slide-in sidebar that shows ALL comments across all beads nodes, sorted newest-first. This provides a global activity feed view.\n\n### File to create\n`components/AllCommentsPanel.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (allComments flat list available from hook)\n\n### Props interface\n```typescript\ninterface AllCommentsPanelProps {\n isOpen: boolean;\n onClose: () => void;\n allComments: BeadsComment[]; // flat list, newest-first (from useBeadsComments)\n onNodeNavigate: (nodeId: string) => void; // click a comment's node ID to navigate\n isAuthenticated?: boolean;\n currentDid?: string;\n onLikeComment?: (comment: BeadsComment) => Promise<void>;\n onDeleteComment?: (comment: BeadsComment) => Promise<void>;\n}\n```\n\n### Design\nSame slide-in pattern as the NodeDetail sidebar:\n```tsx\n<aside className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${\n isOpen ? 'translate-x-0' : 'translate-x-full'\n}`}>\n```\n\n### Layout\n1. **Header**: 'All Comments' title + close X button + comment count badge\n2. **Scrollable list**: Each comment shows:\n - Target node pill: clickable badge with node ID (e.g., 'beads-map-cvh') in emerald — clicking calls `onNodeNavigate`\n - Avatar (24px circle) + handle + relative time\n - Comment text\n - Heart like button (rose-500 when liked) + like count\n - If it's a reply, show 'Re: {parentHandle}' label in zinc-400\n - Delete X for own comments\n3. **Empty state**: 'No comments yet' when list is empty\n4. **Footer**: count summary\n\n### Comment card design\n```tsx\n<div className='py-3 border-b border-zinc-50'>\n {/* Node target pill */}\n <button onClick={() => onNodeNavigate(comment.nodeId)}\n className='inline-flex items-center px-1.5 py-0.5 mb-1.5 rounded text-[10px] font-mono bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors'>\n {comment.nodeId}\n </button>\n {/* Rest of comment: avatar + name + time + text + actions */}\n</div>\n```\n\n### Important: nodeId on comments\nThe allComments list from the hook needs to include the target nodeId on each comment. Either:\n- Add `nodeId: string` to BeadsComment interface (set during processing in the hook)\n- Or derive it from `subject.uri.replace(/^beads:/, '')` at render time\n\nPreferred: add `nodeId` to BeadsComment in the hook (task .1) since it's needed here.\n\n### Testing\n- `pnpm build` must pass\n- Panel slides in/out with animation\n- Comments sorted newest-first\n- Clicking node ID navigates to that node\n- Like/delete actions work","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:25:35.922861+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.912418+13:00","closed_at":"2026-02-11T01:33:11.912418+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:35.924466+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.487447+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.5","title":"Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel","description":"## Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel\n\n### Goal\nConnect all new components in `app/page.tsx`: add the 'Comments' pill button in the header, wire like/reply API handlers, render AllCommentsPanel.\n\n### File to modify\n`app/page.tsx` (currently 926 lines)\n\n### Dependencies\n- beads-map-vdg.1 (extended hook with allComments, likes, thread trees)\n- beads-map-vdg.2 (NodeDetail with like UI)\n- beads-map-vdg.3 (NodeDetail with reply UI) \n- beads-map-vdg.4 (AllCommentsPanel component)\n\n### Step 1: Update imports\n\n```typescript\nimport AllCommentsPanel from '@/components/AllCommentsPanel';\n```\n\n### Step 2: Update useBeadsComments destructuring\n\n```typescript\nconst { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } = useBeadsComments();\n```\n\n### Step 3: Add state\n\n```typescript\nconst [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);\n```\n\n### Step 4: Add like handler\n\n```typescript\nconst handleLikeComment = useCallback(async (comment: BeadsComment) => {\n // Check if already liked by current user\n const existingLike = comment.likes.find(l => l.did === session?.did);\n \n if (existingLike) {\n // Unlike: DELETE the like record\n const response = await fetch(\n \\`/api/records?collection=\\${encodeURIComponent('org.impactindexer.review.like')}&rkey=\\${encodeURIComponent(existingLike.rkey)}\\`,\n { method: 'DELETE' }\n );\n if (!response.ok) throw new Error('Failed to unlike');\n } else {\n // Like: POST a new like record\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.like',\n record: {\n subject: { uri: comment.uri, type: 'record' },\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to like');\n }\n \n await refetchComments();\n}, [session?.did, refetchComments]);\n```\n\n### Step 5: Add reply handler\n\n```typescript\nconst handleReplyComment = useCallback(async (parentComment: BeadsComment, text: string) => {\n // Extract the nodeId from the parent comment's subject\n // The parent comment targets beads:<nodeId>, replies still target the same node\n // but include replyTo pointing to the parent comment's AT-URI\n const nodeId = /* derive from parentComment — needs nodeId field from task .1 */;\n \n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n subject: { uri: \\`beads:\\${nodeId}\\`, type: 'record' },\n text,\n replyTo: parentComment.uri,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to post reply');\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 6: Add pill button in header\n\nIn the stats/right area of the header (around line 716), add a comments pill:\n```tsx\n<button\n onClick={() => setAllCommentsPanelOpen(prev => !prev)}\n className={\\`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border transition-colors \\${\n allCommentsPanelOpen\n ? 'bg-emerald-50 text-emerald-600 border-emerald-200'\n : 'bg-white text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:border-zinc-300'\n }\\`}\n>\n <svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z' />\n </svg>\n Comments\n {allComments.length > 0 && (\n <span className='px-1.5 py-0.5 bg-red-500 text-white rounded-full text-[10px] font-medium min-w-[18px] text-center'>\n {allComments.length}\n </span>\n )}\n</button>\n```\n\n### Step 7: Render AllCommentsPanel\n\nAfter the NodeDetail sidebar (around line 866), add:\n```tsx\n<AllCommentsPanel\n isOpen={allCommentsPanelOpen}\n onClose={() => setAllCommentsPanelOpen(false)}\n allComments={allComments}\n onNodeNavigate={(nodeId) => {\n handleNodeNavigate(nodeId);\n setAllCommentsPanelOpen(false);\n }}\n isAuthenticated={isAuthenticated}\n currentDid={session?.did}\n onLikeComment={handleLikeComment}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n### Step 8: Pass new props to NodeDetail (both desktop + mobile instances)\n\nAdd to both NodeDetail instances:\n```typescript\nonLikeComment={handleLikeComment}\nonReplyComment={handleReplyComment}\n```\n\n### Step 9: Close AllCommentsPanel when NodeDetail opens (and vice versa)\n\nWhen selecting a node, close the all-comments panel:\n```typescript\n// In handleNodeClick:\nsetAllCommentsPanelOpen(false);\n```\n\n### Testing\n- `pnpm build` must pass\n- Pill button visible in header\n- Clicking pill opens AllCommentsPanel\n- Like toggle works (creates/deletes like records)\n- Reply creates comment with replyTo field\n- Panel and NodeDetail don't overlap","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:26:04.167505+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:12.043078+13:00","closed_at":"2026-02-11T01:33:12.043078+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:04.1688+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.2","type":"blocks","created_at":"2026-02-11T01:26:28.611742+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.3","type":"blocks","created_at":"2026-02-11T01:26:28.725946+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.4","type":"blocks","created_at":"2026-02-11T01:26:28.84169+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.6","title":"Build verification and final cleanup","description":"## Build verification and final cleanup\n\n### Goal\nVerify the full build passes, clean up any TypeScript errors, and ensure all features work together.\n\n### Steps\n1. Run `pnpm build` — must pass with zero errors\n2. Verify no unused imports or dead code from the refactoring\n3. Test the full flow mentally: \n - Comments pill shows in header with count badge\n - Clicking opens AllCommentsPanel with all comments newest-first\n - Each comment shows heart like button\n - Clicking heart toggles like (creates/deletes org.impactindexer.review.like)\n - Reply button shows inline form\n - Submitting reply creates comment with replyTo field\n - Replies render threaded with indentation\n - Node ID in AllCommentsPanel navigates to that node\n4. Update AGENTS.md with new component/hook documentation\n\n### Testing\n- `pnpm build` must pass with zero errors","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:26:12.40132+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:12.174234+13:00","closed_at":"2026-02-11T01:33:12.174234+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:12.402978+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg.5","type":"blocks","created_at":"2026-02-11T01:26:28.956024+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.7","title":"Restyle Comments pill to match layout toggle pill styling, remove red counter badge","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:36:49.288253+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:37:27.025479+13:00","closed_at":"2026-02-11T01:37:27.025479+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.7","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:36:49.289175+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w","title":"Right-click context menu: show description or add comment","description":"## Right-Click Context Menu\n\n### Summary\nReplace the current right-click behavior (which directly opens CommentTooltip) with a two-step interaction:\n1. Right-click a graph node → small context menu appears at cursor with two options\n2. User picks \"Show description\" → opens description modal, OR \"Add comment\" → opens the existing CommentTooltip\n\n### Current behavior\n- Right-click a node in BeadsGraph → `handleNodeRightClick` in `app/page.tsx:425-430` sets `contextMenu: { node, x, y }`\n- `contextMenu` state directly renders `CommentTooltip` component at `app/page.tsx:997-1010`\n- CommentTooltip shows node info, existing comments preview, and compose area\n\n### New behavior\n- Right-click a node → `contextMenu` state renders a NEW `ContextMenu` component (small 2-item menu)\n- \"Show description\" → opens a description modal (same portal modal currently in NodeDetail.tsx:332-370)\n- \"Add comment\" → opens the existing CommentTooltip at the same cursor position\n\n### Architecture\nThree changes needed:\n1. **New `ContextMenu` component** — small floating menu at cursor position with 2 items\n2. **Extract `DescriptionModal` component** — lift the portal modal from NodeDetail into a reusable component\n3. **Wire in `page.tsx`** — new state for `commentTooltipState` and `descriptionModalNode`, replace direct CommentTooltip render with ContextMenu → action flow\n\n### State model (page.tsx)\n```\ncontextMenu: { node, x, y } | null // phase 1: shows ContextMenu\ncommentTooltipState: { node, x, y } | null // phase 2a: shows CommentTooltip\ndescriptionModalNode: GraphNode | null // phase 2b: shows DescriptionModal\n```\n\nRight-click → sets contextMenu → renders ContextMenu\nContextMenu \"Show description\" → sets descriptionModalNode, clears contextMenu\nContextMenu \"Add comment\" → sets commentTooltipState, clears contextMenu\n\n### Subtasks\n- .1 Create ContextMenu component\n- .2 Extract DescriptionModal component from NodeDetail\n- .3 Wire context menu + actions in page.tsx\n- .4 Build verify and push\n\n### Acceptance criteria\n- Right-clicking a graph node shows a small 2-item context menu\n- \"Show description\" opens a full-screen modal with the issue description (markdown rendered)\n- \"Add comment\" opens the existing CommentTooltip (unchanged behavior)\n- Escape / click-outside dismisses the context menu\n- \"View in window\" button in NodeDetail sidebar still works\n- pnpm build passes","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T09:18:37.401674+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:47:46.54973+13:00","closed_at":"2026-02-11T10:47:46.54973+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.328556+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.1","title":"Create ContextMenu component","description":"## Create ContextMenu component\n\n### What\nA small floating context menu that appears on right-click of a graph node. Shows two options: \"Show description\" and \"Add comment\". Styled to match the existing app aesthetic.\n\n### New file: `components/ContextMenu.tsx`\n\n#### Props interface\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number; // clientX from right-click event\n y: number; // clientY from right-click event\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n```\n\n#### Positioning\n- `position: fixed`, placed at `(x + 4, y + 4)` — slightly offset from cursor\n- Viewport clamping: if menu would overflow right edge, shift left; if overflow bottom, shift up\n- The menu is small (~160px wide, ~80px tall) so clamping is simple\n- Use `useEffect` + `getBoundingClientRect()` on mount to measure and clamp (same pattern as CommentTooltip.tsx:35-55 but simpler)\n\n#### Visual design\n```\n┌─────────────────┐\n│ 📄 Show description │\n│ 💬 Add comment │\n└─────────────────┘\n```\n\n- Container: `bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden`\n- Shadow: `box-shadow: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.06)`\n- Each item: `px-3 py-2 text-xs text-zinc-700 hover:bg-zinc-50 cursor-pointer flex items-center gap-2 transition-colors`\n- Icons: small SVGs (w-3.5 h-3.5 text-zinc-400)\n - \"Show description\": document/page icon\n - \"Add comment\": chat bubble icon (same as CommentTooltip.tsx:172-183)\n- Divider between items: `border-b border-zinc-100` on first item\n- Animate in: opacity 0→1, translateY(2px)→0, transition 0.15s\n\n#### Dismiss behavior\n- Escape key → calls `onClose()`\n- Click outside → calls `onClose()` (with 50ms delay, same as CommentTooltip.tsx:76-94)\n- Prevent browser context menu on the component itself: `onContextMenu={(e) => e.preventDefault()}`\n\n#### Item click behavior\n- \"Show description\" → calls `onShowDescription()`\n- \"Add comment\" → calls `onAddComment()`\n- Both should also implicitly close the menu (parent handles this by clearing contextMenu state)\n\n#### Full component structure\n```tsx\n\"use client\";\nimport { useState, useRef, useEffect } from \"react\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n\nexport function ContextMenu({ node, x, y, onShowDescription, onAddComment, onClose }: ContextMenuProps) {\n const menuRef = useRef<HTMLDivElement>(null);\n const [pos, setPos] = useState({ x: 0, y: 0 });\n const [visible, setVisible] = useState(false);\n\n // Position + clamp to viewport\n useEffect(() => {\n if (!menuRef.current) return;\n const rect = menuRef.current.getBoundingClientRect();\n const vw = window.innerWidth;\n const vh = window.innerHeight;\n let nx = x + 4;\n let ny = y + 4;\n if (nx + rect.width > vw - 16) nx = vw - rect.width - 16;\n if (nx < 16) nx = 16;\n if (ny + rect.height > vh - 16) ny = vh - rect.height - 16;\n if (ny < 16) ny = 16;\n setPos({ x: nx, y: ny });\n setVisible(true);\n }, [x, y]);\n\n // Escape key\n useEffect(() => {\n const handler = (e: KeyboardEvent) => { if (e.key === \"Escape\") onClose(); };\n window.addEventListener(\"keydown\", handler);\n return () => window.removeEventListener(\"keydown\", handler);\n }, [onClose]);\n\n // Click outside (with delay)\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();\n };\n const timer = setTimeout(() => window.addEventListener(\"mousedown\", handler), 50);\n return () => { clearTimeout(timer); window.removeEventListener(\"mousedown\", handler); };\n }, [onClose]);\n\n return (\n <div ref={menuRef} style={{ position: \"fixed\", left: pos.x, top: pos.y, zIndex: 100,\n opacity: visible ? 1 : 0, transform: visible ? \"translateY(0)\" : \"translateY(2px)\",\n transition: \"opacity 0.15s ease, transform 0.15s ease\" }}\n onContextMenu={(e) => e.preventDefault()}\n >\n <div className=\"bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden\" style={{ minWidth: 180 }}>\n <button onClick={onShowDescription}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-b border-zinc-100\">\n {/* Document icon SVG */}\n Show description\n </button>\n <button onClick={onAddComment}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors\">\n {/* Chat bubble icon SVG */}\n Add comment\n </button>\n </div>\n </div>\n );\n}\n```\n\n### SVG Icons\n**Document icon** (Show description):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z\" />\n</svg>\n```\n\n**Chat bubble icon** (Add comment):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z\" />\n</svg>\n```\n\n### Files to create\n- `components/ContextMenu.tsx`\n\n### Acceptance criteria\n- ContextMenu renders at cursor position with 2 items\n- Hover states on items (bg-zinc-50)\n- Escape key dismisses\n- Click outside dismisses\n- Clicking \"Show description\" calls onShowDescription\n- Clicking \"Add comment\" calls onAddComment\n- Viewport clamping works (menu stays on screen)\n- Animate-in transition (opacity + translateY)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:19:10.934581+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.14949+13:00","closed_at":"2026-02-11T09:23:40.14949+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.1","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:10.936853+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.10","title":"Avatar visual tuning: full opacity, persistent in clusters, minimap display","description":"## Avatar visual tuning\n\n### Changes\n\n#### 1. Full opacity (components/BeadsGraph.tsx paintNode)\n- Changed `ctx.globalAlpha = Math.min(opacity, 0.6)` → `ctx.globalAlpha = 1`\n- Avatars are now always at full opacity, not subtle/translucent\n\n#### 2. Never fade in clusters (components/BeadsGraph.tsx paintNode)\n- Removed `globalScale > 0.4` threshold from the avatar drawing condition\n- Changed `if (claimInfo && globalScale > 0.4)` → `if (claimInfo)`\n- Avatars remain visible even when zoomed out to cluster view\n\n#### 3. Constant screen-space size on zoom (components/BeadsGraph.tsx paintNode)\n- Changed `avatarSize = Math.min(8, Math.max(4, 10 / globalScale))` → `avatarSize = Math.max(4, 10 / globalScale)`\n- Removed the `Math.min(8, ...)` cap so avatar grows in graph-space as you zoom out, maintaining roughly the same pixel size on screen\n\n#### 4. Minimap avatar display (components/BeadsGraph.tsx redrawMinimap)\n- Added avatar drawing loop after the node dots loop in `redrawMinimap`\n- For each claimed node, draws a small circular avatar (radius 5px) at the node position on the minimap\n- Uses the same `getAvatarImage` cache for image loading\n- Fallback: gray circle if image not loaded\n- White border ring for contrast\n\n### Commits\n- `8693bec` Reduce claim avatar opacity to 0.6, constant screen-space size on zoom\n- `0a23755` Avatar: full opacity, never fade in clusters, show on minimap\n\n### Files changed\n- `components/BeadsGraph.tsx` — paintNode avatar section + redrawMinimap avatar loop\n\n### Status: DONE","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:26:08.912736+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:26:45.656242+13:00","closed_at":"2026-02-11T10:26:45.656242+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:08.914469+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w.9","type":"blocks","created_at":"2026-02-11T10:26:08.915791+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.11","title":"Avatar hover tooltip: show profile info on mouseover","description":"## Avatar hover tooltip\n\n### What\nWhen hovering over a claimed node avatar on the graph, a small tooltip appears showing the profile picture and @handle.\n\n### Implementation\n\n#### 1. New prop on BeadsGraph (`components/BeadsGraph.tsx`)\n- Added `onAvatarHover?: (info: { handle: string; avatar?: string; x: number; y: number } | null) => void` to `BeadsGraphProps`\n- Added `onAvatarHoverRef` (stable ref for the callback, avoids stale closures)\n- Added `hoveredAvatarNodeRef` (tracks which avatar is hovered to avoid redundant callbacks)\n- Added `viewNodesRef` (ref synced from `viewNodes` memo, so mousemove respects epics view)\n\n#### 2. Mousemove hit-testing (`components/BeadsGraph.tsx`)\n- `useEffect` registers a `mousemove` listener on the container div\n- Converts screen coords → graph coords via `fg.screen2GraphCoords()`\n- Iterates `viewNodesRef.current` (respects full/epics view mode)\n- For each node with a claim, computes the avatar circle position (`node.x + size * 0.7`, `node.y + size * 0.7`) and radius (`Math.max(4, 10 / globalScale)`)\n- Hit-tests: if mouse is inside the avatar circle, emits `onAvatarHover({ handle, avatar, x, y })`\n- If mouse leaves all avatar circles, emits `onAvatarHover(null)`\n- Uses `[]` dependency (refs for everything) so listener is registered once\n\n#### 3. Epics view fix\n- Initially the hit-test iterated `nodes` (raw prop) which broke in epics view since child nodes are collapsed\n- Fixed to iterate `viewNodesRef.current` which reflects the current view mode\n- `viewNodesRef.current` is synced inline after the `viewNodes` useMemo\n\n#### 4. Tooltip rendering (`app/page.tsx`)\n- Added `avatarTooltip` state: `{ handle, avatar, x, y } | null`\n- Passed `onAvatarHover={setAvatarTooltip}` to `<BeadsGraph>`\n- Renders a `position: fixed` tooltip at `(x+12, y-8)` from cursor with `pointerEvents: none`\n- Tooltip shows: profile pic (20x20 rounded circle) + `@handle` text\n- White bg, zinc border, rounded-lg, shadow-lg — matches app aesthetic\n- Fallback: gray circle with first letter if no avatar URL\n\n### Commits\n- `ea0a905` Add avatar hover tooltip showing profile pic and handle\n- `5817941` Fix avatar tooltip in epics view: hit-test against viewNodes not raw nodes\n\n### Files changed\n- `components/BeadsGraph.tsx` — onAvatarHover prop, refs, mousemove useEffect, viewNodesRef\n- `app/page.tsx` — avatarTooltip state, onAvatarHover prop, tooltip JSX\n\n### Status: DONE","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:26:37.012238+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:26:45.776772+13:00","closed_at":"2026-02-11T10:26:45.776772+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:37.013442+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w.10","type":"blocks","created_at":"2026-02-11T10:26:37.015186+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.12","title":"Unclaim task: right-click to remove claim from a node","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:47:43.376019+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:47:46.432125+13:00","closed_at":"2026-02-11T10:47:46.432125+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.12","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:47:43.377971+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.2","title":"Extract DescriptionModal component from NodeDetail","description":"## Extract DescriptionModal component from NodeDetail\n\n### What\nThe description modal currently lives inside `components/NodeDetail.tsx` (lines 332-370) using local `descriptionExpanded` state. We need the same modal accessible from the right-click context menu (which lives in `page.tsx`, not inside NodeDetail). \n\nExtract the modal into a standalone `DescriptionModal` component, then use it from both NodeDetail and page.tsx.\n\n### Current implementation in NodeDetail.tsx\n\n**State (line 52):**\n```typescript\nconst [descriptionExpanded, setDescriptionExpanded] = useState(false);\n```\n\n**\"View in window\" button (lines 317-322):**\n```tsx\n<button\n onClick={() => setDescriptionExpanded(true)}\n className=\"text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors\"\n>\n View in window\n</button>\n```\n\n**Portal modal (lines 332-370):**\n```tsx\n{descriptionExpanded && node.description && createPortal(\n <div className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={() => setDescriptionExpanded(false)}>\n <div className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}>\n {/* Header: node.id + node.title + X button */}\n {/* Body: ReactMarkdown with node.description */}\n </div>\n </div>,\n document.body\n)}\n```\n\n### New file: `components/DescriptionModal.tsx`\n\n```typescript\n\"use client\";\nimport { createPortal } from \"react-dom\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface DescriptionModalProps {\n node: GraphNode;\n onClose: () => void;\n}\n\nexport function DescriptionModal({ node, onClose }: DescriptionModalProps) {\n if (!node.description) return null;\n\n return createPortal(\n <div\n className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={onClose}\n >\n <div\n className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}\n >\n {/* Modal header */}\n <div className=\"flex items-center justify-between px-5 py-3 border-b border-zinc-100\">\n <div className=\"flex items-center gap-2 min-w-0\">\n <span className=\"text-xs font-mono font-semibold text-emerald-600 shrink-0\">\n {node.id}\n </span>\n <span className=\"text-sm font-semibold text-zinc-900 truncate\">\n {node.title}\n </span>\n </div>\n <button\n onClick={onClose}\n className=\"shrink-0 p-1 text-zinc-400 hover:text-zinc-600 transition-colors\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {/* Modal body */}\n <div className=\"flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed\">\n <ReactMarkdown remarkPlugins={[remarkGfm]}>\n {node.description}\n </ReactMarkdown>\n </div>\n </div>\n </div>,\n document.body\n );\n}\n```\n\n### Refactor NodeDetail.tsx\n\n**Remove from NodeDetail.tsx:**\n- The `descriptionExpanded` state (line 52)\n- The portal modal JSX (lines 332-370)\n\n**Keep in NodeDetail.tsx:**\n- The \"View in window\" button (lines 317-322)\n- But change its onClick to use the new component locally\n\n**Two approaches for NodeDetail:**\n\n**Option A (keep local state):** NodeDetail keeps its own `descriptionExpanded` state and renders `<DescriptionModal>` when true. Simple, no prop drilling needed. The \"View in window\" button works exactly as before.\n\n**Option B (lift state via callback):** Add an `onExpandDescription?: () => void` prop to NodeDetail. \"View in window\" calls this callback, parent (page.tsx) sets `descriptionModalNode`. More centralized but adds a prop.\n\n**Recommended: Option A.** Keep NodeDetail self-contained. It renders `<DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />` instead of the inline portal. The context menu in page.tsx independently renders `<DescriptionModal>` from its own state. Two independent entry points, same component. No coupling needed.\n\n### Changes to NodeDetail.tsx\n\n1. **Add import:**\n```typescript\nimport { DescriptionModal } from \"./DescriptionModal\";\n```\n\n2. **Keep `descriptionExpanded` state** (line 52) — unchanged\n\n3. **Replace lines 332-370** (the inline createPortal block) with:\n```tsx\n{descriptionExpanded && node.description && (\n <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />\n)}\n```\n\n4. **Remove import** of `createPortal` from NodeDetail.tsx IF it is no longer used elsewhere in the file. Check: `createPortal` is only used for the description modal, so remove `import { createPortal } from \"react-dom\"` from NodeDetail.tsx.\n\n5. **Remove import** of `ReactMarkdown` and `remarkGfm` from NodeDetail.tsx IF they are no longer used. Check: ReactMarkdown is still used for the inline description preview (line 325-327), so KEEP these imports.\n\nActually wait — `createPortal` is imported at the top of NodeDetail.tsx. Let me check if it is used anywhere else in the file besides the description modal. Looking at the NodeDetail component, createPortal is ONLY used for the description modal (lines 332-370). So yes, remove the createPortal import.\n\n### Files to create\n- `components/DescriptionModal.tsx`\n\n### Files to edit \n- `components/NodeDetail.tsx`:\n - Add import for DescriptionModal\n - Remove import for createPortal (from \"react-dom\")\n - Replace inline portal JSX (lines 332-370) with `<DescriptionModal>` usage\n - Keep descriptionExpanded state and \"View in window\" button unchanged\n\n### Acceptance criteria\n- New `DescriptionModal` component renders the same modal as before\n- \"View in window\" button in NodeDetail sidebar still works identically\n- DescriptionModal uses createPortal to document.body with z-[100]\n- Backdrop click and X button close the modal\n- Markdown rendering with remarkGfm works\n- No visual regression in the modal appearance\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:19:41.231435+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.327949+13:00","closed_at":"2026-02-11T09:23:40.327949+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.2","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:41.234513+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.3","title":"Wire context menu and actions in page.tsx","description":"## Wire context menu and actions in page.tsx\n\n### What\nConnect the new ContextMenu and DescriptionModal components into the main page. Replace the direct CommentTooltip render with a two-phase flow: ContextMenu → action (description modal OR comment tooltip).\n\n### Current code to change\n\n**State (line 184-188):**\n```typescript\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n**Right-click handler (lines 425-430):**\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n },\n []\n);\n```\n\n**CommentTooltip render (lines 997-1010):**\n```tsx\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n**Background click (lines 420-423):**\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n}, []);\n```\n\n### New state\n\nAdd after existing `contextMenu` state (~line 188):\n```typescript\n// Separate state for CommentTooltip (opened from context menu \"Add comment\")\nconst [commentTooltipState, setCommentTooltipState] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n\n// Description modal (opened from context menu \"Show description\")\nconst [descriptionModalNode, setDescriptionModalNode] = useState<GraphNode | null>(null);\n```\n\n### New imports\n\nAdd at top of file:\n```typescript\nimport { ContextMenu } from \"@/components/ContextMenu\";\nimport { DescriptionModal } from \"@/components/DescriptionModal\";\n```\n\n### Changes to handleNodeRightClick\n\nNo change needed — it still sets `contextMenu` state. But now `contextMenu` renders `ContextMenu` instead of `CommentTooltip`.\n\n### Changes to handleBackgroundClick\n\nAlso clear the new states:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n setCommentTooltipState(null);\n}, []);\n```\n\nNote: do NOT clear `descriptionModalNode` on background click — the modal has its own backdrop click handler.\n\n### Replace CommentTooltip render block (lines 997-1010)\n\nReplace the entire block with:\n\n```tsx\n{/* Right-click context menu */}\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => {\n setDescriptionModalNode(contextMenu.node);\n setContextMenu(null);\n }}\n onAddComment={() => {\n setCommentTooltipState({\n node: contextMenu.node,\n x: contextMenu.x,\n y: contextMenu.y,\n });\n setContextMenu(null);\n }}\n onClose={() => setContextMenu(null)}\n />\n)}\n\n{/* Comment tooltip (opened from context menu \"Add comment\") */}\n{commentTooltipState && (\n <CommentTooltip\n node={commentTooltipState.node}\n x={commentTooltipState.x}\n y={commentTooltipState.y}\n onClose={() => setCommentTooltipState(null)}\n onSubmit={async (text) => {\n await handlePostComment(commentTooltipState.node.id, text);\n setCommentTooltipState(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(commentTooltipState.node.id)}\n />\n)}\n\n{/* Description modal (opened from context menu \"Show description\") */}\n{descriptionModalNode && (\n <DescriptionModal\n node={descriptionModalNode}\n onClose={() => setDescriptionModalNode(null)}\n />\n)}\n```\n\n### Placement in JSX\n\nThe three blocks above should go at the same location where the CommentTooltip currently renders (around line 997, just before `</div>` closing the graph area div at line 1012).\n\nThe `DescriptionModal` uses createPortal to document.body with z-[100], so its placement in the JSX tree does not matter for visual layering. But keeping it near the other overlays is cleaner.\n\n### Edge cases\n\n1. **Right-click while context menu is open**: Existing handler overwrites `contextMenu` state — ContextMenu repositions. Works correctly.\n2. **Right-click while CommentTooltip is open**: The `handleNodeRightClick` sets `contextMenu` which shows ContextMenu. The CommentTooltip from a previous action stays open (its state is separate). The user can dismiss CommentTooltip via its own Escape/click-outside, or just interact with the new context menu. To be cleaner, we should clear `commentTooltipState` when a new right-click happens:\n ```typescript\n const handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n setCommentTooltipState(null); // dismiss any open comment tooltip\n },\n []\n );\n ```\n3. **Right-click while description modal is open**: The modal has z-[100] with backdrop. A right-click on the graph behind the backdrop would not fire (backdrop captures click). If the modal IS open and user clicks backdrop to dismiss it, then right-clicks a node, the normal flow happens. No special handling needed.\n4. **Node with no description**: If user picks \"Show description\" on a node without a description, `DescriptionModal` receives a node with `node.description` being undefined/empty. The component should handle this gracefully — show a \"No description\" message, or the ContextMenu could disable/hide the option. **Recommended: hide \"Show description\" if `!node.description`** — add a `hasDescription` check in ContextMenu.\n\n### Update ContextMenu to conditionally show \"Show description\"\n\nPass `hasDescription` or check `node.description` inside ContextMenu. If no description, either:\n- (a) Hide the item entirely (cleaner)\n- (b) Show it grayed out / disabled\n\n**Recommended: (a) hide it.** If the node has no description, the context menu shows only \"Add comment\". If it has a description, both items show.\n\nTo handle this: ContextMenu already receives the `node` prop. It can check `node.description` internally:\n```tsx\n{node.description && (\n <button onClick={onShowDescription} ...>Show description</button>\n)}\n<button onClick={onAddComment} ...>Add comment</button>\n```\n\nBut wait — if a node has no description and the context menu only shows 1 item, the right-click context menu is pointless overhead (same as before — just open CommentTooltip directly). \n\n**Better approach:** In `handleNodeRightClick`, if the node has no description, skip the context menu and directly open the comment tooltip:\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setCommentTooltipState(null);\n if (!node.description) {\n // No description → skip context menu, open comment tooltip directly\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n },\n []\n);\n```\n\nThis preserves the existing UX for nodes without descriptions (identical to current behavior) and only shows the context menu when there is a meaningful choice.\n\n### Files to edit\n- `app/page.tsx`:\n - Add imports for ContextMenu and DescriptionModal\n - Add `commentTooltipState` and `descriptionModalNode` state\n - Update `handleNodeRightClick` to check for description\n - Update `handleBackgroundClick` to clear new states\n - Replace CommentTooltip render block with ContextMenu + CommentTooltip + DescriptionModal\n\n### Acceptance criteria\n- Right-click a node WITH description → context menu with 2 items\n- Right-click a node WITHOUT description → CommentTooltip opens directly (no context menu)\n- \"Show description\" → description modal opens, context menu closes\n- \"Add comment\" → CommentTooltip opens at same position, context menu closes\n- Right-click another node while CommentTooltip is open → CommentTooltip closes, new context menu opens\n- Background click clears context menu and comment tooltip\n- Escape closes whichever overlay is topmost\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:20:21.369074+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.50276+13:00","closed_at":"2026-02-11T09:23:40.50276+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:21.370692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.1","type":"blocks","created_at":"2026-02-11T09:20:21.372378+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.2","type":"blocks","created_at":"2026-02-11T09:20:21.374047+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.4","title":"Build verify and push context menu feature","description":"## Build verify and push\n\n### What\nFinal task: run pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.1\nbd close beads-map-z5w.2\nbd close beads-map-z5w.3\nbd close beads-map-z5w.4\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add right-click context menu with show description and add comment options (beads-map-z5w)\"\ngit push\n```\n\n### Edge cases to verify\n- Right-click node with description → context menu → \"Show description\" → modal opens\n- Right-click node with description → context menu → \"Add comment\" → CommentTooltip opens\n- Right-click node without description → CommentTooltip opens directly (no context menu)\n- Escape dismisses context menu / comment tooltip / description modal\n- Click outside dismisses context menu / comment tooltip\n- Backdrop click dismisses description modal\n- \"View in window\" in NodeDetail sidebar still works\n- Right-click during timeline replay still works\n- Context menu does not overlap viewport edges\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks and epic closed in beads","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:20:31.850081+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.674292+13:00","closed_at":"2026-02-11T09:23:40.674292+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:31.852407+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w.3","type":"blocks","created_at":"2026-02-11T09:20:31.854127+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.5","title":"Add 'Claim task' menu item to ContextMenu and post claim comment","description":"## Add \"Claim task\" menu item to ContextMenu and post claim comment\n\n### What\nAdd a third option \"Claim task\" to the right-click context menu. When clicked, it posts a comment on the node with the text `@<handle>` (e.g., `@satyam2.climateai.org`). The menu item only appears when the user is authenticated AND the node is not already claimed by anyone.\n\n### Detecting if a node is already claimed\nA node is \"claimed\" if any of its comments has text that starts with `@` and matches a handle pattern. We need to check `commentsByNode.get(nodeId)` to see if any comment text starts with `@`. Since claims are just `@handle`, a simple check is:\n\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\nThis checks: text starts with `@`, and is a single word (no spaces) — i.e., just a handle tag.\n\n### Changes to `components/ContextMenu.tsx`\n\n#### New props:\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClaimTask?: () => void; // NEW — undefined if not authenticated or already claimed\n onClose: () => void;\n}\n```\n\n#### New menu item (after \"Add comment\", before closing `</div>`):\n```tsx\n{onClaimTask && (\n <button\n onClick={onClaimTask}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-t border-zinc-100\"\n >\n <svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z\" />\n </svg>\n Claim task\n </button>\n)}\n```\n\nThe person/user icon SVG is from Heroicons (user outline).\n\nAlso add `border-t border-zinc-100` to the \"Add comment\" button when \"Claim task\" follows it. Actually simpler: just add `border-t border-zinc-100` to the claim button itself (as shown above), and keep \"Add comment\" unchanged — it already has no bottom border.\n\n### Changes to `app/page.tsx`\n\n#### 1. Add claim handler function (after `handlePostComment`, around line 485):\n```typescript\nconst handleClaimTask = useCallback(\n async (nodeId: string) => {\n if (!session?.handle) return;\n await handlePostComment(nodeId, `@${session.handle}`);\n },\n [session?.handle, handlePostComment]\n);\n```\n\nThis reuses the existing `handlePostComment` which:\n- POSTs to `/api/records` with collection `org.impactindexer.review.comment`\n- Creates a comment with `subject.uri = \"beads:<nodeId>\"` and `text = \"@handle\"`\n- Calls `refetchComments()` to update the UI\n\n#### 2. Add `isNodeClaimed` helper (at module level or as a function in page.tsx):\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n // A claim comment is just \"@handle\" — starts with @ and has no spaces\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\n#### 3. Update ContextMenu render (around line 1022):\n```tsx\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => { ... }} // unchanged\n onAddComment={() => { ... }} // unchanged\n onClaimTask={\n isAuthenticated && !isNodeClaimed(commentsByNode.get(contextMenu.node.id))\n ? () => {\n handleClaimTask(contextMenu.node.id);\n setContextMenu(null);\n }\n : undefined\n }\n onClose={() => setContextMenu(null)}\n />\n)}\n```\n\nWhen `onClaimTask` is undefined, the ContextMenu hides the \"Claim task\" button.\n\n### Edge cases\n1. **Not authenticated**: `onClaimTask` is undefined → button hidden\n2. **Already claimed**: `isNodeClaimed` returns true → button hidden\n3. **User claims their own node**: Works fine, comment is posted\n4. **Node with no description + not authenticated**: Right-click opens CommentTooltip directly (existing behavior in `handleNodeRightClick` which checks `!node.description`)\n5. **Node with no description + authenticated + not claimed**: Currently skips context menu. Need to update `handleNodeRightClick` to show context menu even for nodes without description IF the user is authenticated (so they can see \"Claim task\"). Update the condition:\n ```typescript\n if (!node.description && !isAuthenticated) {\n // No description and not logged in → only action is comment → skip menu\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n ```\n Wait, but if `isAuthenticated` but node has no description AND is already claimed, the menu would show just \"Add comment\" which is pointless overhead. The clean rule is: show context menu if there are 2+ items to choose from. For now, keep it simple: always show context menu when authenticated (even if only \"Add comment\" + \"Claim task\", or just \"Add comment\" if claimed). The overhead of one extra click is fine for the authenticated UX.\n\n### Files to edit\n- `components/ContextMenu.tsx` — add `onClaimTask` prop and conditional button\n- `app/page.tsx` — add `handleClaimTask`, `isNodeClaimed` helper, update ContextMenu render, update `handleNodeRightClick` condition\n\n### Acceptance criteria\n- \"Claim task\" appears in context menu when authenticated AND node not already claimed\n- \"Claim task\" hidden when not authenticated OR node already claimed\n- Clicking \"Claim task\" posts a comment `@<handle>` on the node\n- Comments refetch after claiming\n- Context menu closes after claiming\n- When authenticated, context menu always shows (even for nodes without description)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:47:43.132495+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.219995+13:00","closed_at":"2026-02-11T09:54:17.219995+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.5","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:47:43.133427+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.6","title":"Compute claimed-node avatar map from comments","description":"## Compute claimed-node avatar map from comments\n\n### What\nDerive a `Map<string, { avatar?: string; handle: string }>` from `allComments` that maps node IDs to the claimant profile info. This map is then passed to `BeadsGraph` for rendering the avatar on the canvas.\n\n### How claims are detected\nA claim comment has text that starts with `@` and is a single word (no spaces). For example: `@satyam2.climateai.org`. Only the first claim per node counts (one claim only).\n\nThe comment object (`BeadsComment`) already has the resolved profile info: `handle`, `avatar`, `displayName`, `did`. So when we find a claim comment, we already have the avatar URL.\n\n### Implementation in `app/page.tsx`\n\n#### 1. Add a useMemo to compute claimed nodes (after `commentsByNode` is available):\n\n```typescript\n// Compute claimed node avatars from comments\n// A claim comment has text \"@handle\" (starts with @, no spaces)\nconst claimedNodeAvatars = useMemo(() => {\n const map = new Map<string, { avatar?: string; handle: string }>();\n if (!allComments) return map;\n for (const comment of allComments) {\n // Skip if this node already has a claimant (first claim wins)\n if (map.has(comment.nodeId)) continue;\n const text = comment.text.trim();\n if (text.startsWith(\"@\") && text.indexOf(\" \") === -1) {\n map.set(comment.nodeId, {\n avatar: comment.avatar,\n handle: comment.handle,\n });\n }\n }\n return map;\n}, [allComments]);\n```\n\nNote: `allComments` is the flat array from `useBeadsComments()` (already available in page.tsx at line 179). It contains all comments across all nodes, each with resolved profile info.\n\n#### 2. Pass to BeadsGraph:\n\n```tsx\n<BeadsGraph\n // ... existing props ...\n claimedNodeAvatars={claimedNodeAvatars}\n/>\n```\n\n#### 3. Add prop to BeadsGraphProps:\n\nIn `components/BeadsGraph.tsx`, add to the props interface:\n```typescript\nclaimedNodeAvatars?: Map<string, { avatar?: string; handle: string }>;\n```\n\nAnd add a ref to sync it (same pattern as `commentedNodeIdsRef`):\n```typescript\nconst claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string }>>(\n claimedNodeAvatars || new Map()\n);\n\nuseEffect(() => {\n claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map();\n refreshGraph(graphRef);\n}, [claimedNodeAvatars]);\n```\n\n### Why use a ref in BeadsGraph\nSame reason as `commentedNodeIdsRef`: the `paintNode` callback has `[]` dependencies (never recreated). It reads from refs, not from props or state. If we used props directly, we would need to add `claimedNodeAvatars` to the paintNode dependency array, which would cause the ForceGraph component to re-render and re-heat the simulation. The ref pattern avoids this.\n\n### Data flow summary\n```\nuseBeadsComments() → allComments (flat array with resolved profiles)\n ↓\nuseMemo → claimedNodeAvatars: Map<nodeId, { avatar, handle }>\n ↓\n<BeadsGraph claimedNodeAvatars={...}>\n ↓\nclaimedNodeAvatarsRef.current (ref, synced via useEffect)\n ↓\npaintNode reads claimedNodeAvatarsRef.current.get(nodeId)\n```\n\n### Files to edit\n- `app/page.tsx` — add `claimedNodeAvatars` useMemo, pass to BeadsGraph\n- `components/BeadsGraph.tsx` — add `claimedNodeAvatars` prop, add ref + sync effect\n\n### Acceptance criteria\n- `claimedNodeAvatars` correctly maps node IDs to claimant profile info\n- Only the first claim comment per node is used\n- Map updates reactively when comments change (useMemo dependency on allComments)\n- BeadsGraph receives the map and syncs it to a ref\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:48:05.021917+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.341949+13:00","closed_at":"2026-02-11T09:54:17.341949+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:05.022796+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w.5","type":"blocks","created_at":"2026-02-11T09:48:05.023797+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.7","title":"Draw claimant avatar on canvas nodes in BeadsGraph paintNode","description":"## Draw claimant avatar on canvas nodes in BeadsGraph paintNode\n\n### What\nWhen a node has a claimant (from `claimedNodeAvatarsRef`), draw their profile picture as a small circular avatar at the bottom-right of the node on the canvas. If no avatar URL is available, draw a fallback circle with the first letter of the handle.\n\n### Avatar image cache\nCanvas `ctx.drawImage()` requires an `HTMLImageElement`. We need to pre-load avatar images and cache them. Use a **module-level cache** (outside the component, like the existing `_ForceGraph2DModule` pattern) to persist across re-renders:\n\n```typescript\n// Module-level avatar image cache\nconst avatarImageCache = new Map<string, HTMLImageElement | \"loading\" | \"failed\">();\n\nfunction getAvatarImage(url: string, onLoad: () => void): HTMLImageElement | null {\n const cached = avatarImageCache.get(url);\n if (cached === \"loading\" || cached === \"failed\") return null;\n if (cached) return cached;\n\n // Start loading\n avatarImageCache.set(url, \"loading\");\n const img = new Image();\n img.crossOrigin = \"anonymous\"; // Required for canvas drawImage with external URLs\n img.onload = () => {\n avatarImageCache.set(url, img);\n onLoad(); // Trigger a canvas redraw\n };\n img.onerror = () => {\n avatarImageCache.set(url, \"failed\");\n };\n img.src = url;\n return null;\n}\n```\n\nThe `onLoad` callback should call `refreshGraph(graphRef)` to trigger a canvas redraw once the image is loaded. We need `graphRef` accessible from the cache callback. Two approaches:\n\n**Approach A:** Store `graphRef` in a module-level variable that gets set on component mount. Ugly but simple.\n\n**Approach B:** Instead of `onLoad` callback, just call `refreshGraph` from within `paintNode` when cache state changes. But `paintNode` runs every frame anyway, so once the image loads and is in cache, the next frame will pick it up. The issue is that we need ONE extra redraw after the image loads to show it. Solution: in `getAvatarImage`, when image loads, set a module-level flag `avatarCacheDirty = true`. In `paintNode`, if `avatarCacheDirty`, call `refreshGraph` once and reset the flag. Actually this is complicated.\n\n**Approach C (recommended):** The simplest approach. In `paintNode`, just try to get the cached image. If not cached yet, start loading and draw fallback. On next `paintNode` call (which happens on every frame when force simulation is running, or on next user interaction), the image will be ready. For the case where simulation has settled (no movement), the image load triggers no redraw. Fix: attach `img.onload` that triggers `refreshGraph(graphRef)`. Since `graphRef` is available in the component scope, pass a `refreshFn` to the cache function.\n\nActually, the cleanest approach: keep the image cache at module level, but have `paintNode` call a helper that uses the graphRef from the component:\n\n```typescript\n// Inside the BeadsGraph component:\nconst avatarRefreshRef = useRef<() => void>(() => {});\nuseEffect(() => {\n avatarRefreshRef.current = () => refreshGraph(graphRef);\n}, []);\n```\n\nThen in paintNode:\n```typescript\nconst claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id);\nif (claimInfo && globalScale > 0.4) {\n const avatarSize = Math.min(8, Math.max(4, 10 / globalScale));\n const avatarX = node.x + animatedSize * 0.7;\n const avatarY = node.y + animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = Math.min(opacity, 0.95);\n\n // Circular clipping path for avatar\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n\n if (claimInfo.avatar) {\n const img = getAvatarImage(claimInfo.avatar, () => avatarRefreshRef.current());\n if (img) {\n ctx.save();\n ctx.clip();\n ctx.drawImage(\n img,\n avatarX - avatarSize,\n avatarY - avatarSize,\n avatarSize * 2,\n avatarSize * 2\n );\n ctx.restore();\n } else {\n // Loading fallback — gray circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n } else {\n // No avatar URL — fallback circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n\n // White border ring around avatar for contrast\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n ctx.strokeStyle = \"#ffffff\";\n ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);\n ctx.stroke();\n\n ctx.restore();\n}\n```\n\n### Fallback avatar drawing\n```typescript\nfunction drawAvatarFallback(\n ctx: CanvasRenderingContext2D,\n x: number, y: number, radius: number,\n handle: string, globalScale: number\n) {\n // Light gray circle\n ctx.beginPath();\n ctx.arc(x, y, radius, 0, Math.PI * 2);\n ctx.fillStyle = \"#e4e4e7\"; // zinc-200\n ctx.fill();\n\n // First letter of handle\n const letter = handle.replace(\"@\", \"\").charAt(0).toUpperCase();\n const fontSize = Math.min(7, Math.max(3, radius * 1.3));\n ctx.font = `600 ${fontSize}px \"Inter\", system-ui, sans-serif`;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillStyle = \"#71717a\"; // zinc-500\n ctx.fillText(letter, x, y + 0.3);\n}\n```\n\n### Placement: bottom-right of node\nThe existing comment badge is at top-right:\n```\nbadgeX = node.x + animatedSize * 0.75 // top-right\nbadgeY = node.y - animatedSize * 0.75 // top-right (negative Y = up)\n```\n\nFor the avatar at bottom-right:\n```\navatarX = node.x + animatedSize * 0.7 // right\navatarY = node.y + animatedSize * 0.7 // bottom (positive Y = down)\n```\n\n### Drawing order in paintNode\nAdd the avatar drawing AFTER the comment badge (line ~746), before the `ctx.restore()` at the end of paintNode. The order:\n1. ... existing drawing (body, ring, label, etc.) ...\n2. Comment count badge at top-right (lines 716-746) — existing\n3. **Claimant avatar at bottom-right** — NEW (lines ~748+)\n\n### `crossOrigin = \"anonymous\"` \nRequired because avatar URLs are from `cdn.bsky.app` (external domain). Without this, canvas becomes \"tainted\" and some operations may fail. The `crossOrigin = \"anonymous\"` on the Image element tells the browser to request the image with CORS headers. Bluesky CDN supports CORS.\n\n### Visibility threshold\nSame as comment badges: `globalScale > 0.4`. When zoomed out too far, avatars are invisible (too small to see anyway).\n\n### Files to edit\n- `components/BeadsGraph.tsx`:\n - Add module-level `avatarImageCache` and `getAvatarImage()` function\n - Add module-level `drawAvatarFallback()` function\n - Add `avatarRefreshRef` inside component\n - Add avatar drawing section in `paintNode` after comment badge\n - Destructure `claimedNodeAvatars` from props (already added in .6)\n\n### Acceptance criteria\n- Claimed nodes show a small circular avatar at bottom-right\n- Avatar loads asynchronously and appears after image loads\n- Fallback shows gray circle with first letter of handle when no avatar URL\n- Fallback shows while image is loading\n- Avatar has white border ring for contrast\n- Avatar only visible when zoomed in enough (globalScale > 0.4)\n- Avatar scales appropriately with zoom level\n- No canvas tainting errors (crossOrigin = \"anonymous\")\n- Multiple claimed nodes each show their respective claimant avatars\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:48:47.291793+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.463406+13:00","closed_at":"2026-02-11T09:54:17.463406+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:47.292966+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w.6","type":"blocks","created_at":"2026-02-11T09:48:47.301709+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.8","title":"Build verify and push claim task feature","description":"## Build verify and push claim task feature\n\n### What\nFinal task: run pnpm build, fix any errors, close beads tasks, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.5\nbd close beads-map-z5w.6\nbd close beads-map-z5w.7\nbd close beads-map-z5w.8\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add claim task feature: right-click to claim with avatar on node (beads-map-z5w.5-8)\"\ngit push\n```\n\n### Edge cases to verify\n1. **Not authenticated** → \"Claim task\" not in context menu\n2. **Already claimed by someone** → \"Claim task\" not in context menu\n3. **Claim with avatar** → small circular avatar appears at bottom-right of node\n4. **Claim without avatar (no profile pic)** → gray circle with first letter of handle\n5. **Multiple nodes claimed by different users** → each shows correct avatar\n6. **Zoom out far** → avatars disappear (globalScale < 0.4)\n7. **Avatar image fails to load** → fallback circle shown\n8. **Timeline replay** → claimed avatars still show on visible nodes\n9. **Comment badges + avatar** → both visible (top-right badge, bottom-right avatar, no overlap)\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks (.5, .6, .7, .8) and epic closed in beads","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:48:59.026151+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.602817+13:00","closed_at":"2026-02-11T09:54:17.602817+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:59.028136+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w.7","type":"blocks","created_at":"2026-02-11T09:48:59.029382+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.9","title":"Fix claim avatar loading: optimistic display, Bluesky API fallback, CORS fix","description":"## Fix claim avatar loading\n\n### Problem\nAfter implementing the claim feature (.5–.8), two bugs were found:\n1. **Avatar only appeared after page refresh**, not immediately after claiming — the Hypergoat indexer has latency before it indexes new comments, so `refetchComments()` returned stale data.\n2. **Avatar image never loaded (only fallback letter \"S\" shown)** — two causes:\n a. `session.avatar` was undefined (OAuth profile fetch failed silently during login)\n b. `crossOrigin = \"anonymous\"` on the HTMLImageElement caused CORS rejection from Bluesky CDN (`cdn.bsky.app`), triggering `onerror` and permanently caching the image as \"failed\"\n\n### Fixes applied\n\n#### 1. Optimistic claim display (`app/page.tsx`)\n- Added `optimisticClaims` state: `Map<string, { avatar?: string; handle: string }>`\n- `handleClaimTask` immediately sets the claimant avatar in `optimisticClaims` before posting the comment\n- `claimedNodeAvatars` useMemo merges optimistic claims (priority) with comment-derived claims\n- `isNodeClaimed` check in ContextMenu now uses `claimedNodeAvatars.has()` instead of `isNodeClaimed(commentsByNode)` so \"Claim task\" button hides immediately\n- Added 3-second delayed `refetchComments()` after claiming to eventually pick up the indexed comment\n\n#### 2. Bluesky public API avatar fallback (`app/page.tsx`)\n- In `handleClaimTask`, if `session.avatar` is undefined, fetches avatar from `public.api.bsky.app/xrpc/app.bsky.actor.getProfile` using `session.did`\n- This is the same API that `useBeadsComments` uses for profile resolution\n\n#### 3. Removed crossOrigin restriction (`components/BeadsGraph.tsx`)\n- Removed `img.crossOrigin = \"anonymous\"` from `getAvatarImage()` function\n- Canvas `drawImage()` works without CORS — canvas becomes \"tainted\" (cant read pixels back) but we never need `getImageData`/`toDataURL`\n- This fixed the Bluesky CDN image loading failure\n\n### Commits\n- `877b037` Fix claim: optimistic avatar display + delayed refetch for indexer latency\n- `efd6275` Fix claim avatar: remove crossOrigin restriction, fetch avatar from Bluesky public API as fallback\n\n### Files changed\n- `app/page.tsx` — optimisticClaims state, handleClaimTask with API fallback, claimedNodeAvatars merge, isNodeClaimed check\n- `components/BeadsGraph.tsx` — removed crossOrigin from Image element\n\n### Status: DONE","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:25:55.858566+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:26:45.538096+13:00","closed_at":"2026-02-11T10:26:45.538096+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:25:55.859836+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w.8","type":"blocks","created_at":"2026-02-11T10:25:55.860815+13:00","created_by":"daviddao"}]}],"dependencies":[{"issue_id":"beads-map-21c","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.244292+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.1","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:27.389228+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:34.243147+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c.9","type":"blocks","created_at":"2026-02-11T02:07:38.658953+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.11","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:07.010331+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:14.930729+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c.11","type":"blocks","created_at":"2026-02-11T02:12:15.066268+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.2","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:41.591486+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:09.027391+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.440174+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.4","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:40.961908+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:28.830802+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.557476+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.3","type":"blocks","created_at":"2026-02-11T01:51:32.669716+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.4","type":"blocks","created_at":"2026-02-11T01:51:32.780421+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:44.216474+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c.5","type":"blocks","created_at":"2026-02-11T01:51:32.89299+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.7","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:49.847169+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:58.652019+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c.7","type":"blocks","created_at":"2026-02-11T02:01:02.543151+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.9","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:25.358814+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.39819+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.995145+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.706041+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-mq9","type":"blocks","created_at":"2026-02-10T23:19:29.394542+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.316735+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-m1o","type":"blocks","created_at":"2026-02-10T23:19:28.909987+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.410329+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.1","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.795118+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.923002+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:24.073985+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.7","type":"blocks","created_at":"2026-02-11T12:12:27.830152+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.048183+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:12.799635+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.174711+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:16.524399+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.303116+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:20.162124+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.428287+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.2","type":"blocks","created_at":"2026-02-11T12:12:31.588158+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.4","type":"blocks","created_at":"2026-02-11T12:12:35.542205+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.5","type":"blocks","created_at":"2026-02-11T12:12:39.650845+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.552867+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:09.2907+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.675891+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6.6","type":"blocks","created_at":"2026-02-11T12:12:44.251892+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np","depends_on_id":"beads-map-9d3","type":"blocks","created_at":"2026-02-12T10:39:55.489578+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.1","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:33:56.34421+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:01.699953+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.820355+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:07.490637+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.951842+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.1","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:17.43527+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:27.399923+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.363125+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.3","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:36.643125+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:48.970023+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.478692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.2","type":"blocks","created_at":"2026-02-12T10:50:56.600112+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.3","type":"blocks","created_at":"2026-02-12T10:50:56.718812+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9d3.2","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:40.269874+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9d3.3","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:46.062066+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9d3.4","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:51.725102+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.1","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:23:50.371471+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:08.11827+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.58504+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:14.898283+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.764197+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:20.513717+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.949509+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:29.192269+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.3","type":"blocks","created_at":"2026-02-12T11:24:36.145483+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.4","type":"blocks","created_at":"2026-02-12T11:24:36.312505+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.5","type":"blocks","created_at":"2026-02-12T11:24:36.484971+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.001081+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.1","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:56:38.694406+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:01.112211+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh.1","type":"blocks","created_at":"2026-02-10T23:57:01.113311+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:16.26232+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh.2","type":"blocks","created_at":"2026-02-10T23:57:16.263416+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:32.924539+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:57:32.926286+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:56.263692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh.4","type":"blocks","created_at":"2026-02-10T23:57:56.264726+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:15.699689+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh.5","type":"blocks","created_at":"2026-02-10T23:58:15.700911+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:28.65065+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:58:28.65195+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:49.015822+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.6","type":"blocks","created_at":"2026-02-10T23:58:49.016931+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.7","type":"blocks","created_at":"2026-02-10T23:58:49.017826+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi","depends_on_id":"beads-map-cvh","type":"blocks","created_at":"2026-02-12T10:39:55.083326+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.1","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:20.161533+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.2","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:28.754207+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.3","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:39.227376+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:47.745514+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.253835+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:54.778714+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.395175+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:32:01.725925+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.1","type":"blocks","created_at":"2026-02-11T00:38:43.522633+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.647344+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.3","type":"blocks","created_at":"2026-02-11T00:38:43.773371+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.4","type":"blocks","created_at":"2026-02-11T00:38:43.895718+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.5","type":"blocks","created_at":"2026-02-11T00:38:44.013093+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.7","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:45:37.232842+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.476318+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-7j2","type":"blocks","created_at":"2026-02-10T23:19:29.07598+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-2fk","type":"blocks","created_at":"2026-02-10T23:19:29.155362+13:00","created_by":"daviddao"},{"issue_id":"beads-map-gjo","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.148777+13:00","created_by":"daviddao"},{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.553429+13:00","created_by":"daviddao"},{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-ecl","type":"blocks","created_at":"2026-02-10T23:19:29.234083+13:00","created_by":"daviddao"},{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.23277+13:00","created_by":"daviddao"},{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.823723+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-8np","type":"blocks","created_at":"2026-02-12T10:39:55.570556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.652022+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw.1","depends_on_id":"beads-map-mfw","type":"parent-child","created_at":"2026-02-12T10:38:08.455822+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.630363+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-iyn","type":"blocks","created_at":"2026-02-10T23:19:29.312556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg","depends_on_id":"beads-map-dyi","type":"blocks","created_at":"2026-02-11T01:26:33.09446+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.1","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:33.395429+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:55.518315+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.248408+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:16.083107+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.371142+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:35.924466+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.487447+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:04.1688+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.2","type":"blocks","created_at":"2026-02-11T01:26:28.611742+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.3","type":"blocks","created_at":"2026-02-11T01:26:28.725946+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.4","type":"blocks","created_at":"2026-02-11T01:26:28.84169+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:12.402978+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg.5","type":"blocks","created_at":"2026-02-11T01:26:28.956024+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.7","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:36:49.289175+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.328556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.1","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:10.936853+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:08.914469+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w.9","type":"blocks","created_at":"2026-02-11T10:26:08.915791+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:37.013442+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w.10","type":"blocks","created_at":"2026-02-11T10:26:37.015186+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.12","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:47:43.377971+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.2","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:41.234513+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:21.370692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.1","type":"blocks","created_at":"2026-02-11T09:20:21.372378+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.2","type":"blocks","created_at":"2026-02-11T09:20:21.374047+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:31.852407+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w.3","type":"blocks","created_at":"2026-02-11T09:20:31.854127+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.5","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:47:43.133427+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:05.022796+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w.5","type":"blocks","created_at":"2026-02-11T09:48:05.023797+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:47.292966+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w.6","type":"blocks","created_at":"2026-02-11T09:48:47.301709+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:59.028136+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w.7","type":"blocks","created_at":"2026-02-11T09:48:59.029382+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:25:55.859836+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w.8","type":"blocks","created_at":"2026-02-11T10:25:55.860815+13:00","created_by":"daviddao"}],"graphData":{"nodes":[{"id":"beads-map-21c","title":"Timeline replay: scrubber bar to animate project history","description":"## Timeline Replay: scrubber bar to animate project history\n\n### Summary\nAdd a timeline replay feature that lets users watch the project's history unfold. A playback bar at the bottom-right of the graph (replacing the current floating legend hint) provides play/pause, a draggable scrubber, and speed controls. As the virtual clock advances, nodes pop into existence (at their createdAt time), show status changes (at updatedAt), and fade to closed (at closedAt). Links appear when their dependency was created.\n\n### Architecture\n\n**Data layer (`lib/timeline.ts` — new file):**\n- `TimelineEvent` type: `{ time: number, type: 'node-created'|'node-closed'|'link-created', id: string }`\n- `buildTimelineEvents(nodes, links)`: extracts all timestamped events from nodes (createdAt, closedAt) and links (createdAt), sorts chronologically, returns `{ events: TimelineEvent[], minTime: number, maxTime: number }`\n- `filterDataAtTime(allNodes, allLinks, currentTime)`: returns `{ nodes: GraphNode[], links: GraphLink[] }` containing only items visible at `currentTime`. Nodes visible when `createdAt <= currentTime`. Node status = closed if `closedAt && closedAt <= currentTime`, else original status. Links visible when both endpoints visible AND `link.createdAt <= currentTime`.\n\n**Component (`components/TimelineBar.tsx` — new file):**\n- Positioned absolute bottom-right inside BeadsGraph, replaces the floating legend hint\n- Contains: play/pause button (svg icons), horizontal range slider, current date/time label, speed toggle (1x/2x/4x)\n- `requestAnimationFrame` loop advances currentTime when playing\n- Dragging slider pauses playback and updates currentTime\n- Props: `minTime`, `maxTime`, `currentTime`, `isPlaying`, `speed`, `onTimeChange`, `onPlayPause`, `onSpeedChange`\n- Tick marks on slider for event density (optional visual enhancement)\n\n**Wiring (`app/page.tsx` + `components/BeadsGraph.tsx`):**\n- New state in page.tsx: `timelineActive: boolean`, `timelineTime: number`, `timelinePlaying: boolean`, `timelineSpeed: number`\n- New pill button in header (same style as Force/DAG/Comments pills) to toggle timeline mode\n- When timelineActive: compute filtered nodes/links via filterDataAtTime, stamp _spawnTime on newly-visible nodes, pass filtered data to BeadsGraph\n- SSE live updates still accumulate into `data` but filtered view controls what's shown\n- TimelineBar rendered inside BeadsGraph (or as overlay in graph area)\n\n**NodeDetail date format enhancement:**\n- Change formatDate() to include hour:minute — \"Feb 10, 2026 at 11:48\"\n\n**GraphLink.createdAt:**\n- Add optional `createdAt?: string` to GraphLink type\n- Populate from BeadDependency.created_at in buildGraphData()\n\n### Subject areas\n- `lib/types.ts` — GraphLink.createdAt addition\n- `lib/parse-beads.ts` — populate link createdAt\n- `lib/timeline.ts` — new file, pure functions for event extraction and time-filtering\n- `components/TimelineBar.tsx` — new component\n- `components/BeadsGraph.tsx` — render TimelineBar, replace legend hint when timeline active\n- `components/NodeDetail.tsx` — formatDate with time\n- `app/page.tsx` — state, pill button, filtering logic, wiring\n\n### Status at a point in time\nSince we only have createdAt/updatedAt/closedAt (not per-status-change history), the replay shows:\n- Before createdAt: node doesn't exist\n- Between createdAt and closedAt: node shows as \"open\" (original non-closed status)\n- At closedAt: node transitions to \"closed\" status with ripple animation\n- updatedAt: can trigger a subtle pulse to indicate activity\n\n### Speed mapping\n1x = 1 real second per calendar day of project time. 2x = 2 days/sec. 4x = 4 days/sec.\n\n### Dependency chain\n.1 (formatDate) is independent\n.2 (GraphLink.createdAt) is independent\n.3 (timeline.ts) depends on .2 (needs link createdAt)\n.4 (TimelineBar component) is independent (pure UI)\n.5 (wiring in page.tsx + BeadsGraph) depends on .2, .3, .4\n.6 (build verification) depends on .5","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:47:14.847191+13:00","updatedAt":"2026-02-11T02:13:05.329913+13:00","closedAt":"2026-02-11T02:13:05.329913+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":12,"dependentCount":1,"blockerIds":["beads-map-21c.1","beads-map-21c.10","beads-map-21c.11","beads-map-21c.12","beads-map-21c.2","beads-map-21c.3","beads-map-21c.4","beads-map-21c.5","beads-map-21c.6","beads-map-21c.7","beads-map-21c.8","beads-map-21c.9"],"dependentIds":["beads-map-3jy"]},{"id":"beads-map-21c.1","title":"Add hour:minute to date display in NodeDetail","description":"## Add hour:minute to date display in NodeDetail\n\n### What\nChange the formatDate() function in components/NodeDetail.tsx to include hour and minute alongside the existing date.\n\n### Current code (components/NodeDetail.tsx, lines 132-144)\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n } catch {\n return dateStr;\n }\n};\n```\n\nCurrent output: \"Feb 10, 2026\"\n\n### Target output\n\"Feb 10, 2026 at 11:48\"\n\n### Implementation\nReplace the formatDate function body. Use toLocaleDateString for the date part and toLocaleTimeString for the time part:\n\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n const date = d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n const time = d.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n hour12: false,\n });\n return `${date} at ${time}`;\n } catch {\n return dateStr;\n }\n};\n```\n\n### Where it's used\nThe formatDate function is called in three places in the same file (lines 220-258):\n- `formatDate(node.createdAt)` — Created row\n- `formatDate(node.updatedAt)` — Updated row\n- `formatDate(node.closedAt)` — Closed row (conditional)\n\nAll three will automatically pick up the new format.\n\n### Files to edit\n- `components/NodeDetail.tsx` — lines 132-144, formatDate function only\n\n### Acceptance criteria\n- Date rows in NodeDetail show \"Feb 10, 2026 at 11:48\" format\n- Hours use 24h format (no AM/PM) for compactness\n- No other files changed\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:47:27.387689+13:00","updatedAt":"2026-02-11T01:53:53.448072+13:00","closedAt":"2026-02-11T01:53:53.448072+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.10","title":"Build verify and push timeline link/preamble/speed fix","description":"## Build verify and push\n\nRun pnpm build, fix any type errors, commit and push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.10\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline: links with both nodes, empty preamble, 2s per event (beads-map-21c.9)\"\ngit push\n```\n\n### Edge cases\n- Link between two nodes that appear on the same step — link should appear immediately\n- Preamble (step -1) shows empty canvas, then step 0 shows first event\n- Scrubbing slider to step 0 shows first event (not preamble)\n- Speed change during playback — interval restarts correctly\n- Toggle off during preamble — clears state\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:07:34.241545+13:00","updatedAt":"2026-02-11T02:09:44.108526+13:00","closedAt":"2026-02-11T02:09:44.108526+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.9"]},{"id":"beads-map-21c.11","title":"Fix timeline replay links: normalize source/target to string IDs before diff/merge","description":"## Fix timeline replay links: normalize source/target to string IDs\n\n### Bug\nLinks during timeline replay appear but are NOT connected to their nodes — they float/draw to wrong positions.\n\n### Root cause\nreact-force-graph-2d mutates link objects in-place, replacing link.source and link.target from string IDs to object references pointing to actual node objects in the simulation.\n\nWhen filterDataAtTime() is called with data.graphData.links, those links already have mutated source/target (object refs pointing to the MAIN graph's node objects). These mutated links flow through mergeBeadsData() and get passed to BeadsGraph as timeline data. ForceGraph2D sees already-resolved object references and uses them directly — but they point to the WRONG node objects (main graph nodes, not timeline nodes). Links draw to invisible ghost positions.\n\n### Fix\nIn filterDataAtTime() in lib/timeline.ts, line 126, normalize source/target back to string IDs when pushing links into the result:\n\nCurrent code (lib/timeline.ts, lines 111-128):\n```typescript\nfor (const link of allLinks) {\n const src =\n typeof link.source === \"object\"\n ? (link.source as { id: string }).id\n : link.source;\n const tgt =\n typeof link.target === \"object\"\n ? (link.target as { id: string }).id\n : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n links.push(link); // <-- BUG: pushes mutated link with object refs\n}\n```\n\nFix — replace the last line:\n```typescript\n links.push({\n ...link,\n source: src,\n target: tgt,\n });\n```\n\nThe src and tgt variables are already extracted as string IDs (lines 112-118). By spreading a new link object with string source/target, d3-force will resolve them to the correct node objects in the timeline's node array.\n\n### Files to edit\n- lib/timeline.ts — line 126: replace links.push(link) with links.push({ ...link, source: src, target: tgt })\n\n### Acceptance criteria\n- During timeline replay, links visually connect to their nodes\n- Links draw correctly at every step, including first appearance and after scrubbing\n- pnpm build passes","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:12:07.008838+13:00","updatedAt":"2026-02-11T02:13:05.073793+13:00","closedAt":"2026-02-11T02:13:05.073793+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.12"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.12","title":"Build verify and push timeline link connection fix","description":"## Build verify and push\n\npnpm build, close tasks, sync, commit, push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.11\nbd close beads-map-21c.12\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline links: normalize source/target to string IDs (beads-map-21c.11)\"\ngit push\n```\n\n### Acceptance criteria\n- pnpm build passes\n- git status clean after push","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:12:14.928323+13:00","updatedAt":"2026-02-11T02:13:05.202556+13:00","closedAt":"2026-02-11T02:13:05.202556+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.11"]},{"id":"beads-map-21c.2","title":"Add createdAt field to GraphLink type and populate from dependency data","description":"## Add createdAt to GraphLink\n\n### What\nGraphLink currently has no timestamp. Add an optional createdAt field and populate it from BeadDependency.created_at so links can be time-filtered in the timeline replay.\n\n### Current GraphLink type (lib/types.ts, lines 70-78)\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 1: lib/types.ts\nAdd `createdAt?: string;` to GraphLink, after `type` and before `_spawnTime`:\n\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n createdAt?: string; // <-- ADD THIS: ISO 8601 from BeadDependency.created_at\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 2: lib/parse-beads.ts\nIn buildGraphData(), the link mapping (lines 161-175) currently drops created_at:\n\n```typescript\nconst links: GraphLink[] = dependencies\n .filter(\n (d) =>\n (d.type === \"blocks\" || d.type === \"parent-child\") &&\n issueMap.has(d.issue_id) &&\n issueMap.has(d.depends_on_id)\n )\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n }));\n```\n\nAdd `createdAt: d.created_at,` to the .map() return object:\n\n```typescript\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n createdAt: d.created_at, // <-- ADD THIS\n }));\n```\n\n### Files to edit\n- `lib/types.ts` — add createdAt to GraphLink interface\n- `lib/parse-beads.ts` — add createdAt to link mapping in buildGraphData()\n\n### What NOT to change\n- Do NOT change BeadDependency type (it already has created_at)\n- Do NOT change diff-beads.ts (link diffing uses linkKey which only considers source/target/type)\n- Do NOT change mergeBeadsData in page.tsx (it spreads link objects, so createdAt will be preserved)\n\n### Acceptance criteria\n- GraphLink.createdAt is optional string type\n- Links built from JSONL data carry their dependency creation timestamp\n- pnpm build passes\n- No runtime behavior changes (createdAt is informational until timeline feature uses it)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:47:41.589951+13:00","updatedAt":"2026-02-11T01:53:53.622113+13:00","closedAt":"2026-02-11T01:53:53.622113+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-21c.3","beads-map-21c.5"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.3","title":"Build timeline event extraction and time-filter logic (lib/timeline.ts)","description":"## Build timeline.ts: event extraction and time-filtering\n\n### What\nCreate a new file lib/timeline.ts with pure functions for:\n1. Extracting a sorted list of temporal events from graph data\n2. Filtering nodes/links to only show what exists at a given point in time\n\n### New file: lib/timeline.ts\n\n```typescript\nimport type { GraphNode, GraphLink } from \"./types\";\n\n// --- Types ---\n\nexport type TimelineEventType = \"node-created\" | \"node-closed\" | \"link-created\";\n\nexport interface TimelineEvent {\n time: number; // unix ms\n type: TimelineEventType;\n id: string; // node ID or link key (source->target)\n}\n\nexport interface TimelineRange {\n events: TimelineEvent[];\n minTime: number; // earliest event (unix ms)\n maxTime: number; // latest event (unix ms)\n}\n\n// --- Event extraction ---\n\n/**\n * Extract all temporal events from nodes and links, sorted chronologically.\n *\n * Events:\n * - node-created: from node.createdAt\n * - node-closed: from node.closedAt (if present)\n * - link-created: from link.createdAt (if present)\n *\n * Nodes/links missing timestamps are skipped.\n * Returns { events, minTime, maxTime }.\n */\nexport function buildTimelineEvents(\n nodes: GraphNode[],\n links: GraphLink[]\n): TimelineRange {\n const events: TimelineEvent[] = [];\n\n for (const node of nodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (!isNaN(createdMs)) {\n events.push({ time: createdMs, type: \"node-created\", id: node.id });\n }\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs)) {\n events.push({ time: closedMs, type: \"node-closed\", id: node.id });\n }\n }\n }\n\n for (const link of links) {\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs)) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n events.push({ time: linkMs, type: \"link-created\", id: `${src}->${tgt}` });\n }\n }\n }\n\n events.sort((a, b) => a.time - b.time);\n\n const times = events.map(e => e.time);\n const minTime = times.length > 0 ? times[0] : Date.now();\n const maxTime = times.length > 0 ? times[times.length - 1] : Date.now();\n\n return { events, minTime, maxTime };\n}\n\n// --- Time filtering ---\n\n/**\n * Filter nodes and links to only include items visible at `currentTime`.\n *\n * Node visibility: createdAt <= currentTime (parsed as Date).\n * Node status override: if closedAt && closedAt <= currentTime, force status to \"closed\".\n * Link visibility: both source and target nodes are visible AND link.createdAt <= currentTime.\n * If link has no createdAt, it appears when both endpoints are visible.\n *\n * Returns shallow copies of node objects with status potentially overridden.\n * Does NOT mutate input arrays.\n */\nexport function filterDataAtTime(\n allNodes: GraphNode[],\n allLinks: GraphLink[],\n currentTime: number\n): { nodes: GraphNode[]; links: GraphLink[] } {\n // Filter visible nodes\n const visibleNodeIds = new Set<string>();\n const nodes: GraphNode[] = [];\n\n for (const node of allNodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (isNaN(createdMs) || createdMs > currentTime) continue;\n\n visibleNodeIds.add(node.id);\n\n // Check if node should show as closed at this time\n let status = node.status;\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs) && closedMs <= currentTime) {\n status = \"closed\";\n } else if (node.status === \"closed\") {\n // Node is closed in current data but we're before closedAt — show as open\n status = \"open\";\n }\n } else if (node.status === \"closed\") {\n // Closed but no closedAt timestamp — show as closed always (legacy data)\n status = \"closed\";\n }\n\n // Shallow copy with potentially overridden status\n if (status !== node.status) {\n nodes.push({ ...node, status } as GraphNode);\n } else {\n nodes.push(node);\n }\n }\n\n // Filter visible links\n const links: GraphLink[] = [];\n for (const link of allLinks) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n // If link has createdAt, check it\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n }\n\n links.push(link);\n }\n\n return { nodes, links };\n}\n```\n\n### Key design decisions\n- Pure functions, no React, no side effects — easy to test\n- filterDataAtTime returns shallow copies when status is overridden, original objects when not (preserves x/y positions from force simulation)\n- Link source/target can be string or object (force-graph mutates these) — handle both\n- Links without createdAt appear as soon as both endpoints are visible (graceful fallback)\n- For nodes that are \"closed\" in current data but we're scrubbing to before closedAt, we show them as \"open\"\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt must exist in the type)\n\n### Files to create\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- buildTimelineEvents extracts events from nodes and links, sorted by time\n- filterDataAtTime correctly shows only nodes/links that exist at a given time\n- Closed nodes appear as \"open\" when scrubbing before their closedAt\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:48:09.026383+13:00","updatedAt":"2026-02-11T01:54:26.759387+13:00","closedAt":"2026-02-11T01:54:26.759387+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-21c.5"],"dependentIds":["beads-map-21c","beads-map-21c.2"]},{"id":"beads-map-21c.4","title":"Create TimelineBar component with play/pause, scrubber, speed controls","description":"## TimelineBar component\n\n### What\nA horizontal playback bar positioned at the bottom-right of the graph area. Replaces the current floating legend hint when timeline mode is active. Contains play/pause, a scrubber slider, date/time display, and speed toggle.\n\n### Layout & positioning\nThe TimelineBar replaces the existing floating legend hint (currently at bottom-4 right-4 z-10 in BeadsGraph.tsx lines 1227-1234):\n```tsx\n{!selectedNode && !hoveredNode && (\n <div className=\"absolute bottom-4 right-4 z-10 text-xs text-zinc-400 bg-white/90 ...\">\n Node size = dependency importance | Color = status | Ring = project\n </div>\n)}\n```\n\nThe TimelineBar should be rendered in the same position: `absolute bottom-4 right-4 z-10` with similar styling (`bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm`). It should NOT overlap with the minimap (bottom-4 left-4, 160x120px).\n\n### New file: components/TimelineBar.tsx\n\nProps interface:\n```typescript\ninterface TimelineBarProps {\n minTime: number; // earliest event timestamp (unix ms)\n maxTime: number; // latest event timestamp (unix ms)\n currentTime: number; // current playback position (unix ms)\n isPlaying: boolean;\n speed: number; // 1, 2, or 4\n onTimeChange: (time: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\n### Visual design\n```\n┌──────────────────────────────────────────────────────────┐\n│ ▶ ──────────────●────────────────── Feb 10, 2026 2x │\n└──────────────────────────────────────────────────────────┘\n```\n\nElements left to right:\n1. **Play/Pause button**: SVG icon, toggle between play (triangle) and pause (two bars). Size: w-6 h-6. Color: emerald-500 when playing, zinc-500 when paused.\n2. **Scrubber slider**: HTML `<input type=\"range\">` styled with Tailwind. Min=minTime, max=maxTime, value=currentTime, step=1. Full width (flex-1). Track: h-1 bg-zinc-200 rounded. Thumb: w-3 h-3 bg-emerald-500 rounded-full. Filled portion: emerald-500.\n3. **Current date/time label**: Shows the date at the scrubber position. Format: \"Feb 10, 2026\" (compact). Font: text-xs text-zinc-500 font-medium. Fixed width to prevent layout shift (~100px).\n4. **Speed button**: Cycles through 1x -> 2x -> 4x -> 1x on click. Shows current speed as text. Font: text-xs font-medium. Color: emerald-500 background pill when not 1x, zinc border when 1x. Style: same pill as layout buttons.\n\n### Styling\n- Container: `bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2`\n- Width: auto-sized, roughly 300-400px, using `min-w-[300px] max-w-[480px]`\n- Height: compact, ~40px\n- Flex row layout: `flex items-center gap-2`\n- On mobile (sm:hidden for the full bar, show just play/pause + date)\n\n### Range input custom styling\nUse CSS in globals.css or inline styles to customize the range slider:\n```css\n/* In globals.css */\n.timeline-slider::-webkit-slider-track {\n height: 4px;\n background: #e4e4e7; /* zinc-200 */\n border-radius: 2px;\n}\n.timeline-slider::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n background: #10b981; /* emerald-500 */\n border-radius: 50%;\n margin-top: -4px;\n cursor: pointer;\n}\n```\n\n### Date formatting\n```typescript\nfunction formatTimelineDate(ms: number): string {\n const d = new Date(ms);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n}\n```\n\n### Play/Pause SVG icons\nPlay icon (triangle pointing right):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M4 2l10 6-10 6V2z\" />\n</svg>\n```\n\nPause icon (two vertical bars):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <rect x=\"3\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n <rect x=\"9.5\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n</svg>\n```\n\n### Interaction behavior\n- Dragging the slider calls onTimeChange(newTime) continuously\n- The component does NOT manage the rAF playback loop — that lives in page.tsx\n- Clicking speed cycles: 1 -> 2 -> 4 -> 1\n- The component is purely controlled (all state via props)\n\n### Files to create\n- `components/TimelineBar.tsx`\n\n### Files to edit\n- `app/globals.css` — add .timeline-slider custom range input styles\n\n### Acceptance criteria\n- TimelineBar renders play/pause button, slider, date label, speed toggle\n- Slider responds to drag, calls onTimeChange\n- Play/pause button calls onPlayPause\n- Speed button cycles through 1x/2x/4x\n- Matches existing UI style (white/90, backdrop-blur, rounded-lg, zinc borders)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:48:40.960221+13:00","updatedAt":"2026-02-11T01:53:53.797119+13:00","closedAt":"2026-02-11T01:53:53.797119+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.5"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.5","title":"Wire timeline into page.tsx and BeadsGraph: state, filtering, animations, pill button","description":"## Wire timeline into page.tsx and BeadsGraph\n\n### What\nThis is the integration task. Connect the timeline data layer (lib/timeline.ts), the TimelineBar component, and the existing graph rendering. Add a pill button to toggle timeline mode, manage playback state, and filter nodes/links based on the virtual clock.\n\n### Overview of changes\n\n**page.tsx** — Add state, pill button, rAF playback loop, filtering, and TimelineBar wiring\n**BeadsGraph.tsx** — Accept optional timeline props, conditionally render TimelineBar instead of legend hint\n\n---\n\n### Change 1: page.tsx — New imports\n\nAdd at top of file:\n```typescript\nimport { buildTimelineEvents, filterDataAtTime } from \"@/lib/timeline\";\nimport type { TimelineRange } from \"@/lib/timeline\";\nimport TimelineBar from \"@/components/TimelineBar\";\n```\n\n### Change 2: page.tsx — New state variables\n\nAdd after existing state declarations (around line 190):\n```typescript\n// Timeline replay state\nconst [timelineActive, setTimelineActive] = useState(false);\nconst [timelineTime, setTimelineTime] = useState(0); // current virtual clock (unix ms)\nconst [timelinePlaying, setTimelinePlaying] = useState(false);\nconst [timelineSpeed, setTimelineSpeed] = useState(1); // 1x, 2x, 4x\n```\n\n### Change 3: page.tsx — Compute timeline range\n\nAdd a useMemo that computes the timeline event range from the full data. This MUST be computed from the full (unfiltered) data set:\n\n```typescript\nconst timelineRange = useMemo<TimelineRange | null>(() => {\n if (!data) return null;\n return buildTimelineEvents(data.graphData.nodes, data.graphData.links);\n}, [data]);\n```\n\n### Change 4: page.tsx — Initialize timelineTime when activating\n\nWhen timeline mode is activated, set timelineTime to minTime:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next && timelineRange) {\n setTimelineTime(timelineRange.minTime);\n setTimelinePlaying(false);\n }\n if (!next) {\n setTimelinePlaying(false);\n }\n return next;\n });\n}, [timelineRange]);\n```\n\n### Change 5: page.tsx — rAF playback loop\n\nAdd a useEffect that advances timelineTime when playing. Speed mapping: 1x = 1 real second advances 1 calendar day of project time. So:\n- msPerFrame = (1000/60) * speed * (86400000 / 1000) = speed * 1440000 / 60 = speed * 24000 per frame at 60fps\n\nActually simpler: track last rAF timestamp, compute real elapsed ms, multiply by speed factor:\n- 1x: 1 real second = 1 day (86400000ms) of project time -> factor = 86400\n- 2x: factor = 172800\n- 4x: factor = 345600\n\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n\n let rafId: number;\n let lastTs: number | null = null;\n const factor = timelineSpeed * 86400; // 1 real ms = factor project ms\n\n function tick(ts: number) {\n if (lastTs !== null) {\n const realElapsed = ts - lastTs;\n setTimelineTime(prev => {\n const next = prev + realElapsed * factor;\n if (next >= timelineRange!.maxTime) {\n setTimelinePlaying(false);\n return timelineRange!.maxTime;\n }\n return next;\n });\n }\n lastTs = ts;\n rafId = requestAnimationFrame(tick);\n }\n\n rafId = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(rafId);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\n### Change 6: page.tsx — Filter data for timeline mode\n\nCompute the filtered nodes/links using a useMemo. This is what gets passed to BeadsGraph when timeline is active:\n\n```typescript\nconst timelineFilteredData = useMemo(() => {\n if (!timelineActive || !data) return null;\n return filterDataAtTime(\n data.graphData.nodes,\n data.graphData.links,\n timelineTime\n );\n}, [timelineActive, data, timelineTime]);\n```\n\n### Change 7: page.tsx — Stamp _spawnTime on newly visible nodes\n\nTo get pop-in animations as nodes appear during playback, track previously visible node IDs and stamp _spawnTime on new ones:\n\n```typescript\nconst prevTimelineNodeIdsRef = useRef<Set<string>>(new Set());\n\nconst timelineNodes = useMemo(() => {\n if (!timelineFilteredData) return null;\n const prevIds = prevTimelineNodeIdsRef.current;\n const now = Date.now();\n const nodes = timelineFilteredData.nodes.map(node => {\n if (!prevIds.has(node.id)) {\n return { ...node, _spawnTime: now } as GraphNode;\n }\n return node;\n });\n // Update prev set for next frame\n prevTimelineNodeIdsRef.current = new Set(timelineFilteredData.nodes.map(n => n.id));\n return nodes;\n}, [timelineFilteredData]);\n\nconst timelineLinks = useMemo(() => {\n if (!timelineFilteredData) return null;\n return timelineFilteredData.links;\n}, [timelineFilteredData]);\n```\n\n**IMPORTANT**: This runs in useMemo which is a pure computation. The ref update inside useMemo is a known pattern but impure. An alternative: use useEffect to update the ref. Choose whichever approach doesn't cause visual glitches. If useMemo causes double-stamping in StrictMode, move to useEffect with a separate state.\n\n### Change 8: page.tsx — Pass filtered or full data to BeadsGraph\n\nCurrently (line 863-864):\n```tsx\n<BeadsGraph\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n```\n\nChange to:\n```tsx\n<BeadsGraph\n nodes={timelineActive && timelineNodes ? timelineNodes : data.graphData.nodes}\n links={timelineActive && timelineLinks ? timelineLinks : data.graphData.links}\n```\n\n### Change 9: page.tsx — Timeline pill button in header\n\nAdd a pill button next to the Comments pill (before the `<span className=\"w-px h-4 bg-zinc-200\" />` separator before AuthButton). Same styling as Comments/layout pills:\n\n```tsx\n<span className=\"w-px h-4 bg-zinc-200\" />\n{/* Timeline pill */}\n<div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\">\n <button\n onClick={handleTimelineToggle}\n className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n timelineActive\n ? \"bg-emerald-500 text-white\"\n : \"text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n >\n <span className=\"flex items-center gap-1.5\">\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n {/* Clock/replay icon */}\n <circle cx=\"8\" cy=\"8\" r=\"6\" />\n <polyline points=\"8,4 8,8 11,10\" />\n </svg>\n <span className=\"hidden sm:inline\">Replay</span>\n </span>\n </button>\n</div>\n```\n\nPlace this BEFORE the Comments pill separator.\n\n### Change 10: page.tsx — Render TimelineBar\n\nAdd TimelineBar inside the graph area div (after BeadsGraph, before the closing </div> of the graph area). It should render only when timeline is active:\n\n```tsx\n{timelineActive && timelineRange && (\n <div className=\"absolute bottom-4 right-4 z-10\">\n <TimelineBar\n minTime={timelineRange.minTime}\n maxTime={timelineRange.maxTime}\n currentTime={timelineTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onTimeChange={setTimelineTime}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n />\n </div>\n)}\n```\n\n### Change 11: BeadsGraph.tsx — Hide legend hint when timeline is active\n\nAdd a new prop to BeadsGraphProps:\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n timelineActive?: boolean; // <-- ADD THIS\n}\n```\n\nChange the legend hint conditional (lines 1227-1234) from:\n```tsx\n{!selectedNode && !hoveredNode && (\n```\nto:\n```tsx\n{!selectedNode && !hoveredNode && !timelineActiveRef.current && (\n```\n\nAdd a ref for timelineActive (same pattern as selectedNodeRef etc):\n```typescript\nconst timelineActiveRef = useRef(false);\nuseEffect(() => { timelineActiveRef.current = timelineActive ?? false; }, [timelineActive]);\n```\n\nWait — actually the legend hint is in the JSX return, not in paintNode, so we can use the prop directly:\n```tsx\n{!selectedNode && !hoveredNode && !props.timelineActive && (\n```\n\nBut BeadsGraph destructures props at the top. Add `timelineActive` to the destructured props and use it directly in the JSX conditional. No ref needed for this since it's in JSX, not in a useCallback.\n\n### Change 12: page.tsx — Pass timelineActive to BeadsGraph\n\n```tsx\n<BeadsGraph\n ...\n timelineActive={timelineActive} // <-- ADD THIS\n/>\n```\n\n### Summary of files to edit\n- `app/page.tsx` — state, imports, memos, pill button, TimelineBar render, data filtering\n- `components/BeadsGraph.tsx` — timelineActive prop, hide legend when active\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt)\n- beads-map-21c.3 (lib/timeline.ts)\n- beads-map-21c.4 (TimelineBar component)\n\n### Acceptance criteria\n- \"Replay\" pill button in header toggles timeline mode\n- When active, graph shows only nodes/links that exist at the virtual clock time\n- Pressing play animates nodes appearing over time with pop-in animations\n- Scrubbing the slider immediately updates visible nodes\n- Speed toggle cycles 1x/2x/4x\n- Legend hint hidden when timeline is active (TimelineBar replaces it)\n- When timeline is deactivated, full live data is shown again\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:49:28.829389+13:00","updatedAt":"2026-02-11T01:56:10.694555+13:00","closedAt":"2026-02-11T01:56:10.694555+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":4,"blockerIds":["beads-map-21c.6"],"dependentIds":["beads-map-21c","beads-map-21c.2","beads-map-21c.3","beads-map-21c.4"]},{"id":"beads-map-21c.6","title":"Build verification, edge cases, and polish for timeline replay","description":"## Build verification and polish\n\n### What\nFinal task: verify the build passes, test edge cases, and polish any rough edges.\n\n### Build gate\n```bash\npnpm build\n```\nMust pass with zero errors. If there are type errors, fix them.\n\n### Edge cases to verify\n\n1. **Empty graph**: If no nodes have timestamps, timeline should gracefully handle minTime === maxTime (slider disabled or shows single point)\n2. **Single node**: Timeline with one node should still work (slider shows one point in time)\n3. **All nodes already closed**: Scrubbing to maxTime should show all nodes as closed\n4. **Scrubbing backward**: Moving slider left should remove nodes (they should just disappear, no exit animation needed for scrub-back — only forward playback gets spawn animations)\n5. **Rapid scrubbing**: Fast slider movement should not cause performance issues. The filterDataAtTime function should be fast (O(n) where n = total nodes + links)\n6. **Toggle off during playback**: Turning off timeline while playing should stop playback and restore full data\n7. **Node selection during timeline**: Clicking a node during timeline playback should work normally (open NodeDetail sidebar)\n8. **Comments during timeline**: Comment badges should still work on visible nodes (commentedNodeIds filtering still applies)\n\n### Polish items\n- Ensure TimelineBar doesn't overlap with minimap on small screens\n- Verify the rAF loop cleans up properly on unmount\n- Check that prevTimelineNodeIdsRef resets when timeline is deactivated\n- Verify nodes retain their force-simulation positions when switching between timeline and live mode (x/y should be preserved since we're using the same node objects from data.graphData.nodes)\n\n### Stale .next cache\nIf you see module resolution errors, run:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Files potentially needing fixes\n- `app/page.tsx`\n- `components/BeadsGraph.tsx`\n- `components/TimelineBar.tsx`\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- All edge cases handled gracefully\n- No console errors during timeline playback\n- Clean git status after commit","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:49:44.214947+13:00","updatedAt":"2026-02-11T01:56:40.00756+13:00","closedAt":"2026-02-11T01:56:40.00756+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.5"]},{"id":"beads-map-21c.7","title":"Rewrite timeline to event-step playback using diff/merge pipeline","description":"## Rewrite timeline to event-step playback using diff/merge pipeline\n\n### Problem\nTwo bugs in the current timeline replay:\n1. **Too fast**: rAF loop maps real time to project time (1 sec = 1 day), so months of history play in seconds\n2. **Graph mess**: Timeline bypasses the diff/merge pipeline, so nodes appear without x/y positions and the force simulation doesn't organize them. Links and nodes are disconnected.\n\n### Root cause\nThe current timeline creates a parallel data path: filterDataAtTime() returns raw nodes (no x/y), stamps _spawnTime manually in useMemo, and passes them directly to BeadsGraph. This skips:\n- mergeBeadsData() which preserves x/y from old nodes and places new ones near neighbors\n- diffBeadsData() which detects added/removed/changed for proper animation stamps\n- The force simulation reheat that react-force-graph does when graphData changes\n\n### Fix: event-step model + diff/merge pipeline\n\n**Playback model change:**\n- Replace continuous time scrubber with discrete event steps\n- Each step = one event from the sorted events array\n- Playback advances one step every 5 seconds (at 1x), giving force simulation time to settle\n- Speed: 1x = 5s/step, 2x = 2.5s/step, 4x = 1.25s/step\n- Slider maps to step index (0 to events.length-1), not unix timestamps\n\n**Data pipeline change:**\n- Maintain `timelineData: BeadsApiResponse | null` state (the \"current timeline snapshot\")\n- On each step change:\n 1. Get timestamp from events[step].time\n 2. Call filterDataAtTime(allNodes, allLinks, timestamp) to get visible nodes/links\n 3. Wrap as BeadsApiResponse-shaped object\n 4. Call diffBeadsData(prevTimelineData, newSnapshot) to get the diff\n 5. Call mergeBeadsData(prevTimelineData, newSnapshot, diff) to get positioned + animated data\n 6. Set timelineData = merged result\n- Pass timelineData.graphData.nodes/.links to BeadsGraph\n- Force simulation naturally reheats when the node/link arrays change\n\n### Files to edit\n\n**app/page.tsx** — The big one. Replace the entire timeline section (lines ~196-410):\n\nState changes:\n- REMOVE: timelineTime (unix ms)\n- REMOVE: prevTimelineNodeIdsRef\n- ADD: timelineStep (number, 0-based index into events array)\n- ADD: timelineData (BeadsApiResponse | null)\n\nRemove these memos/computations:\n- timelineFilteredData useMemo\n- timelineNodes useMemo\n- timelineLinks useMemo\n\nReplace rAF playback loop with setInterval:\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n const intervalMs = 5000 / timelineSpeed;\n const interval = setInterval(() => {\n setTimelineStep(prev => {\n const next = prev + 1;\n if (next >= timelineRange.events.length) {\n setTimelinePlaying(false);\n return prev;\n }\n return next;\n });\n }, intervalMs);\n return () => clearInterval(interval);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\nAdd effect to compute timelineData when step changes:\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange || timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n\n const filtered = filterDataAtTime(data.graphData.nodes, data.graphData.links, event.time);\n const newSnapshot: BeadsApiResponse = {\n ...data,\n graphData: { nodes: filtered.nodes, links: filtered.links },\n };\n\n setTimelineData(prev => {\n if (!prev) return newSnapshot; // first frame — no merge needed\n const diff = diffBeadsData(prev, newSnapshot);\n if (!diff.hasChanges) return prev;\n return mergeBeadsData(prev, newSnapshot, diff);\n });\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChange BeadsGraph props:\n```tsx\nnodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}\nlinks={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}\n```\n\nChange TimelineBar props:\n```tsx\n<TimelineBar\n totalSteps={timelineRange.events.length}\n currentStep={timelineStep}\n currentTime={timelineRange.events[timelineStep]?.time ?? timelineRange.minTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onStepChange={setTimelineStep}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n/>\n```\n\nUpdate handleTimelineToggle:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next) {\n setTimelineStep(0);\n setTimelinePlaying(false);\n setTimelineData(null);\n } else {\n setTimelinePlaying(false);\n setTimelineData(null);\n }\n return next;\n });\n}, []);\n```\n\n**components/TimelineBar.tsx** — Change from time-based to step-based props:\n\nNew props:\n```typescript\ninterface TimelineBarProps {\n totalSteps: number; // events.length\n currentStep: number; // 0-based index\n currentTime: number; // unix ms of current event (for date display)\n isPlaying: boolean;\n speed: number; // 1, 2, 4\n onStepChange: (step: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\nSlider: min=0, max=totalSteps-1, value=currentStep, onChange calls onStepChange\nDate label: formatTimelineDate(currentTime)\nAdd step counter: \"3 / 47\" next to date\nhasRange = totalSteps > 1\n\n**lib/timeline.ts** — No changes needed. buildTimelineEvents and filterDataAtTime work as-is.\n\n**components/BeadsGraph.tsx** — No changes needed. Force simulation reheats naturally.\n\n### Depends on\nNothing new — this replaces parts of beads-map-21c.5\n\n### Acceptance criteria\n- Playing timeline advances one event at a time, 5 seconds between events at 1x\n- 2x = 2.5s between events, 4x = 1.25s between events\n- Nodes appear with pop-in animation and get properly positioned by force simulation\n- Links connect to their nodes correctly\n- Scrubbing the slider jumps between event steps\n- Graph layout matches the active layout mode (Force or DAG)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:00:49.845818+13:00","updatedAt":"2026-02-11T02:03:04.087128+13:00","closedAt":"2026-02-11T02:03:04.087128+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.8"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.8","title":"Build verify and push timeline event-step rewrite","description":"## Build verify and push\n\nRun pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\npnpm build\ngit add -A\ngit commit -m \"Rewrite timeline to event-step playback with diff/merge pipeline (beads-map-21c.7)\"\nbd sync\ngit push\n```\n\n### Edge cases to check\n- Empty events array (no timestamps) — slider disabled, play disabled\n- Single event — slider shows one point\n- Scrubbing backward — nodes removed via diff/merge exit animation\n- Toggle off during playback — stops interval, clears timelineData\n- Speed change during playback — interval restarts with new timing\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:00:58.650469+13:00","updatedAt":"2026-02-11T02:03:27.972704+13:00","closedAt":"2026-02-11T02:03:27.972704+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.7"]},{"id":"beads-map-21c.9","title":"Fix timeline: links appear with both nodes, empty preamble, 2s per event","description":"## Fix timeline: links appear with both nodes, empty preamble, 2s per event\n\n### Three issues to fix\n\n#### Issue 1: Links don't appear when both nodes are visible\n**Root cause:** filterDataAtTime() in lib/timeline.ts lines 148-151 checks link.createdAt independently:\n```typescript\nif (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n}\n```\nEven when both endpoints are on canvas, the link is hidden until its own timestamp.\n\n**Fix in lib/timeline.ts:** Remove the link.createdAt check entirely (lines 147-151). A link should appear the moment both endpoints are visible. The visibleNodeIds check on line 145 is sufficient:\n```typescript\n// Both endpoints must be visible\nif (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n// REMOVE the link.createdAt check below this\n```\n\nAlso remove link-created events from buildTimelineEvents() (lines 54-73) since link timing is now derived from node visibility, not link timestamps. This simplifies the event list to just node-created and node-closed.\n\n#### Issue 2: Zoom crash into first node on play\n**Root cause:** BeadsGraph.tsx line 432-440 has a zoomToFit effect:\n```typescript\nuseEffect(() => {\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n}, [nodes.length]);\n```\nWhen timeline starts at step 0 (1 node), this zooms to fit that single node = extreme zoom in.\n\n**Fix — two parts:**\n\n**Part A: Prevent zoomToFit during timeline mode.**\nThe timelineActive prop is already passed to BeadsGraph. Use it to skip the zoomToFit:\n```typescript\nuseEffect(() => {\n if (timelineActive) return; // skip during timeline replay\n if (graphRef.current && nodes.length > 0) {\n ...\n }\n}, [nodes.length, timelineActive]);\n```\n\n**Part B: Add 2-second empty preamble before first event.**\nIn the playback setInterval in page.tsx, when play starts and timelineStep is -1 (a new \"preamble\" step), show zero nodes for 2 seconds, then advance to step 0.\n\nImplementation approach: Use step index -1 as the preamble. When timeline is activated or play starts from the beginning, set step to -1. The effect that computes timelineData should check: if step === -1, set timelineData to an empty snapshot (no nodes, no links). The setInterval advances from -1 to 0, then 0 to 1, etc.\n\nChanges in app/page.tsx:\n- handleTimelineToggle: setTimelineStep(-1) instead of 0\n- The setInterval already does prev + 1, so -1 + 1 = 0 (first real event). Works naturally.\n- The timelineData effect: add check for timelineStep === -1 -> empty snapshot\n- TimelineBar: totalSteps stays as events.length (preamble is \"step -1\", not counted in steps)\n- TimelineBar slider: min stays 0, but current step shows as 0 when preamble is active\n\nChanges in page.tsx effect that computes timelineData (lines 367-389):\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange) return;\n \n // Preamble step: empty canvas\n if (timelineStep === -1) {\n setTimelineData({\n ...data,\n graphData: { nodes: [], links: [] },\n });\n return;\n }\n \n if (timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n // ... rest of diff/merge logic\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChanges in page.tsx TimelineBar rendering:\n```tsx\ncurrentStep={Math.max(timelineStep, 0)}\ncurrentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}\n```\n\n#### Issue 3: 5 seconds per event is too slow\n**Fix in app/page.tsx line 353:** Change 5000 to 2000:\n```typescript\nconst intervalMs = 2000 / timelineSpeed;\n```\nThis gives 2s per event at 1x, 1s at 2x, 0.5s at 4x.\n\n### Files to edit\n- lib/timeline.ts — remove link.createdAt check in filterDataAtTime, remove link-created events from buildTimelineEvents\n- app/page.tsx — step -1 preamble, 2s interval, TimelineBar prop adjustments \n- components/BeadsGraph.tsx — skip zoomToFit during timeline mode\n\n### Also remove link-created from TimelineEventType\nSince links no longer have their own timeline events, simplify:\n- TimelineEventType becomes \"node-created\" | \"node-closed\" (remove \"link-created\")\n- buildTimelineEvents() removes the link loop (lines 54-73)\n\n### Acceptance criteria\n- Links appear the instant both connected nodes are on canvas\n- Pressing play shows empty canvas for 2 seconds (preamble), then first node appears\n- Each event takes 2 seconds at 1x speed\n- No zoom-crash into a single node when timeline starts\n- Scrubbing slider still works (slider min=0, preamble is before slider range)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:07:25.357205+13:00","updatedAt":"2026-02-11T02:09:24.013992+13:00","closedAt":"2026-02-11T02:09:24.013992+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.10"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-2fk","title":"Create lib/diff-beads.ts — diff engine for nodes and links","description":"Create a new file: lib/diff-beads.ts\n\nPURPOSE: Compare two BeadsApiResponse objects and identify what changed — which nodes/links were added, removed, or modified. The diff output drives animation metadata stamping in the merge logic (task .5).\n\nINTERFACE:\n\n```typescript\nimport type { BeadsApiResponse, GraphNode, GraphLink } from \"./types\";\n\nexport interface NodeChange {\n field: string; // e.g. \"status\", \"priority\", \"title\"\n from: string; // previous value (stringified)\n to: string; // new value (stringified)\n}\n\nexport interface BeadsDiff {\n addedNodeIds: Set<string>; // IDs of nodes not in old data\n removedNodeIds: Set<string>; // IDs of nodes not in new data\n changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes\n addedLinkKeys: Set<string>; // \"source->target:type\" keys\n removedLinkKeys: Set<string>; // \"source->target:type\" keys\n hasChanges: boolean; // true if anything changed at all\n}\n\n/**\n * Build a stable key for a link.\n * Links may have string or object source/target (after force-graph mutation).\n */\nexport function linkKey(link: GraphLink): string;\n\n/**\n * Compute the diff between old and new beads data.\n * Compares nodes by ID and links by source->target:type key.\n */\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff;\n```\n\nIMPLEMENTATION:\n\n```typescript\nexport function linkKey(link: GraphLink): string {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n return `${src}->${tgt}:${link.type}`;\n}\n\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff {\n // If no old data, everything is \"added\"\n if (!oldData) {\n return {\n addedNodeIds: new Set(newData.graphData.nodes.map(n => n.id)),\n removedNodeIds: new Set(),\n changedNodes: new Map(),\n addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),\n removedLinkKeys: new Set(),\n hasChanges: true,\n };\n }\n\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const newNodeMap = new Map(newData.graphData.nodes.map(n => [n.id, n]));\n\n // Node diffs\n const addedNodeIds = new Set<string>();\n const removedNodeIds = new Set<string>();\n const changedNodes = new Map<string, NodeChange[]>();\n\n for (const [id, node] of newNodeMap) {\n if (!oldNodeMap.has(id)) {\n addedNodeIds.add(id);\n } else {\n const old = oldNodeMap.get(id)!;\n const changes: NodeChange[] = [];\n if (old.status !== node.status) {\n changes.push({ field: \"status\", from: old.status, to: node.status });\n }\n if (old.priority !== node.priority) {\n changes.push({ field: \"priority\", from: String(old.priority), to: String(node.priority) });\n }\n if (old.title !== node.title) {\n changes.push({ field: \"title\", from: old.title, to: node.title });\n }\n if (changes.length > 0) {\n changedNodes.set(id, changes);\n }\n }\n }\n for (const id of oldNodeMap.keys()) {\n if (!newNodeMap.has(id)) {\n removedNodeIds.add(id);\n }\n }\n\n // Link diffs\n const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));\n const newLinkKeys = new Set(newData.graphData.links.map(linkKey));\n\n const addedLinkKeys = new Set<string>();\n const removedLinkKeys = new Set<string>();\n\n for (const key of newLinkKeys) {\n if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);\n }\n for (const key of oldLinkKeys) {\n if (!newLinkKeys.has(key)) removedLinkKeys.add(key);\n }\n\n const hasChanges =\n addedNodeIds.size > 0 ||\n removedNodeIds.size > 0 ||\n changedNodes.size > 0 ||\n addedLinkKeys.size > 0 ||\n removedLinkKeys.size > 0;\n\n return { addedNodeIds, removedNodeIds, changedNodes, addedLinkKeys, removedLinkKeys, hasChanges };\n}\n```\n\nWHY linkKey() HANDLES OBJECTS:\nreact-force-graph-2d mutates link.source and link.target from string IDs to node objects during simulation. When we compare old links (which have been mutated) against new links (which have string IDs from the server), we need to handle both cases.\n\nDEPENDS ON: task .1 (animation timestamp types in GraphNode/GraphLink)\n\nACCEPTANCE CRITERIA:\n- lib/diff-beads.ts exports diffBeadsData and linkKey\n- Correctly identifies added/removed/changed nodes\n- Correctly identifies added/removed links\n- Handles null oldData (initial load — everything is \"added\")\n- Handles object-form source/target in links (post-simulation mutation)\n- hasChanges is false when data is identical\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:16:20.792858+13:00","updatedAt":"2026-02-10T23:25:49.501958+13:00","closedAt":"2026-02-10T23:25:49.501958+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-ecl"],"dependentIds":["beads-map-3jy","beads-map-gjo"]},{"id":"beads-map-2qg","title":"Integration testing — live update end-to-end verification","description":"Final verification that the live update system works end-to-end with all animations.\n\nSETUP:\n Terminal 1: cd to any beads project (e.g. ~/Projects/gainforest/gainforest-beads)\n Terminal 2: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev (in beads-map)\n Browser: http://localhost:3000\n\nTEST MATRIX:\n\n1. NEW NODE (bd create):\n Terminal 1: bd create --title \"Live test node\" --priority 2\n Browser: Within ~300ms, a new node should POP IN with:\n - Scale animation from 0 to 1 (bouncy easeOutBack)\n - Brief green glow ring around it\n - Node placed near its connected neighbors (or at random if standalone)\n - Force simulation gently incorporates it into the layout\n VERIFY: No graph position reset, existing nodes stay where they are\n\n2. STATUS CHANGE (bd update):\n Terminal 1: bd update <id-from-step-1> --status in_progress\n Browser: The node should show:\n - Expanding ripple ring in amber (in_progress color)\n - Node body color transitions from emerald (open) to amber\n - Ripple fades out over ~800ms\n VERIFY: No position change, other nodes unaffected\n\n3. NEW LINK (bd link):\n Terminal 1: bd link <id1> blocks <id2>\n Browser: A new link should appear:\n - Fades in over 500ms\n - Brief emerald flash along the link path (300ms)\n - Starts thicker, settles to normal width\n - Flow particles appear on it\n VERIFY: Both endpoints stay in position\n\n4. CLOSE ISSUE (bd close):\n Terminal 1: bd close <id-from-step-1>\n Browser: The node should SHRINK OUT:\n - Scale animation from 1 to 0 (400ms)\n - Opacity fades to 0\n - Connected links also fade out\n - After ~600ms, the ghost node/links are removed from the array\n VERIFY: Stats update (total count decreases)\n\n5. RAPID CHANGES (debounce test):\n Terminal 1: for i in 1 2 3 4 5; do bd create --title \"Rapid $i\" --priority 3; done\n Browser: Nodes should NOT pop in one-by-one with 300ms delays. They should all appear in a single batch after the debounce settles (~300ms after the last command).\n VERIFY: All 5 nodes spawn simultaneously with pop-in animations\n\n6. MULTI-REPO (if using gainforest-beads hub):\n Terminal 1: cd ../audiogoat && bd create --title \"Cross-repo test\" --priority 3\n Browser: The new audiogoat node should appear in the graph\n VERIFY: Node has audiogoat prefix color ring\n\n7. RECONNECTION:\n Stop and restart the dev server.\n Browser: EventSource should auto-reconnect and load fresh data.\n VERIFY: No stale data, no duplicate nodes\n\n8. EPIC COLLAPSE VIEW:\n Switch to \"Epics\" view mode, then create a child task.\n Terminal 1: bd create --title \"Child of epic\" --priority 2 --parent <epic-id>\n Browser: In Epics mode, the parent epic node should update:\n - Child count badge increments\n - Epic node briefly flashes (change animation)\n - No child node appears (it's collapsed)\n Switch to Full mode: child node should be visible (already in data)\n\n9. BUILD CHECK:\n pnpm build — must pass with zero errors\n\n10. CLEANUP:\n Delete test issues: bd delete <id> for each test issue created\n Browser: Nodes shrink out on deletion\n\nFUNCTIONAL CHECKS:\n- Force/DAG layout toggle still works during/after animations\n- Full/Epics toggle still works\n- Search still finds nodes (including newly spawned ones)\n- Minimap updates with new nodes\n- Click node -> sidebar shows correct data (including newly added nodes)\n- Header stats update in real-time (issue count, dep count, project count)\n- No memory leaks (EventSource properly cleaned up on page navigation)\n- No console errors during any test\n\nPERFORMANCE CHECKS:\n- Animation frame rate stays smooth (60fps) during spawn/exit\n- No jitter or \"graph explosion\" when new data merges\n- File watcher doesn't cause excessive CPU usage during idle\n\nDEPENDS ON: All previous tasks (.1-.7) must be complete\n\nACCEPTANCE CRITERIA:\n- All 10 test scenarios pass\n- All functional checks pass\n- All performance checks pass\n- pnpm build clean\n- No console errors","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:18:51.905378+13:00","updatedAt":"2026-02-10T23:40:49.415522+13:00","closedAt":"2026-02-10T23:40:49.415522+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-3jy","beads-map-mq9"]},{"id":"beads-map-3jy","title":"Live updates via SSE with animated node/link transitions","description":"Add real-time live updates to beads-map so that when .beads/issues.jsonl changes on disk (via bd create, bd close, bd link, bd update, etc.), the graph automatically updates with smooth animations — new nodes pop in, removed nodes shrink out, status changes flash, and new links fade in.\n\nARCHITECTURE:\n- Server: New SSE endpoint (/api/beads/stream) watches all JSONL files with fs.watch()\n- Server: On file change, re-parses all data and pushes the full dataset over SSE\n- Client: EventSource in page.tsx receives updates, diffs against current state\n- Client: Diff metadata (added/removed/changed) drives animations in paintNode/paintLink\n- Animations: spawn pop-in (easeOutBack), exit shrink-out, status change ripple, link fade-in\n\nKEY DESIGN DECISIONS:\n1. SSE over polling: true push, instant updates, no wasted requests\n2. Full data push (not incremental diffs): simpler, avoids sync issues, JSONL files are small\n3. Debounce 300ms: bd often writes multiple times per command (flush + sync)\n4. Position preservation: merge new data while keeping existing node x/y/fx/fy positions\n5. Animation via timestamps: stamp _spawnTime/_removeTime/_changedAt on items, animate in paintNode/paintLink based on elapsed time\n\nFILES TO CREATE:\n- lib/watch-beads.ts — file watcher utility wrapping fs.watch with debounce\n- lib/diff-beads.ts — diff engine comparing old vs new BeadsApiResponse\n- app/api/beads/stream/route.ts — SSE endpoint\n\nFILES TO MODIFY:\n- lib/parse-beads.ts — export getAdditionalRepoPaths (currently private)\n- lib/types.ts — add animation timestamp fields to GraphNode/GraphLink\n- app/page.tsx — replace one-shot fetch with EventSource + merge logic\n- components/BeadsGraph.tsx — spawn/exit/change animations in paintNode + paintLink\n\nDEPENDENCY CHAIN:\n.1 (types + parse-beads exports) → .2 (watch-beads.ts) → .3 (SSE endpoint) → .5 (page.tsx EventSource)\n.1 → .4 (diff-beads.ts) → .5\n.5 → .6 (paintNode animations) → .7 (paintLink animations) → .8 (integration test)","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:14:54.798302+13:00","updatedAt":"2026-02-10T23:40:49.541566+13:00","closedAt":"2026-02-10T23:40:49.541566+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":10,"dependentCount":0,"blockerIds":["beads-map-21c","beads-map-2fk","beads-map-2qg","beads-map-7j2","beads-map-cvh","beads-map-ecl","beads-map-gjo","beads-map-iyn","beads-map-m1o","beads-map-mq9"],"dependentIds":[]},{"id":"beads-map-3qb","title":"Filter out tombstoned issues from graph","status":"closed","priority":1,"issueType":"bug","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:48:26.859412+13:00","updatedAt":"2026-02-10T23:48:54.69868+13:00","closedAt":"2026-02-10T23:48:54.69868+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":0,"blockerIds":[],"dependentIds":[]},{"id":"beads-map-48c","title":"Show full description with markdown rendering in NodeDetail sidebar","description":"## Show full description with markdown rendering in NodeDetail sidebar\n\n### Summary\nTwo changes to the description section in `components/NodeDetail.tsx`:\n\n1. **Remove truncation** — Stop calling `truncateDescription()` so the full description text is shown. The scrollable container (`max-h-40 overflow-y-auto`) already handles long content elegantly — keep that.\n\n2. **Render markdown** — Descriptions are written in markdown (headings, code blocks, lists, links, bold/italic). Currently rendered as plain `<pre>` text. Install `react-markdown` + `remark-gfm` and render the description as formatted markdown inside the scrollable box, with appropriate typography styles for the small text size (text-xs base).\n\n### Tasks\n- .1 Install react-markdown and remark-gfm\n- .2 Remove truncation, add markdown rendering with styled prose in the scrollable box\n- .3 Build verification\n\n### Files to modify\n- `package.json` — add react-markdown, remark-gfm\n- `components/NodeDetail.tsx` — replace `<pre>{truncateDescription(...)}</pre>` with `<ReactMarkdown>` component, remove `truncateDescription` function\n- `app/globals.css` — possibly add small prose styling overrides for the description box\n\n### Key constraint\nKeep the scrollable container (`max-h-40 overflow-y-auto custom-scrollbar`) — that's good UX. Just show the full content inside it and render it as markdown instead of plain text.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:11:03.062054+13:00","updatedAt":"2026-02-11T01:12:31.404312+13:00","closedAt":"2026-02-11T01:12:31.404312+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":0,"blockerIds":[],"dependentIds":[]},{"id":"beads-map-7j2","title":"Create SSE endpoint /api/beads/stream","description":"Create a new file: app/api/beads/stream/route.ts\n\nPURPOSE: Server-Sent Events endpoint that streams beads data to the client. On initial connection, sends the full dataset. Then watches all JSONL files for changes and pushes updated data whenever files change. This replaces the one-shot GET /api/beads fetch for live use.\n\nIMPLEMENTATION:\n\n```typescript\nimport { discoverBeadsDir } from \"@/lib/discover\";\nimport { loadBeadsData } from \"@/lib/parse-beads\";\nimport { watchBeadsFiles } from \"@/lib/watch-beads\";\n\n// Prevent Next.js from statically optimizing this route\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(request: Request) {\n let cleanup: (() => void) | null = null;\n\n const stream = new ReadableStream({\n start(controller) {\n const encoder = new TextEncoder();\n\n function send(data: unknown) {\n try {\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`)\n );\n } catch {\n // Stream closed — cleanup will handle\n }\n }\n\n try {\n const { beadsDir } = discoverBeadsDir();\n\n // Send initial data\n const initialData = loadBeadsData(beadsDir);\n send(initialData);\n\n // Watch for changes and push updates\n cleanup = watchBeadsFiles(beadsDir, () => {\n try {\n const newData = loadBeadsData(beadsDir);\n send(newData);\n } catch (err) {\n console.error(\"Failed to reload beads data:\", err);\n }\n });\n\n // Heartbeat every 30s to keep connection alive through proxies/firewalls\n const heartbeat = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(\": heartbeat\\n\\n\"));\n } catch {\n clearInterval(heartbeat);\n }\n }, 30000);\n\n // Clean up when client disconnects\n request.signal.addEventListener(\"abort\", () => {\n clearInterval(heartbeat);\n if (cleanup) cleanup();\n try { controller.close(); } catch { /* already closed */ }\n });\n\n } catch (err: any) {\n // Discovery failed — send error and close\n send({ error: err.message });\n controller.close();\n }\n },\n\n cancel() {\n if (cleanup) cleanup();\n },\n });\n\n return new Response(stream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\", // Disable Nginx buffering\n },\n });\n}\n```\n\nKEY DESIGN DECISIONS:\n- export const dynamic = \"force-dynamic\": tells Next.js not to statically optimize this route\n- Full data push on each change: JSONL files are small (10-100 issues), so re-parsing is fast (<5ms). Sending full data avoids incremental diff sync complexity on the server.\n- Heartbeat every 30s: prevents proxies and load balancers from closing idle connections\n- request.signal.addEventListener(\"abort\"): proper cleanup when client disconnects (browser tab close, navigation away, EventSource reconnect)\n- TextEncoder for SSE format: controller.enqueue requires Uint8Array\n- X-Accel-Buffering: no: prevents Nginx from buffering SSE responses\n\nSSE MESSAGE FORMAT:\nEach message is a complete BeadsApiResponse JSON object:\n data: {\"issues\":[...],\"dependencies\":[...],\"graphData\":{\"nodes\":[...],\"links\":[...]},\"stats\":{...}}\n\nThe client (task .5) will parse this and diff against current state.\n\nERROR HANDLING:\n- Discovery failure: sends { error: \"...\" } then closes stream\n- Parse failure during watch: logs error, does NOT close stream (transient file write state)\n- Client disconnect: cleanup function closes all watchers\n\nTESTING:\n1. Start dev server: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n2. Open in browser: http://localhost:3000/api/beads/stream\n3. You should see SSE data flowing (initial payload, then updates when JSONL changes)\n4. In another terminal: cd ~/Projects/gainforest/gainforest-beads && bd create --title \"test live\" --priority 3\n5. Within ~300ms, the SSE stream should push a new message with the updated data\n6. Ctrl+C the stream — check no watcher leaks in the server process\n\nDEPENDS ON: task .1 (types), task .2 (watch-beads.ts)\n\nACCEPTANCE CRITERIA:\n- GET /api/beads/stream returns Content-Type: text/event-stream\n- Initial data sent immediately on connection\n- Updates pushed when any watched JSONL file changes\n- Heartbeat keeps connection alive\n- Proper cleanup on client disconnect\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:15:56.921088+13:00","updatedAt":"2026-02-10T23:26:39.409649+13:00","closedAt":"2026-02-10T23:26:39.409649+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-ecl"],"dependentIds":["beads-map-3jy","beads-map-m1o"]},{"id":"beads-map-7r6","title":"Activity feed: real-time + historical event log with compact overlay and expandable panel","description":"Add a comprehensive activity feed to beads-map showing both historical events and real-time updates.\n\n**Features:**\n- Historical feed from existing data (node creation/closure, links, comments, claims, likes)\n- Real-time events from SSE diffs (status/priority/title/owner changes, new comments, etc.)\n- Compact overlay (top-right) showing latest 5 events\n- Full panel (slide-in sidebar) with search and category filters\n- 13 event types with color-coded icons\n- Mutual exclusivity with other sidebars\n\n**Components created:**\n- lib/activity.ts: Event types, builders, diff-to-events converter\n- ActivityItem.tsx: Reusable event row (compact + full variants)\n- ActivityOverlay.tsx: Always-visible card with collapsible state\n- ActivityPanel.tsx: Full sidebar with search/filters\n\n**Integration:**\n- Activity pill in header navbar\n- SSE handler pipes diffs into activity feed\n- Event deduplication and 200-event cap\n","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:13.943245+13:00","updatedAt":"2026-02-11T12:11:21.787516+13:00","closedAt":"2026-02-11T12:05:23.135577+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":8,"dependentCount":1,"blockerIds":["beads-map-7r6.1","beads-map-7r6.2","beads-map-7r6.3","beads-map-7r6.4","beads-map-7r6.5","beads-map-7r6.6","beads-map-7r6.7","beads-map-7r6.8"],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-7r6.1","title":"Create lib/activity.ts: ActivityEvent type and historical feed builder","description":"Create the core activity feed infrastructure in lib/activity.ts.\n\n**Implemented:**\n- ActivityEventType enum: 13 event types (node-created, node-closed, node-status-changed, node-priority-changed, node-title-changed, node-owner-changed, link-added, link-removed, comment-added, reply-added, task-claimed, task-unclaimed, like-added)\n- ActivityEvent interface: { id, type, time, nodeId, nodeTitle?, actor?, detail?, meta? }\n- ActivityActor interface: { handle, avatar?, did? }\n- ActivityFilterCategory type: \"issues\" | \"deps\" | \"comments\" | \"claims\" | \"likes\"\n- getEventCategory(): maps event types to filter categories\n- buildHistoricalFeed(nodes, links, allComments): extracts events from existing data\n- diffToActivityEvents(diff, nodes): converts real-time BeadsDiff into events\n- mergeFeedEvents(existing, incoming): deduplicates by event ID, sorts newest-first, caps at 200\n- Event ID format: \"${type}:${nodeId}:${time}\" for deduplication\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:21.793982+13:00","updatedAt":"2026-02-11T12:11:25.528565+13:00","closedAt":"2026-02-11T12:05:22.084119+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-7r6.2","beads-map-7r6.3","beads-map-7r6.7"],"dependentIds":["beads-map-7r6"]},{"id":"beads-map-7r6.2","title":"Wire activity feed state in page.tsx: accumulate historical + SSE events","description":"Wire activity feed state management in app/page.tsx to accumulate historical and real-time events.\n\n**Implemented:**\n- Added state: activityFeed (ActivityEvent[]), activityPanelOpen (boolean), activityOverlayCollapsed (boolean)\n- useEffect to rebuild historical feed when data or allComments change via buildHistoricalFeed()\n- SSE onmessage handler: after computing diff, calls diffToActivityEvents() and merges into feed via mergeFeedEvents()\n- Feed accumulation with deduplication by event ID\n- Max 200 events retained (newest first)\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:21.921951+13:00","updatedAt":"2026-02-11T12:11:29.04198+13:00","closedAt":"2026-02-11T12:05:22.216634+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-7r6.6"],"dependentIds":["beads-map-7r6","beads-map-7r6.1","beads-map-7r6.7"]},{"id":"beads-map-7r6.3","title":"Create components/ActivityItem.tsx: single event row (compact + full variants)","description":"Create ActivityItem.tsx component for rendering individual activity events.\n\n**Implemented:**\n- Two variants: \"compact\" (single-line for overlay) and \"full\" (rich with avatar for panel)\n- Per-type SVG icons with color coding:\n - Emerald: positive actions (created, claimed, liked)\n - Amber: changes (status, priority, title, owner)\n - Red: removals (closed, link removed, unclaimed)\n - Blue: comments and replies\n- describeEvent() function: maps event types to human-readable text\n- Clickable node ID pills calling onNodeClick prop\n- Displays actor handle and avatar in full variant\n- Timestamp formatting (relative time)\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.046526+13:00","updatedAt":"2026-02-11T12:11:32.556229+13:00","closedAt":"2026-02-11T12:05:22.350628+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-7r6.4","beads-map-7r6.5"],"dependentIds":["beads-map-7r6","beads-map-7r6.1"]},{"id":"beads-map-7r6.4","title":"Create components/ActivityOverlay.tsx: compact always-visible top-right card","description":"Create ActivityOverlay.tsx: compact always-visible card in the top-right of the graph area.\n\n**Implemented:**\n- Position: absolute top-3 right-3 z-10 (inside graph area div)\n- Frosted glass styling: bg-white/90 backdrop-blur-sm rounded-lg border shadow-sm\n- Width: w-64 (256px)\n- Shows latest 5 events in compact variant\n- Collapsible to small pill with recent event count badge (events in last 5 min)\n- \"See all activity\" link opens full ActivityPanel\n- Hidden when: NodeDetail sidebar open, ActivityPanel open, or timeline active\n- Smooth transitions between expanded/collapsed states\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.173537+13:00","updatedAt":"2026-02-11T12:11:35.718366+13:00","closedAt":"2026-02-11T12:05:22.484041+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7r6.6"],"dependentIds":["beads-map-7r6","beads-map-7r6.3"]},{"id":"beads-map-7r6.5","title":"Create components/ActivityPanel.tsx: full slide-in sidebar with search and filters","description":"Create ActivityPanel.tsx: full slide-in sidebar with search and category filters.\n\n**Implemented:**\n- Layout: desktop w-[360px] absolute top-0 right-0 z-30, mobile bottom drawer\n- Search bar: filters by nodeId, title, actor handle, detail (case-insensitive substring)\n- 5 filter chips: Issues, Deps, Comments, Claims, Likes\n - All active by default, toggleable (min 1 active required)\n - Active chip styling: bg-emerald-50 text-emerald-700 border-emerald-200\n- Scrollable event list with full-variant ActivityItems\n- Footer: shows filtered event count\n- Same slide-in pattern as AllCommentsPanel\n- Close button with X icon\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.301972+13:00","updatedAt":"2026-02-11T12:11:38.561844+13:00","closedAt":"2026-02-11T12:05:22.615127+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7r6.6"],"dependentIds":["beads-map-7r6","beads-map-7r6.3"]},{"id":"beads-map-7r6.6","title":"Add Activity pill to header and wire overlay + panel in page.tsx","description":"Wire Activity pill button in header navbar and render ActivityOverlay + ActivityPanel.\n\n**Implemented:**\n- Added \"Activity\" pill button in header (between Comments and Auth divider)\n- Active state styling when activityPanelOpen\n- Rendered ActivityOverlay inside graph area div\n- Rendered ActivityPanel after AllCommentsPanel\n- Mutual exclusivity: opening ActivityPanel closes NodeDetail and AllCommentsPanel\n- ActivityOverlay hides when any sidebar or timeline is active\n- Props wired: feed, onNodeClick, onOpenPanel, onToggleCollapse, visibility flags\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.426965+13:00","updatedAt":"2026-02-11T12:11:42.290477+13:00","closedAt":"2026-02-11T12:05:22.74963+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":4,"blockerIds":["beads-map-7r6.8"],"dependentIds":["beads-map-7r6","beads-map-7r6.2","beads-map-7r6.4","beads-map-7r6.5"]},{"id":"beads-map-7r6.7","title":"Extend diff engine to track owner and assignee changes","description":"Extend the diff engine in lib/diff-beads.ts to track owner and assignee field changes.\n\n**Implemented:**\n- Added owner field comparison at line 84: `if ((old.owner || \"\") !== (node.owner || \"\"))`\n- Generates node-owner-changed diff when owner field changes\n- Enables activity feed to show \"owner changed\" events in real-time\n","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.551848+13:00","updatedAt":"2026-02-11T12:11:45.586883+13:00","closedAt":"2026-02-11T12:05:22.877032+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7r6.2"],"dependentIds":["beads-map-7r6","beads-map-7r6.1"]},{"id":"beads-map-7r6.8","title":"Build, verify, and push activity feed feature","description":"Build, verify, and commit the complete activity feed feature.\n\n**Implemented:**\n- Ran pnpm build to verify no TypeScript errors\n- Tested activity overlay and panel in dev mode\n- Verified historical feed generation from existing data\n- Confirmed real-time event updates from SSE\n- Verified search and filter functionality in ActivityPanel\n- Committed changes with message: \"Activity feed: historical + real-time event log with compact overlay and expandable panel\"\n- Commit hash: ea51cb7\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.674715+13:00","updatedAt":"2026-02-11T12:11:48.579538+13:00","closedAt":"2026-02-11T12:05:23.005815+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-7r6","beads-map-7r6.6"]},{"id":"beads-map-8np","title":"Epic: Surface owner/assignee in tooltip and search","description":"Two enhancements: (1) Show owner and assignee in the node hover tooltip (BeadTooltip) when present. (2) Make the search bar match on owner and assignee names so typing 'daviddao' finds all nodes assigned to or owned by that person.","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:33:49.054947+13:00","updatedAt":"2026-02-12T10:35:36.891487+13:00","closedAt":"2026-02-12T10:35:36.891487+13:00","closeReason":"Completed: 0c7b4e1 — all tasks done","prefix":"beads-map","blockerCount":4,"dependentCount":1,"blockerIds":["beads-map-8np.1","beads-map-8np.2","beads-map-8np.3","beads-map-mfw"],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-8np.1","title":"Add assignee and createdBy to GraphNode and buildGraphData","description":"In lib/types.ts: add 'assignee?: string' and 'createdBy?: string' fields to GraphNode interface. In lib/parse-beads.ts buildGraphData() (line ~140): map 'assignee: issue.assignee' and 'createdBy: issue.created_by' into the GraphNode object. In lib/diff-beads.ts: if assignee/createdBy changes should trigger _changedAt, add them to the diff comparison (optional — they're display-only so probably not needed).","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:33:56.34265+13:00","updatedAt":"2026-02-12T10:35:36.639771+13:00","closedAt":"2026-02-12T10:35:36.639771+13:00","closeReason":"Completed: 0c7b4e1","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-8np.2","beads-map-8np.3"],"dependentIds":["beads-map-8np"]},{"id":"beads-map-8np.2","title":"Show owner and assignee in BeadTooltip","description":"In components/BeadTooltip.tsx: add two new metadata rows between 'Created' and 'Blocked by'. (1) 'Owner' row showing node.owner if present. (2) 'Assignee' row showing node.assignee if present. Both should be conditionally rendered — only show when the value exists. Style: same labelStyle/valueStyle as existing rows.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:34:01.699002+13:00","updatedAt":"2026-02-12T10:35:36.724586+13:00","closedAt":"2026-02-12T10:35:36.724586+13:00","closeReason":"Completed: 0c7b4e1","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-8np","beads-map-8np.1"]},{"id":"beads-map-8np.3","title":"Extend search bar to match on owner and assignee","description":"In app/page.tsx searchResults useMemo (line ~756): extend the searchable string from 'n.id n.title n.prefix' to include 'n.owner n.assignee n.createdBy' (with fallback to empty string for undefined values). This lets users type 'daviddao' and see all nodes owned by or assigned to that person. No UI changes to the result rendering needed — the existing display (id, title, prefix badge) is sufficient.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:34:07.488931+13:00","updatedAt":"2026-02-12T10:35:36.807758+13:00","closedAt":"2026-02-12T10:35:36.807758+13:00","closeReason":"Completed: 0c7b4e1","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-8np","beads-map-8np.1"]},{"id":"beads-map-8z1","title":"Epic: Per-epic collapse/uncollapse via right-click context menu","description":"Add ability to collapse/uncollapse individual epics via right-click context menu while in Full view. A new collapsedEpicIds Set<string> state in page.tsx tracks which epics are individually collapsed. The viewNodes/viewLinks memo in BeadsGraph.tsx reads this set: in Full view, only children of collapsed epics are hidden; in Epics view, all children are hidden (existing behavior, unchanged). Context menu shows 'Collapse epic' on expanded epic nodes and 'Uncollapse epic' on collapsed ones. Collapse/uncollapse only available in Full view mode (Epics view forces all collapsed). collapsedEpicIds is independent of the Full/Epics toggle — switching modes preserves per-epic state.","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:08.902101+13:00","updatedAt":"2026-02-12T10:56:38.122465+13:00","closedAt":"2026-02-12T10:56:38.122465+13:00","closeReason":"All 4 subtasks completed in 74d70b0: per-epic collapse/uncollapse via right-click context menu","prefix":"beads-map","blockerCount":4,"dependentCount":0,"blockerIds":["beads-map-8z1.1","beads-map-8z1.2","beads-map-8z1.3","beads-map-8z1.4"],"dependentIds":[]},{"id":"beads-map-8z1.1","title":"Add collapsedEpicIds state and toggle handler in page.tsx","description":"In app/page.tsx: (1) Add state: const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set()). (2) Add handler: const handleToggleEpicCollapse = useCallback((epicId: string) => { setCollapsedEpicIds(prev => { const next = new Set(prev); if (next.has(epicId)) next.delete(epicId); else next.add(epicId); return next; }); }, []). (3) Pass collapsedEpicIds as prop to <BeadsGraph>. Place state near other graph-related state (around line 188 near hoveredNode). Acceptance: prop is passed, handler exists, pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:17.434106+13:00","updatedAt":"2026-02-12T10:56:30.552955+13:00","closedAt":"2026-02-12T10:56:30.552955+13:00","closeReason":"Completed in 74d70b0: collapsedEpicIds state + handleToggleEpicCollapse in page.tsx","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-8z1.2","beads-map-8z1.4"],"dependentIds":["beads-map-8z1"]},{"id":"beads-map-8z1.2","title":"Integrate collapsedEpicIds into viewNodes/viewLinks memo in BeadsGraph","description":"In components/BeadsGraph.tsx: (1) Add collapsedEpicIds?: Set<string> to BeadsGraphProps (line ~36). (2) Destructure from props (line ~204). (3) Modify viewNodes/viewLinks useMemo (line ~296): Change the early return at line 297 from 'if (viewMode === \"full\") return ...' to 'if (viewMode === \"full\" && (!collapsedEpicIds || collapsedEpicIds.size === 0)) return ...'. (4) In the collapse logic, add a shouldCollapse helper: when viewMode === \"epics\" collapse all children (existing); when viewMode === \"full\" only collapse children whose parent is in collapsedEpicIds. Replace the line 'const childIds = new Set(childToParent.keys())' with a filtered set using shouldCollapse. (5) Add collapsedEpicIds to the useMemo dependency array. The rest of the memo (aggregate stats, filter nodes, remap links) stays identical — it already operates on the childIds set. Acceptance: individually collapsed epics fold their children in Full view; Epics view unchanged.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:27.393645+13:00","updatedAt":"2026-02-12T10:56:31.606551+13:00","closedAt":"2026-02-12T10:56:31.606551+13:00","closeReason":"Completed in 74d70b0: viewNodes/viewLinks memo supports per-epic collapse","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-8z1.4"],"dependentIds":["beads-map-8z1","beads-map-8z1.1"]},{"id":"beads-map-8z1.3","title":"Add Collapse/Uncollapse epic menu items to ContextMenu","description":"In components/ContextMenu.tsx: (1) Add two new optional props to ContextMenuProps: onCollapseEpic?: () => void and onUncollapseEpic?: () => void. (2) Destructure them in the component. (3) Add 'Collapse epic' button after 'Add comment' (before Claim/Unclaim): conditionally rendered when onCollapseEpic is defined. Icon: inward-pointing chevrons/arrows (collapse visual). Style: same as other menu items (w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50). (4) Add 'Uncollapse epic' button: conditionally rendered when onUncollapseEpic is defined. Icon: outward-pointing chevrons/arrows (expand visual). (5) Adjust border-b logic on 'Add comment' button: it should show border-b when any of onCollapseEpic, onUncollapseEpic, onClaimTask, onUnclaimTask follow. Acceptance: menu items render correctly when props are provided, pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:36.641477+13:00","updatedAt":"2026-02-12T10:56:32.590331+13:00","closedAt":"2026-02-12T10:56:32.590331+13:00","closeReason":"Completed in 74d70b0: Collapse/Uncollapse epic menu items in ContextMenu","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-8z1.4"],"dependentIds":["beads-map-8z1"]},{"id":"beads-map-8z1.4","title":"Wire collapse/uncollapse props in ContextMenu JSX in page.tsx","description":"In app/page.tsx, in the <ContextMenu> JSX (line ~1249): (1) Pass onCollapseEpic: set when ALL of: viewMode is 'full' (need viewMode from BeadsGraph — see note), node.issueType === 'epic', and !collapsedEpicIds.has(node.id). Calls handleToggleEpicCollapse(contextMenu.node.id) then setContextMenu(null). (2) Pass onUncollapseEpic: set when ALL of: viewMode is 'full', node.issueType === 'epic', and collapsedEpicIds.has(node.id). Same handler. NOTE on viewMode: viewMode currently lives inside BeadsGraph as internal state. Options: (a) Lift viewMode to page.tsx (cleanest but larger refactor), (b) Expose viewMode from BeadsGraph via the imperative handle (BeadsGraphHandle), (c) Add an onViewModeChange callback + viewMode prop to sync it up. Recommended: option (b) — add viewMode to the existing BeadsGraphHandle ref. Then page.tsx reads graphRef.current?.viewMode to decide. Alternatively, simpler approach: always show collapse/uncollapse on epic nodes in Full view — since the user explicitly chose the action, it's fine. We can track whether we're in epics view by checking the viewMode ref. Acceptance: right-clicking an epic in Full view shows Collapse/Uncollapse; in Epics view these items don't appear.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:48.968686+13:00","updatedAt":"2026-02-12T10:56:33.727649+13:00","closedAt":"2026-02-12T10:56:33.727649+13:00","closeReason":"Completed in 74d70b0: viewMode exposed via BeadsGraphHandle, props wired in page.tsx","prefix":"beads-map","blockerCount":0,"dependentCount":4,"blockerIds":[],"dependentIds":["beads-map-8z1","beads-map-8z1.1","beads-map-8z1.2","beads-map-8z1.3"]},{"id":"beads-map-9d3","title":"Epic: Add hover tooltip to graph nodes showing title, creation date, blockers, and priority","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:25.175725+13:00","updatedAt":"2026-02-12T10:30:56.956317+13:00","closedAt":"2026-02-12T10:30:56.956317+13:00","closeReason":"Completed: 6d96fa3 — all tasks done","prefix":"beads-map","blockerCount":4,"dependentCount":0,"blockerIds":["beads-map-8np","beads-map-9d3.2","beads-map-9d3.3","beads-map-9d3.4"],"dependentIds":[]},{"id":"beads-map-9d3.2","title":"Create BeadTooltip component","description":"New file: components/BeadTooltip.tsx. React component inspired by plresearch.org DependencyGraph Tooltip design. White card, fade-in animation (0.2s translateY), colored accent bar (node prefix color), pointerEvents:none, position:fixed. Shows: (1) Title 14px semibold, (2) Created date via formatRelativeTime from lib/utils.ts, (3) Blocked by section listing dependentIds as short IDs or 'None', (4) Priority with PRIORITY_COLORS dot + PRIORITY_LABELS from lib/types.ts. Smart viewport clamping: prefer above cursor, flip below if no room. Width ~280px, border-radius 8px, shadow 0 8px 32px rgba(0,0,0,0.08). Props: node:GraphNode, x:number, y:number, prefixColor:string, allNodes:GraphNode[] (resolve blocker IDs to titles).","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:40.268918+13:00","updatedAt":"2026-02-12T10:30:56.779274+13:00","closedAt":"2026-02-12T10:30:56.779274+13:00","closeReason":"Completed: 6d96fa3","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-9d3.3","title":"Wire hover tooltip state in page.tsx","description":"In app/page.tsx: (1) Add nodeTooltip state: { node: GraphNode; x: number; y: number } | null. (2) Modify handleNodeHover to accept (node, x, y) from BeadsGraph. (3) Render <BeadTooltip> in the graph area (alongside existing avatar tooltip) when nodeTooltip is set. Pass allNodes from data.graphData.nodes so tooltip can resolve blocker IDs to titles. Use getPrefixColor(node.prefix) for the accent color.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:46.061095+13:00","updatedAt":"2026-02-12T10:30:56.86785+13:00","closedAt":"2026-02-12T10:30:56.86785+13:00","closeReason":"Completed: 6d96fa3","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-9d3.4","title":"Pass mouse position from BeadsGraph on hover","description":"In components/BeadsGraph.tsx: (1) Track last mouse position via mousemove listener on the container div (same pattern as avatar hover hit-testing). Store in a ref: lastMouseRef = useRef({x:0,y:0}). (2) Update onNodeHover prop type from (node: GraphNode | null) => void to (node: GraphNode | null, x: number, y: number) => void. (3) In the ForceGraph2D onNodeHover handler, read lastMouseRef.current and pass clientX/clientY along with the node. (4) Update BeadsGraphProps interface accordingly.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:51.724328+13:00","updatedAt":"2026-02-12T10:30:56.688848+13:00","closedAt":"2026-02-12T10:30:56.688848+13:00","closeReason":"Completed: 6d96fa3","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-9lm","title":"Epic: Add radial, cluster-by-prefix, and spread graph layouts inspired by beads_viewer reference project","description":"Add three new graph layout modes to the beads-map force graph, inspired by the beads_viewer_for_agentic_coding_flywheel_setup reference project. Currently we have Force (physics-based) and DAG (topological top-down). This epic adds: (1) Radial — arranges nodes in concentric rings by dependency depth using d3.forceRadial, centered on origin. Root nodes (no incoming blockers) sit at center, deeper nodes in outer rings. (2) Cluster — groups nodes spatially by their project prefix (e.g., beads-map, beads, etc.) using d3.forceX/forceY pulling nodes toward prefix-specific center points arranged in a circle. Useful for multi-repo graphs to see project boundaries. (3) Spread — same physics as Force but with much stronger repulsion (charge -300), larger link distances (180), and weaker center pull. Maximizes spacing for readability and screenshot exports. All three are implemented purely via d3-force configuration in the existing layout useEffect in BeadsGraph.tsx — no new components or files needed. The dagMode prop on ForceGraph2D is only 'td' for DAG; all other modes use undefined (physics-only). Reference: beads_viewer_for_agentic_coding_flywheel_setup/graph.js lines 2420-2465 (applyRadialLayout, applyClusterLayout, applyForceLayout functions).","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:23:43.98936+13:00","updatedAt":"2026-02-12T11:30:38.998185+13:00","closedAt":"2026-02-12T11:30:38.998185+13:00","closeReason":"All 5 subtasks completed: imports (0137bf2), radial+cluster+spread forces (cee4c87), UI buttons (8d08e1c)","prefix":"beads-map","blockerCount":5,"dependentCount":0,"blockerIds":["beads-map-9lm.1","beads-map-9lm.3","beads-map-9lm.4","beads-map-9lm.5","beads-map-9lm.6"],"dependentIds":[]},{"id":"beads-map-9lm.1","title":"Add d3-force imports and extend LayoutMode type","description":"In components/BeadsGraph.tsx, make two changes: (1) Line 12 — extend the d3-force import from 'import { forceCollide } from \"d3-force\"' to 'import { forceCollide, forceRadial, forceX, forceY } from \"d3-force\"'. All four are exported from d3-force (verified: node_modules/d3-force/src/ contains radial.js, x.js, y.js alongside collide.js). (2) Line 21 — extend the LayoutMode type from 'type LayoutMode = \"force\" | \"dag\"' to 'type LayoutMode = \"force\" | \"dag\" | \"radial\" | \"cluster\" | \"spread\"'. This is a prerequisite for all three layout tasks — the new imports are used by radial (forceRadial) and cluster (forceX, forceY) layouts, and the type extension lets all five layouts be assigned to layoutMode state. Note: the existing dagMode prop logic (layoutMode === \"dag\" ? \"td\" : undefined at ~line 1876) automatically handles the new modes correctly since none equal \"dag\". Acceptance: pnpm build passes with zero errors. The new LayoutMode values are usable in useState and the switch branches.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:23:50.369669+13:00","updatedAt":"2026-02-12T11:28:17.095657+13:00","closedAt":"2026-02-12T11:28:17.095657+13:00","closeReason":"Completed: 0137bf2","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-9lm.3","beads-map-9lm.4","beads-map-9lm.5"],"dependentIds":["beads-map-9lm"]},{"id":"beads-map-9lm.3","title":"Implement radial layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"radial\")' branch after the existing 'dag' and 'force' branches. Implementation steps: (1) COMPUTE BFS DEPTH: Build an incoming-edges map from viewLinks — only count 'blocks' edges (skip 'parent-child'). Find root nodes (those with no incoming blocks edges). BFS outward from roots: for each node reached, set depth = parent_depth + 1. Store depth transiently as node._depth on each viewNode object (underscore prefix = transient animation metadata convention per AGENTS.md). Nodes unreachable from any root get _depth = 0. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as the force branch ~line 642) — these may be left over from DAG mode which sets fixed positions. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-100).distanceMax(300); fg.d3Force('link')?.distance(80).strength(0.5); fg.d3Force('center')?.strength(0.01); fg.d3Force('radial', forceRadial((node: any) => ((node as any)._depth || 0) * 80, 0, 0).strength(0.5)); fg.d3Force('x', forceX(0).strength(0.05)); fg.d3Force('y', forceY(0).strength(0.05)); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 5).strength(0.6)). (4) CROSS-TASK CLEANUP: The existing 'dag' and 'force' branches must be updated to clear the new custom forces — add fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); at the start of each non-radial branch. This prevents stale radial/x/y forces from persisting when switching away from radial mode. (5) ADD viewLinks TO USEEFFECT DEPS: Currently the deps are [layoutMode, viewNodes.length]. The radial BFS reads viewLinks, so add viewLinks to the dependency array. Edge cases: (a) If graph has cycles, BFS may not reach all nodes — default _depth=0 is fine, they cluster at center. (b) If all nodes are roots (no blocks edges), all get _depth=0 and cluster at center ring — this is correct behavior for a flat graph. Acceptance: selecting Radial layout arranges nodes in concentric rings by dependency depth. Root nodes at center, leaf nodes on outer rings. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:08.116392+13:00","updatedAt":"2026-02-12T11:29:23.576116+13:00","closedAt":"2026-02-12T11:29:23.576116+13:00","closeReason":"Completed: cee4c87","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-9lm.6"],"dependentIds":["beads-map-9lm","beads-map-9lm.1"]},{"id":"beads-map-9lm.4","title":"Implement cluster-by-prefix layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"cluster\")' branch. Implementation steps: (1) COMPUTE PREFIX CENTERS: Get unique prefixes from viewNodes via new Set(viewNodes.map(n => n.prefix)). Arrange center positions in a circle: radius = Math.max(200, prefixes.length * 50). For each prefix at index i, center = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius } where angle = (2 * Math.PI * i / count) - Math.PI / 2 (start from top). Store in a local Map<string, {x: number, y: number}>. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same as force/radial branches). (3) CLEAR STALE FORCES: fg.d3Force('radial', null) to remove any leftover radial force from a previous layout. (4) CONFIGURE FORCES: fg.d3Force('x', forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.3)); fg.d3Force('y', forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.3)); fg.d3Force('charge')?.strength(-40).distanceMax(250); fg.d3Force('link')?.distance(60).strength(0.3); fg.d3Force('center')?.strength(0.01); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 6).strength(0.7)). Design notes: Uses forceX/forceY per-node accessors to pull each node toward its prefix cluster center. Weaker charge (-40) keeps nodes within their cluster rather than repelling to distant positions. Cross-prefix links will stretch across clusters, making inter-project dependencies visually obvious. Edge cases: (a) Single-prefix graph — all nodes cluster at one position, which is fine (effectively same as force). (b) node.prefix is always set per lib/types.ts:51 so the Map lookup always succeeds. Acceptance: selecting Cluster layout spatially groups nodes by project prefix. Multi-repo graphs show distinct clusters. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:14.896035+13:00","updatedAt":"2026-02-12T11:29:23.709649+13:00","closedAt":"2026-02-12T11:29:23.709649+13:00","closeReason":"Completed: cee4c87","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-9lm.6"],"dependentIds":["beads-map-9lm","beads-map-9lm.1"]},{"id":"beads-map-9lm.5","title":"Implement spread layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"spread\")' branch. This is the simplest layout — identical to the existing force branch but with tuned parameters for maximum spacing and readability. Implementation steps: (1) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as force branch ~line 642). (2) CLEAR STALE FORCES: fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); — remove any custom forces from radial/cluster modes. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-300).distanceMax(500); fg.d3Force('link')?.distance(180).strength(0.4); fg.d3Force('center')?.strength(0.02); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 8).strength(0.8)). Key differences from force mode: charge is -300 vs -180 (much stronger repulsion), link distance is 180 vs 90-120 (wider gaps), center is 0.02 vs 0.03 (weaker pull so graph can spread), collision radius is +8 vs +6 (more buffer). Inspired by beads_viewer reference: LAYOUT_PRESETS.spread uses linkDistance 180, chargeStrength -300, centerStrength 0.02. No link distance per-connection intelligence needed (unlike force mode which varies by connection count) — uniform spacing is the point. Acceptance: selecting Spread layout produces a well-spaced graph suitable for screenshots and exports. Nodes should not overlap. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:20.51105+13:00","updatedAt":"2026-02-12T11:29:23.830149+13:00","closedAt":"2026-02-12T11:29:23.830149+13:00","closeReason":"Completed: cee4c87","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-9lm.6"],"dependentIds":["beads-map-9lm","beads-map-9lm.1"]},{"id":"beads-map-9lm.6","title":"Add layout toggle buttons for Radial, Cluster, Spread","description":"In components/BeadsGraph.tsx, expand the existing 2-button layout segmented control (Force/DAG) to 5 buttons: Force, DAG, Radial, Cluster, Spread. The existing buttons are at ~line 1609 inside a div with className 'flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden'. Each button follows the exact same pattern: (a) onClick={() => setLayoutMode('radial')} etc., (b) active state: bg-emerald-500 text-white, inactive: text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50, (c) inner span with SVG icon (16x16 viewBox) + hidden sm:inline text label, (d) w-px bg-zinc-200 divider between each button. Existing buttons to keep: Force (scattered dots with connections icon, ~line 1610-1638) and DAG (top-down tree icon, ~line 1641-1672). New buttons to add after DAG: (1) RADIAL: icon = concentric circles (e.g., circle cx=8 cy=8 r=2 filled + circle r=5 stroke-only + circle r=7 stroke-only opacity=0.4), label 'Radial'. (2) CLUSTER: icon = grouped dots (e.g., 3 dots upper-left clustered + 3 dots lower-right clustered, suggesting two groups), label 'Cluster'. (3) SPREAD: icon = scattered dots with ample spacing (e.g., 5 small dots spread across the 16x16 viewBox with no connections, suggesting maximum spacing), label 'Spread'. Each new button needs a divider (w-px bg-zinc-200) before it. The layoutMode state variable is at ~line 255: useState<LayoutMode>('dag'). The bootstrap trick (~line 666) auto-switches from DAG to force on initial load — this should remain unchanged (new layouts are only activated by user click). Acceptance: all 5 buttons render correctly, clicking each switches the layout. Active button is visually highlighted. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:29.190312+13:00","updatedAt":"2026-02-12T11:30:38.877928+13:00","closedAt":"2026-02-12T11:30:38.877928+13:00","closeReason":"Completed: 8d08e1c","prefix":"beads-map","blockerCount":0,"dependentCount":4,"blockerIds":[],"dependentIds":["beads-map-9lm","beads-map-9lm.3","beads-map-9lm.4","beads-map-9lm.5"]},{"id":"beads-map-cvh","title":"ATProto login with identity display and annotation support","description":"Add ATProto (Bluesky) OAuth login to beads-map, porting the auth infrastructure from Hyperscan. Users can sign in with their Bluesky handle, see their avatar/name in the header, and (in future work) leave annotations on issues in the graph.\n\nARCHITECTURE:\n- OAuth 2.0 Authorization Code flow with PKCE via @atproto/oauth-client-node\n- Encrypted cookie sessions via iron-session (no client-side token storage)\n- React Context (AuthProvider + useAuth hook) for client-side auth state\n- 6 API routes (login, callback, client-metadata, jwks, status, logout)\n- Sign-in modal + avatar dropdown in header top-right (next to stats)\n- Support both confidential (production) and public (dev) OAuth client modes\n\nWHY HYPERSCAN'S APPROACH:\nHyperscan already solved this for ATProto in a Next.js App Router context. Their implementation is production-grade, handles all edge cases (reconnection, network errors, session restoration), and follows OAuth best practices. We'll port the core auth infrastructure verbatim, then adapt the UI to match beads-map's design.\n\nDEPENDENCY ON PAST WORK:\nThis modifies app/page.tsx (header) and app/layout.tsx (AuthProvider wrapper), which were last touched by the live-update epic (beads-map-3jy). The two features are independent but touch the same files.\n\nSCOPE:\nThis epic covers ONLY the auth infrastructure and identity display (avatar in header). Annotation features (writing comments to ATProto) will be a separate follow-up epic that builds on the authenticated agent helper (task .7).\n\nTASK BREAKDOWN:\n.1 - Install deps + env setup\n.2 - Session management (iron-session)\n.3 - OAuth client factory (confidential + public modes)\n.4 - Auth API routes (login, callback, status, logout, metadata, jwks)\n.5 - AuthProvider + useAuth hook\n.6 - AuthButton component (sign-in modal + avatar dropdown)\n.7 - Authenticated agent helper (for future annotation writes)\n.8 - Build + integration test\n\nFILES TO CREATE (13 files):\n- .env.example\n- scripts/generate-jwk.js\n- lib/env.ts\n- lib/session.ts\n- lib/auth/client.ts\n- lib/auth.tsx\n- lib/agent.ts\n- app/api/login/route.ts\n- app/api/oauth/callback/route.ts\n- app/api/oauth/client-metadata.json/route.ts\n- app/api/oauth/jwks.json/route.ts\n- app/api/status/route.ts\n- app/api/logout/route.ts\n- components/AuthButton.tsx\n\nFILES TO MODIFY (2 files):\n- package.json (add 5 dependencies)\n- app/layout.tsx (wrap in AuthProvider)\n- app/page.tsx (add <AuthButton /> to header)","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:56:19.74299+13:00","updatedAt":"2026-02-11T00:06:12.831198+13:00","closedAt":"2026-02-11T00:06:12.831198+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":9,"dependentCount":1,"blockerIds":["beads-map-cvh.1","beads-map-cvh.2","beads-map-cvh.3","beads-map-cvh.4","beads-map-cvh.5","beads-map-cvh.6","beads-map-cvh.7","beads-map-cvh.8","beads-map-dyi"],"dependentIds":["beads-map-3jy"]},{"id":"beads-map-cvh.1","title":"Install ATProto auth dependencies and environment setup","description":"Foundation task: install npm packages, create environment variable template, add JWK generation script, and create env validation utility.\n\nPART 1: Install dependencies\n\nAdd to package.json:\n pnpm add @atproto/oauth-client-node@^0.3.15 @atproto/api@^0.18.16 @atproto/jwk-jose@^0.1.11 @atproto/syntax@^0.4.2 iron-session@^8.0.4\n\nPART 2: Create .env.example\n\nFile: /Users/david/Projects/gainforest/beads-map/.env.example\nContent:\n# ATProto OAuth Authentication\n# Copy this file to .env.local and fill in the values\n\n# Required for all modes: Session encryption key (32+ chars)\nCOOKIE_SECRET=development-secret-at-least-32-chars!!\n\n# Required for production: Your app's public URL\n# Leave empty for localhost dev mode (uses public OAuth client)\nPUBLIC_URL=\n\n# Optional: Dev server port (default 3000)\nPORT=3000\n\n# Required for production confidential client: ES256 JWK private key\n# Generate with: node scripts/generate-jwk.js\n# Leave empty for localhost dev mode\nATPROTO_JWK_PRIVATE=\n\nPART 3: Create scripts/generate-jwk.js\n\nCopy verbatim from Hyperscan: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\nMake executable: chmod +x scripts/generate-jwk.js\n\nPART 4: Create lib/env.ts\n\nFile: /Users/david/Projects/gainforest/beads-map/lib/env.ts\nPort from Hyperscan's /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n- Validates COOKIE_SECRET, PUBLIC_URL, PORT, ATPROTO_JWK_PRIVATE\n- Provides defaults for dev mode\n- Exports typed env object\n\nREFERENCE FILES:\n- Hyperscan package.json: /Users/david/Projects/gainforest/hyperscan/package.json\n- Hyperscan generate-jwk.js: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\n- Hyperscan env.ts: /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n\nACCEPTANCE CRITERIA:\n- All 5 packages installed in package.json dependencies\n- .env.example exists with all 4 env vars documented\n- scripts/generate-jwk.js exists and is executable\n- lib/env.ts exists and validates env vars\n- pnpm build passes (env.ts may not be used yet, but must compile)\n- .gitignore already has .env* (from earlier work)\n\nNOTES:\n- Do NOT create .env.local -- user will do that manually\n- Do NOT commit any actual secrets\n- The env.ts validation will allow missing values for dev mode (defaults kick in)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:56:38.692755+13:00","updatedAt":"2026-02-11T00:06:08.670005+13:00","closedAt":"2026-02-11T00:06:08.670005+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-cvh.2"],"dependentIds":["beads-map-cvh"]},{"id":"beads-map-cvh.2","title":"Create session management with iron-session","description":"Port Hyperscan's iron-session setup for encrypted cookie-based authentication sessions.\n\nPURPOSE: iron-session encrypts session data into cookies (no database needed). The session stores user identity (did, handle, displayName, avatar) and OAuth session tokens for authenticated API calls.\n\nCREATE FILE: lib/session.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nKey changes when porting:\n1. Cookie name: 'impact_indexer_sid' -> 'beads_map_sid'\n2. Import env from our lib/env.ts (not Hyperscan's path)\n3. Keep the same session shape:\n interface Session {\n did?: string\n handle?: string\n displayName?: string\n avatar?: string\n returnTo?: string\n oauthSession?: string\n }\n4. Keep the same exports:\n - getSession(request/cookies)\n - getRawSession(request/cookies)\n - clearSession(request/cookies)\n\nIMPLEMENTATION NOTES:\n- Use env.COOKIE_SECRET for encryption\n- secure: true only when env.PUBLIC_URL is set (production)\n- maxAge: 30 days (same as Hyperscan)\n- Support both Next.js Request and cookies() from next/headers (for Server Components vs API routes)\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nACCEPTANCE CRITERIA:\n- lib/session.ts exists\n- Exports getSession, getRawSession, clearSession\n- Session type matches Hyperscan's shape\n- Cookie is secure in production (when PUBLIC_URL set), insecure in dev\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:01.110787+13:00","updatedAt":"2026-02-11T00:06:08.757647+13:00","closedAt":"2026-02-11T00:06:08.757647+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.3"],"dependentIds":["beads-map-cvh","beads-map-cvh.1"]},{"id":"beads-map-cvh.3","title":"Create OAuth client factory with dual mode support","description":"Port Hyperscan's OAuth client setup with support for both confidential (production) and public (localhost dev) client modes.\n\nPURPOSE: The OAuth client handles the full Authorization Code flow with PKCE. It needs two modes:\n- Public client (dev): loopback client_id, no secrets, works on localhost\n- Confidential client (prod): uses ES256 JWK for private_key_jwt auth, requires PUBLIC_URL\n\nCREATE FILE: lib/auth/client.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nKey adaptations:\n1. Import Session type from our lib/session.ts\n2. Import env from our lib/env.ts\n3. clientName: 'Beads Map' (not 'Impact Indexer')\n4. The sessionStore must sync OAuth session data between in-memory Map and iron-session cookies (critical for serverless)\n\nIMPLEMENTATION NOTES:\n- Export getGlobalOAuthClient() as the main API\n- If PUBLIC_URL is set: confidential mode (load JWK from env.ATPROTO_JWK_PRIVATE, publish client-metadata.json and jwks.json)\n- If PUBLIC_URL is empty: public mode (loopback client_id, use 127.0.0.1 not localhost, no JWK)\n- The client is cached globally per process (singleton pattern)\n- Session store serializes OAuth session (tokens, DPoP keys) to/from cookie.oauthSession field\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nACCEPTANCE CRITERIA:\n- lib/auth/client.ts exists (create lib/auth/ dir)\n- Exports getGlobalOAuthClient()\n- Supports both confidential and public modes based on env.PUBLIC_URL\n- Session store syncs with iron-session cookie\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:16.261043+13:00","updatedAt":"2026-02-11T00:06:08.840672+13:00","closedAt":"2026-02-11T00:06:08.840672+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-cvh.4","beads-map-cvh.7"],"dependentIds":["beads-map-cvh","beads-map-cvh.2"]},{"id":"beads-map-cvh.4","title":"Create 6 authentication API routes","description":"Port all 6 auth-related API routes from Hyperscan.\n\nCREATE 6 ROUTE FILES:\n\n1. app/api/login/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n - POST handler\n - Validates handle with @atproto/syntax isValidHandle\n - Calls client.authorize(handle)\n - Stores returnTo in session cookie\n - Returns { redirectUrl }\n\n2. app/api/oauth/callback/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n - GET handler\n - Calls client.callback(params) to exchange code for tokens\n - Fetches profile via @atproto/api Agent\n - Saves { did, handle, displayName, avatar, oauthSession } to session cookie\n - Redirects to returnTo (303 redirect)\n - Has retry logic for network errors\n\n3. app/api/oauth/client-metadata.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n - GET handler (only in confidential mode)\n - Returns OAuth client metadata JSON per ATProto spec\n\n4. app/api/oauth/jwks.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n - GET handler (only in confidential mode)\n - Returns JWKS public keys\n\n5. app/api/status/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n - GET handler\n - Reads session cookie\n - Returns { authenticated, did, handle, displayName, avatar } or { authenticated: false }\n\n6. app/api/logout/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n - POST handler\n - Calls clearSession()\n - Returns { success: true }\n\nKEY NOTES:\n- All routes use export const dynamic = 'force-dynamic'\n- Import from our lib/ paths (not Hyperscan's src/lib/)\n- The callback route should handle both OAuth success and error states\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n\nACCEPTANCE CRITERIA:\n- All 6 route files exist in correct paths\n- Each exports the correct HTTP method handler (GET or POST)\n- All routes compile and pnpm build passes\n- All routes use dynamic = 'force-dynamic'\n- Imports use beads-map paths (not Hyperscan's)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:32.923191+13:00","updatedAt":"2026-02-11T00:06:08.921439+13:00","closedAt":"2026-02-11T00:06:08.921439+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.5"],"dependentIds":["beads-map-cvh","beads-map-cvh.3"]},{"id":"beads-map-cvh.5","title":"Create AuthProvider and useAuth hook","description":"Port Hyperscan's client-side auth state management: React Context provider and useAuth hook. Wrap the app in AuthProvider.\n\nCREATE FILE: lib/auth.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n\nKey pieces:\n1. AuthContext with shape: state, login, logout\n2. AuthProvider component:\n - Manages auth status: idle, authorizing, authenticated, error\n - On mount: checks /api/status to restore session\n - Exposes login(handle) and logout() functions\n3. useAuth() hook:\n - Returns status, session, isLoading, isAuthenticated, login, logout\n - session shape: did, handle, displayName, avatar or null\n\nLOGIN FLOW client-side:\n1. User calls login(handle)\n2. POST to /api/login with handle and returnTo\n3. Server returns redirectUrl\n4. window.location.href = redirectUrl (redirect to PDS)\n5. After OAuth callback completes, browser redirects back to returnTo\n6. AuthProvider re-checks /api/status and updates context\n\nLOGOUT FLOW:\n1. User calls logout()\n2. POST to /api/logout\n3. Clear local state\n4. Optionally reload or redirect\n\nMODIFY FILE: app/layout.tsx\n\nWrap children in AuthProvider from lib/auth.tsx\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n/Users/david/Projects/gainforest/hyperscan/src/app/layout.tsx (for wrapper example)\n\nACCEPTANCE CRITERIA:\n- lib/auth.tsx exists\n- Exports AuthProvider, useAuth\n- useAuth returns correct shape\n- app/layout.tsx wraps children in AuthProvider\n- pnpm build passes\n- No client-side token storage, all session state comes from /api/status reading cookies","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:56.261859+13:00","updatedAt":"2026-02-11T00:06:09.003714+13:00","closedAt":"2026-02-11T00:06:09.003714+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.6"],"dependentIds":["beads-map-cvh","beads-map-cvh.4"]},{"id":"beads-map-cvh.6","title":"Create AuthButton component and integrate into header","description":"Port Hyperscan's AuthButton component (sign-in modal + avatar dropdown) and add it to the page.tsx header top-right, next to the stats.\n\nCREATE FILE: components/AuthButton.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n\nKey UI elements:\n1. When logged out: Sign in text link\n2. Click opens modal with:\n - Title: Sign in with ATProto\n - Handle input field with placeholder alice.bsky.social\n - Helper text: Just a username? We will add .bsky.social for you\n - Cancel + Connect buttons (emerald-600 green for Connect)\n - Error display area\n - Backdrop blur overlay\n3. When logged in: Avatar (24x24 rounded) + display name/handle (truncated)\n4. Click avatar opens dropdown with:\n - Profile link (to /profile if we add that page, or just show did for now)\n - Divider\n - Sign out button\n\nThe component uses useAuth() hook from lib/auth.tsx for status, session, login, logout.\n\nADAPT STYLING:\n- Use beads-map's design tokens: emerald-500/600, zinc colors, same border-radius, same shadows\n- Match the existing header style (text-xs, simple, clean)\n- Modal should be centered with 20vh from top (same as Hyperscan)\n\nMODIFY FILE: app/page.tsx\n\nAdd AuthButton to header right section (line 644-662). Current structure:\n Left: Logo + title\n Center: Search\n Right: Stats (total issues, deps, projects)\n\nNew structure:\n Right: Stats + vertical divider + <AuthButton />\n\nThe stats div stays, just add:\n <span className=\"w-px h-4 bg-zinc-200\" />\n <AuthButton />\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n/Users/david/Projects/gainforest/hyperscan/src/components/Header.tsx (for placement example)\n\nACCEPTANCE CRITERIA:\n- components/AuthButton.tsx exists\n- Shows sign-in text link when logged out\n- Shows avatar + name/handle when logged in\n- Modal opens on click when logged out, dropdown on click when logged in\n- Integrated into page.tsx header right section\n- Matches beads-map visual style (emerald, zinc, text-xs)\n- pnpm build passes\n- Dev server shows the button (no visual regression on existing header)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:58:15.698478+13:00","updatedAt":"2026-02-11T00:06:09.086644+13:00","closedAt":"2026-02-11T00:06:09.086644+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.8"],"dependentIds":["beads-map-cvh","beads-map-cvh.5"]},{"id":"beads-map-cvh.7","title":"Create authenticated agent helper for ATProto API calls","description":"Create a server-side utility that restores an authenticated ATProto Agent from the session cookie. This is the foundation for future annotation writes.\n\nCREATE FILE: lib/agent.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nPurpose:\n- Server-side only (API routes, Server Components, Server Actions)\n- Reads session cookie to get oauthSession data\n- Calls client.restore(did, oauthSession) to rebuild OAuth session\n- Returns @atproto/api Agent instance for making authenticated ATProto API calls\n\nKey function:\nexport async function getAuthenticatedAgent(request: Request): Promise<Agent>\n - Reads session via getSession(request)\n - If no session.did or session.oauthSession: throw Error(Unauthorized)\n - Deserialize oauthSession\n - Call client.restore(did, sessionData)\n - Return new Agent(restoredOAuthSession)\n\nUSAGE EXAMPLE (for future annotation API):\n// In app/api/annotations/route.ts\nimport { getAuthenticatedAgent } from '@/lib/agent'\n\nexport async function POST(request: Request) {\n const agent = await getAuthenticatedAgent(request)\n // agent.com.atproto.repo.createRecord(...)\n}\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nACCEPTANCE CRITERIA:\n- lib/agent.ts exists\n- Exports getAuthenticatedAgent(request)\n- Throws clear error if not authenticated\n- Returns Agent instance ready for ATProto API calls\n- pnpm build passes\n- NOT YET USED (will be used in future annotation epic), but must compile","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:58:28.649345+13:00","updatedAt":"2026-02-11T00:06:09.166246+13:00","closedAt":"2026-02-11T00:06:09.166246+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.8"],"dependentIds":["beads-map-cvh","beads-map-cvh.3"]},{"id":"beads-map-cvh.8","title":"Build verification and integration test","description":"Final verification that the ATProto login system works end-to-end in dev mode (public OAuth client).\n\nPART 1: Build check\n pnpm build -- must pass with zero errors\n\nPART 2: Dev server smoke test\n\nStart dev server with existing beads project:\n BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n\nBrowser tests:\n1. Open http://localhost:3000\n2. Header should show: Logo | Search | Stats | Sign in (new!)\n3. Click Sign in -> modal opens\n4. Enter a Bluesky handle (e.g. alice.bsky.social)\n5. Click Connect -> redirects to bsky.social OAuth consent screen\n6. Approve -> redirects back to beads-map\n7. Header should now show: Logo | Search | Stats | Avatar + name (alice.bsky.social)\n8. Click avatar -> dropdown opens with Sign out\n9. Click Sign out -> back to unauthenticated state\n10. Refresh page -> session persists (avatar still shows)\n11. Open devtools Network tab -> no client-side token storage, only encrypted cookies\n\nServer logs should show:\n- Watching N files for changes (from file watcher, unrelated)\n- No OAuth client errors\n- If using localhost: should see public client mode log\n\nPART 3: Session persistence check\n- Login\n- Close browser tab\n- Reopen http://localhost:3000\n- Should still be logged in (session cookie persists)\n\nPART 4: Error handling check\n- Click Sign in\n- Enter invalid handle (e.g. test.invalid)\n- Should show error message in modal (not crash)\n\nFUNCTIONAL CHECKS:\n- Graph still works (nodes, links, search, layout toggle)\n- Stats still update in real-time\n- No console errors during login/logout flow\n- No memory leaks (EventSource from earlier work still cleans up)\n\nPERFORMANCE CHECKS:\n- Page load time not significantly affected by auth check\n- No jank during modal open/close animations\n\nKNOWN LIMITATIONS (OK for this epic):\n- /profile route does not exist yet (clicking Profile in dropdown would 404)\n- No annotation features yet (will be follow-up epic)\n- Confidential client mode not tested (requires PUBLIC_URL + JWK in production)\n\nACCEPTANCE CRITERIA:\n- pnpm build passes\n- Can log in via localhost OAuth in dev mode\n- Avatar + name display correctly when logged in\n- Session persists across page refresh\n- Logout works\n- No console errors\n- All existing features (graph, search, live updates) still work","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:58:49.014769+13:00","updatedAt":"2026-02-11T00:06:09.245646+13:00","closedAt":"2026-02-11T00:06:09.245646+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":3,"blockerIds":[],"dependentIds":["beads-map-cvh","beads-map-cvh.6","beads-map-cvh.7"]},{"id":"beads-map-dyi","title":"Right-click comment tooltip on graph nodes with ATProto annotations","description":"## Right-click comment tooltip on graph nodes with ATProto annotations\n\n### Summary\nAdd right-click context menu on graph nodes that opens a beautiful floating tooltip (inspired by plresearch.org dependency graph) with a text input to post ATProto comments using the `org.impactindexer.review.comment` lexicon. Fetch existing comments from the Hypergoat indexer, show comment icon badge on nodes with comments, and display a full comment section in the NodeDetail sidebar panel.\n\n### Subject URI Convention\nComments target beads issues using: `{ uri: 'beads:<issue-id>', type: 'record' }`\nExample: `{ uri: 'beads:beads-map-cvh', type: 'record' }`\n\n### Architecture\n```\n[User right-clicks node] → CommentTooltip appears (positioned near cursor)\n → [User types + clicks Send]\n → POST /api/records → getAuthenticatedAgent() → agent.com.atproto.repo.createRecord()\n → Record written to user's PDS as org.impactindexer.review.comment\n → refetch() → Hypergoat GraphQL indexer → updated commentsByNode Map\n → [Comment badge appears on node] + [Comments shown in NodeDetail sidebar]\n```\n\n### Task dependency chain\n- .1 (API route) and .2 (comments hook) are independent — can be done in parallel\n- .3 (right-click + tooltip) is independent but uses auth awareness\n- .4 (comment badge) depends on .2 (needs commentedNodeIds)\n- .5 (NodeDetail comments) depends on .2 (needs comments data)\n- .6 (wiring) depends on ALL of .1-.5\n\n### Key reference files\n- Hyperscan API route: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`\n- Hypergoat indexer: `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts`\n- Tooltip design: `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Subject ref defs: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/defs.json`\n\n### New files to create\n1. `app/api/records/route.ts` — generic ATProto record CRUD route\n2. `hooks/useBeadsComments.ts` — fetch + parse comments from indexer\n3. `components/CommentTooltip.tsx` — floating right-click comment tooltip\n\n### Files to modify\n1. `components/BeadsGraph.tsx` — add onNodeRightClick prop + comment badge in paintNode\n2. `components/NodeDetail.tsx` — add Comments section at bottom\n3. `app/page.tsx` — wire everything together\n\n### Build & test\n```bash\npnpm build # Must pass with zero errors\nBEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev # Manual test\n```\n\n### Design specification (from plresearch.org)\n- White bg, border `1px solid #E5E7EB`, border-radius 8px\n- Shadow: `0 8px 32px rgba(0,0,0,0.08)`\n- Padding: 18px 20px\n- Colored accent bar (24px x 2px) using node prefix color\n- Fade-in animation: 0.2s ease from opacity:0 translateY(4px)\n- Comment badge on nodes: blue (#3b82f6) speech bubble at top-right","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:11.044718+13:00","updatedAt":"2026-02-11T00:47:32.504475+13:00","closedAt":"2026-02-11T00:47:32.504475+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":8,"dependentCount":1,"blockerIds":["beads-map-dyi.1","beads-map-dyi.2","beads-map-dyi.3","beads-map-dyi.4","beads-map-dyi.5","beads-map-dyi.6","beads-map-dyi.7","beads-map-vdg"],"dependentIds":["beads-map-cvh"]},{"id":"beads-map-dyi.1","title":"Create /api/records route for ATProto record CRUD","description":"## Create /api/records route for ATProto record CRUD\n\n### Goal\nCreate `app/api/records/route.ts` — a generic server-side API route that allows authenticated users to create, update, and delete ATProto records on their PDS (Personal Data Server). This is the foundational route that the comment feature will use to write `org.impactindexer.review.comment` records.\n\n### What to create\n**New file:** `app/api/records/route.ts`\n\n### Source to port from\nCopy almost verbatim from Hyperscan: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts` (136 lines). The only changes needed are import paths (`@/lib/agent` → `@/lib/agent`, `@/lib/session` → `@/lib/session` — these are actually identical since beads-map uses the same structure).\n\n### Implementation details\n\nThe file exports three HTTP handlers:\n\n**POST /api/records** — Create a new record:\n```typescript\nimport { NextRequest, NextResponse } from 'next/server'\nimport { getAuthenticatedAgent } from '@/lib/agent'\nimport { getSession } from '@/lib/session'\n\nexport const dynamic = 'force-dynamic'\n\nexport async function POST(request: NextRequest) {\n // 1. Check session.did from iron-session cookie → 401 if missing\n // 2. Call getAuthenticatedAgent() → 401 if null\n // 3. Parse body: { collection: string, rkey?: string, record: object }\n // 4. Validate collection (required, string) and record (required, object)\n // 5. Call agent.com.atproto.repo.createRecord({ repo: session.did, collection, rkey: rkey || undefined, record })\n // 6. Return { success: true, uri: res.data.uri, cid: res.data.cid }\n}\n```\n\n**PUT /api/records** — Update an existing record:\n- Same auth checks\n- Body: { collection, rkey (required), record }\n- Calls agent.com.atproto.repo.putRecord(...)\n- Returns { success: true }\n\n**DELETE /api/records?collection=...&rkey=...** — Delete a record:\n- Same auth checks\n- Params from URL searchParams\n- Calls agent.com.atproto.repo.deleteRecord(...)\n- Returns { success: true }\n\nAll methods wrap in try/catch, returning { error: message } with status 500 on failure.\n\n### Dependencies already in place\n- `lib/agent.ts` — exports `getAuthenticatedAgent()` which returns an `Agent` from `@atproto/api` (already created in previous session)\n- `lib/session.ts` — exports `getSession()` returning `Session` with optional `did` field (already created)\n- `@atproto/api` — already installed in package.json\n\n### Testing\nAfter creating the file, run `pnpm build` to verify it compiles. The route should appear in the build output as `ƒ /api/records` (dynamic route).\n\n### Acceptance criteria\n- [ ] File exists at `app/api/records/route.ts`\n- [ ] Exports POST, PUT, DELETE handlers\n- [ ] Uses `export const dynamic = 'force-dynamic'`\n- [ ] All three methods check `session.did` and `getAuthenticatedAgent()`\n- [ ] `pnpm build` passes with no type errors","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:20.159813+13:00","updatedAt":"2026-02-11T00:44:02.816953+13:00","closedAt":"2026-02-11T00:44:02.816953+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-dyi.2","title":"Create useBeadsComments hook to fetch comments from Hypergoat indexer","description":"## Create useBeadsComments hook to fetch comments from Hypergoat indexer\n\n### Goal\nCreate `hooks/useBeadsComments.ts` — a React hook that fetches all `org.impactindexer.review.comment` records from the Hypergoat GraphQL indexer, filters them to only those whose subject URI starts with `beads:`, resolves commenter profiles, and returns structured data for the UI.\n\n### What to create\n**New file:** `hooks/useBeadsComments.ts`\n\n### Hypergoat GraphQL API\n- **Endpoint:** `https://hypergoat-app-production.up.railway.app/graphql`\n- **Query pattern** (from `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts` line 80-100):\n```graphql\nquery FetchRecords($collection: String!, $first: Int, $after: String) {\n records(collection: $collection, first: $first, after: $after) {\n edges {\n node {\n cid\n collection\n did\n rkey\n uri\n value\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n```\n- Call with `collection: 'org.impactindexer.review.comment'`, `first: 100`\n- The `value` field is a JSON object containing the record data including `subject`, `text`, `createdAt`\n\n### Comment record shape (from `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`):\n```typescript\n{\n subject: { uri: string, type: string }, // e.g. { uri: 'beads:beads-map-cvh', type: 'record' }\n text: string,\n createdAt: string, // ISO 8601\n replyTo?: string, // AT-URI of parent comment (not used yet but good to preserve)\n}\n```\n\n### Profile resolution\nFor each unique `did` in comments, resolve to display info via the Bluesky public API:\n```typescript\n// Resolve DID to profile (handle, displayName, avatar)\nconst res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`)\nconst profile = await res.json()\n// Returns: { did, handle, displayName?, avatar? }\n```\nCache results in a module-level Map<string, ResolvedProfile> to avoid redundant fetches. Deduplicate in-flight requests.\n\n### Hook interface\n```typescript\ninterface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string; // AT-URI of the comment record itself\n rkey: string;\n}\n\ninterface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // key = beads issue ID (e.g. 'beads-map-cvh')\n commentedNodeIds: Set<string>; // for quick badge lookup in paintNode\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n\nexport function useBeadsComments(): UseBeadsCommentsResult\n```\n\n### Implementation steps\n1. On mount, call the GraphQL endpoint to fetch comments\n2. Parse each record's `value.subject.uri` — only keep those starting with `beads:`\n3. Extract the beads issue ID by stripping the `beads:` prefix (e.g. `beads:beads-map-cvh` → `beads-map-cvh`)\n4. Group comments by issue ID into a Map\n5. Build `commentedNodeIds` Set from the Map keys\n6. Resolve all unique DIDs to profiles in parallel (with caching)\n7. Merge profile data into each BeadsComment object\n8. Return the result with a `refetch()` function that re-runs the whole pipeline\n9. Comments within each node should be sorted newest-first by `createdAt`\n\n### Error handling\n- Silent failure on profile resolution (show DID prefix as fallback)\n- Set error state on GraphQL fetch failure\n- Use `cancelled` flag pattern for cleanup (matches existing codebase convention)\n\n### No dependencies to add\nThis hook only uses `fetch()` and React hooks — no new npm packages needed.\n\n### Acceptance criteria\n- [ ] File exists at `hooks/useBeadsComments.ts`\n- [ ] Fetches from Hypergoat GraphQL endpoint\n- [ ] Filters comments to only `beads:*` subject URIs\n- [ ] Groups by issue ID, provides `commentedNodeIds` Set\n- [ ] Resolves commenter profiles (handle, avatar)\n- [ ] Provides `refetch()` method\n- [ ] `pnpm build` passes (even if hook isn't wired up yet — it should have no import errors)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:28.751791+13:00","updatedAt":"2026-02-11T00:44:02.935547+13:00","closedAt":"2026-02-11T00:44:02.935547+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-dyi.4","beads-map-dyi.5","beads-map-dyi.6"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-dyi.3","title":"Add right-click handler to BeadsGraph and context menu tooltip component","description":"## Add right-click handler to BeadsGraph and context menu tooltip component\n\n### Goal\nEnable right-clicking on graph nodes to open a beautiful floating comment tooltip. Create the `CommentTooltip` component and wire the right-click event through BeadsGraph to the parent page.\n\n### Part A: Modify `components/BeadsGraph.tsx`\n\n**1. Add `onNodeRightClick` to the props interface** (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n nodes: GraphNode[];\n links: GraphLink[];\n selectedNode: GraphNode | null;\n hoveredNode: GraphNode | null;\n onNodeClick: (node: GraphNode) => void;\n onNodeHover: (node: GraphNode | null) => void;\n onBackgroundClick: () => void;\n onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void; // NEW\n}\n```\n\n**2. Destructure the new prop** in the component function (around line 145):\n```typescript\nconst { nodes, links, selectedNode, hoveredNode, onNodeClick, onNodeHover, onBackgroundClick, onNodeRightClick } = props;\n```\nNote: BeadsGraph uses `forwardRef` — the props are the first argument.\n\n**3. Add `onNodeRightClick` to ForceGraph2D** (around line 1231-1235, after `onNodeClick`):\n```typescript\nonNodeRightClick={(node: any, event: MouseEvent) => {\n event.preventDefault();\n onNodeRightClick?.(node as GraphNode, event);\n}}\n```\nThe `react-force-graph-2d` library supports `onNodeRightClick` as a built-in prop. The `event.preventDefault()` prevents the browser's default context menu.\n\n### Part B: Create `components/CommentTooltip.tsx`\n\n**New file:** `components/CommentTooltip.tsx`\n\nThis is a `'use client'` component that renders an absolutely-positioned floating tooltip near the right-click location.\n\n**Design inspiration:** The tooltip from `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx` (lines 237-274). Key design elements:\n- White background (`#FFFFFF`)\n- Subtle border: `1px solid #E5E7EB`\n- Border radius: `8px`\n- Padding: `18px 20px`\n- Box shadow: `0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08)`\n- Fade-in animation: `0.2s ease` from `opacity: 0; translateY(4px)` to `opacity: 1; translateY(0)`\n- Colored accent bar at top: `width: 24px, height: 2px` using the node's prefix color\n\n**Props:**\n```typescript\ninterface CommentTooltipProps {\n node: GraphNode;\n x: number; // screen X from MouseEvent.clientX\n y: number; // screen Y from MouseEvent.clientY\n onClose: () => void;\n onSubmit: (text: string) => Promise<void>;\n isAuthenticated: boolean;\n existingComments?: BeadsComment[]; // show recent comments in tooltip too\n}\n```\n\n**Layout (top to bottom):**\n1. **Colored accent bar** — 24px wide, 2px tall, using `PREFIX_COLORS[node.prefix]` from `@/lib/types`\n2. **Node info** — ID in mono font (`text-emerald-600`), title in `font-semibold text-sm text-zinc-800`\n3. **Existing comments preview** — if any, show count like '3 comments' as a subtle label, with the most recent 1-2 comments abbreviated\n4. **Textarea** — if authenticated: `<textarea>` with placeholder 'Leave a comment...', 3 rows, matching zinc style. If not authenticated: show `<p>Sign in to comment</p>` with a muted style.\n5. **Action row** — Send button (emerald bg, white text, rounded, small) + Cancel button (text-only, zinc). Send button disabled when textarea empty. Shows spinner during submission.\n\n**Positioning logic:**\n- Position at `(x + 14, y - tooltipHeight - 14)` relative to viewport\n- If overflows right: clamp to `window.innerWidth - tooltipWidth - 16`\n- If overflows left: clamp to `16`\n- If overflows top: flip below cursor at `(x + 14, y + 28)`\n- Use a `useRef` + `useEffect` to measure tooltip dimensions after first render and adjust position (same pattern as plresearch.org Tooltip component, lines 244-256)\n- Fixed positioning (`position: fixed`) since it's relative to the viewport, not a container\n\n**Interaction:**\n- Closes on Escape key (add keydown listener in useEffect)\n- Closes on click outside (add mousedown listener, check if event.target is outside tooltip ref)\n- Auto-focuses the textarea on mount\n- After successful submit: calls `onSubmit(text)`, clears textarea, calls `onClose()`\n\n**Tailwind animation:** Add a CSS class or inline style for the fade-in:\n```css\n@keyframes tooltipFadeIn {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: translateY(0); }\n}\n```\nUse inline style `animation: 'tooltipFadeIn 0.2s ease'` or a Tailwind animate class.\n\n### Acceptance criteria\n- [ ] `BeadsGraphProps` includes `onNodeRightClick`\n- [ ] ForceGraph2D has `onNodeRightClick` handler with `preventDefault()`\n- [ ] `components/CommentTooltip.tsx` exists with the design described above\n- [ ] Tooltip positions near cursor, clamped to viewport\n- [ ] Closes on Escape, click outside\n- [ ] Shows auth-gated textarea vs 'Sign in to comment' message\n- [ ] Send button calls `onSubmit` with text, shows loading state\n- [ ] `pnpm build` passes (component may not be rendered yet — that's task .6)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:39.225841+13:00","updatedAt":"2026-02-11T00:44:03.051623+13:00","closedAt":"2026-02-11T00:44:03.051623+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-dyi.4","title":"Add comment icon badge to nodes with comments in paintNode","description":"## Add comment icon badge to nodes with comments in paintNode\n\n### Goal\nDraw a small speech-bubble comment icon on graph nodes that have ATProto comments. This provides at-a-glance visual feedback about which issues have been discussed.\n\n### What to modify\n**File:** `components/BeadsGraph.tsx`\n\n### Step 1: Add `commentedNodeIds` prop\n\nAdd to `BeadsGraphProps` interface (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n commentedNodeIds?: Set<string>; // NEW — node IDs that have comments\n}\n```\n\nDestructure in the component (around line 145 where other props are destructured):\n```typescript\nconst { ..., commentedNodeIds } = props;\n```\n\n### Step 2: Create a ref for commentedNodeIds\n\nFollowing the established ref pattern in BeadsGraph (lines 181-185 where `selectedNodeRef`, `hoveredNodeRef`, `connectedNodesRef` are declared):\n\n```typescript\nconst commentedNodeIdsRef = useRef<Set<string>>(commentedNodeIds || new Set());\n```\n\nAdd a sync effect (near lines 263-293 where selectedNode/hoveredNode ref syncs happen):\n```typescript\nuseEffect(() => {\n commentedNodeIdsRef.current = commentedNodeIds || new Set();\n // Trigger a canvas redraw so the badge appears/disappears\n refreshGraph(graphRef);\n}, [commentedNodeIds]);\n```\n\n**Why a ref?** The `paintNode` callback has an empty dependency array (`[]`) — it reads all visual state from refs, not props. This avoids recreating the callback and re-rendering ForceGraph2D. This is the same pattern used for `selectedNodeRef`, `hoveredNodeRef`, and `connectedNodesRef` (see lines 181-185, 263-293).\n\n### Step 3: Draw the comment badge in paintNode\n\nIn the `paintNode` callback (lines 456-631), add the badge drawing AFTER the label section (around line 625, before `ctx.restore()` on line 628):\n\n```typescript\n// Comment badge — small speech bubble at top-right of node\nif (commentedNodeIdsRef.current.has(graphNode.id) && globalScale > 0.5) {\n const badgeSize = Math.min(6, Math.max(3, 8 / globalScale));\n // Position at ~45 degrees from center, just outside the node circle\n const badgeX = node.x + animatedSize * 0.7;\n const badgeY = node.y - animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = opacity * 0.85;\n\n // Speech bubble body (rounded rect)\n const bw = badgeSize * 1.6; // bubble width\n const bh = badgeSize * 1.2; // bubble height\n const br = badgeSize * 0.3; // border radius\n ctx.beginPath();\n ctx.moveTo(badgeX - bw/2 + br, badgeY - bh/2);\n ctx.lineTo(badgeX + bw/2 - br, badgeY - bh/2);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY - bh/2, badgeX + bw/2, badgeY - bh/2 + br);\n ctx.lineTo(badgeX + bw/2, badgeY + bh/2 - br);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY + bh/2, badgeX + bw/2 - br, badgeY + bh/2);\n // Small triangle pointer at bottom-left\n ctx.lineTo(badgeX - bw/4, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/3, badgeY + bh/2 + badgeSize * 0.4);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY + bh/2, badgeX - bw/2, badgeY + bh/2 - br);\n ctx.lineTo(badgeX - bw/2, badgeY - bh/2 + br);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY - bh/2, badgeX - bw/2 + br, badgeY - bh/2);\n ctx.closePath();\n\n ctx.fillStyle = '#3b82f6'; // blue-500\n ctx.fill();\n\n ctx.restore();\n}\n```\n\nThe exact canvas drawing can be simplified/refined — the key requirements are:\n- Small speech-bubble shape (recognizable as a comment icon)\n- Positioned at top-right of the node circle\n- Blue fill (`#3b82f6`) at ~0.85 opacity\n- Only drawn when `globalScale > 0.5` (same threshold as labels on line 598)\n- Scales with zoom level like other indicators\n\n### Acceptance criteria\n- [ ] `commentedNodeIds` prop added to `BeadsGraphProps`\n- [ ] Ref created and synced with effect + `refreshGraph()` call\n- [ ] Speech bubble badge drawn in `paintNode` for nodes in the set\n- [ ] Badge scales with zoom, only visible at reasonable zoom levels\n- [ ] No ForceGraph re-render triggered (ref pattern maintained)\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:47.743964+13:00","updatedAt":"2026-02-11T00:44:03.169692+13:00","closedAt":"2026-02-11T00:44:03.169692+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi","beads-map-dyi.2"]},{"id":"beads-map-dyi.5","title":"Add comment section to NodeDetail panel","description":"## Add comment section to NodeDetail panel\n\n### Goal\nAdd a 'Comments' section at the bottom of the NodeDetail sidebar panel that shows existing ATProto comments for the selected node and provides an inline compose area for authenticated users.\n\n### What to modify\n**File:** `components/NodeDetail.tsx`\n\n### Current file structure (304 lines)\n- Line 1-18: imports and props interface\n- Line 20-247: main `NodeDetail` component\n - Line 25-46: null state (no node selected)\n - Line 48-245: node detail rendering\n - Lines 229-245: 'Blocked by' section (LAST section before closing div)\n - Line 246: closing `</div>`\n- Lines 250-298: helper components (`MetricCard`, `DependencyLink`)\n- Lines 300-303: `truncateDescription`\n\n### Changes needed\n\n**1. Expand the props interface** (line 14-18):\n```typescript\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // NEW import\n\ninterface NodeDetailProps {\n node: GraphNode | null;\n allNodes: GraphNode[];\n onNodeNavigate: (nodeId: string) => void;\n comments?: BeadsComment[]; // NEW — comments for selected node\n onPostComment?: (text: string) => Promise<void>; // NEW — submit callback\n isAuthenticated?: boolean; // NEW — auth state for compose area\n}\n```\n\n**2. Destructure new props** (line 20-24):\n```typescript\nexport default function NodeDetail({\n node, allNodes, onNodeNavigate, comments, onPostComment, isAuthenticated,\n}: NodeDetailProps) {\n```\n\n**3. Add Comments section** (after the 'Blocked by' section, around line 245, before the closing `</div>`):\n\n```tsx\n{/* Comments */}\n<div className=\"mb-4\">\n <h4 className=\"text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2\">\n Comments {comments && comments.length > 0 && (\n <span className=\"ml-1 px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full text-[10px] font-medium\">\n {comments.length}\n </span>\n )}\n </h4>\n\n {/* Comment list */}\n {comments && comments.length > 0 ? (\n <div className=\"space-y-3\">\n {comments.map((comment) => (\n <CommentItem key={comment.uri} comment={comment} />\n ))}\n </div>\n ) : (\n <p className=\"text-xs text-zinc-400 italic\">No comments yet</p>\n )}\n\n {/* Compose area */}\n {isAuthenticated && onPostComment ? (\n <CommentCompose onSubmit={onPostComment} />\n ) : !isAuthenticated ? (\n <p className=\"text-xs text-zinc-400 mt-2\">Sign in to leave a comment</p>\n ) : null}\n</div>\n```\n\n**4. Create helper sub-components** (after `DependencyLink`, before `truncateDescription`):\n\n**CommentItem** — displays a single comment:\n```tsx\nfunction CommentItem({ comment }: { comment: BeadsComment }) {\n return (\n <div className=\"flex gap-2\">\n {/* Avatar */}\n <div className=\"shrink-0 w-6 h-6 rounded-full bg-zinc-100 overflow-hidden\">\n {comment.avatar ? (\n <img src={comment.avatar} alt=\"\" className=\"w-full h-full object-cover\" />\n ) : (\n <div className=\"w-full h-full flex items-center justify-center text-[10px] font-medium text-zinc-400\">\n {(comment.handle || comment.did).charAt(0).toUpperCase()}\n </div>\n )}\n </div>\n {/* Content */}\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-xs font-medium text-zinc-600 truncate\">\n {comment.displayName || comment.handle || comment.did.slice(0, 16) + '...'}\n </span>\n <span className=\"text-[10px] text-zinc-300 shrink-0\">\n {formatRelativeTime(comment.createdAt)}\n </span>\n </div>\n <p className=\"text-xs text-zinc-500 mt-0.5 whitespace-pre-wrap break-words\">{comment.text}</p>\n </div>\n </div>\n );\n}\n```\n\n**CommentCompose** — inline textarea + send button:\n```tsx\nfunction CommentCompose({ onSubmit }: { onSubmit: (text: string) => Promise<void> }) {\n const [text, setText] = useState('');\n const [sending, setSending] = useState(false);\n\n const handleSubmit = async () => {\n if (!text.trim() || sending) return;\n setSending(true);\n try {\n await onSubmit(text.trim());\n setText('');\n } catch (err) {\n console.error('Failed to post comment:', err);\n } finally {\n setSending(false);\n }\n };\n\n return (\n <div className=\"mt-3 space-y-2\">\n <textarea\n value={text}\n onChange={(e) => setText(e.target.value)}\n placeholder=\"Leave a comment...\"\n rows={2}\n className=\"w-full px-2.5 py-1.5 text-xs border border-zinc-200 rounded-md bg-zinc-50 text-zinc-700 placeholder-zinc-400 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500\"\n />\n <button\n onClick={handleSubmit}\n disabled={!text.trim() || sending}\n className=\"px-3 py-1 text-xs font-medium text-white bg-emerald-500 rounded-md hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {sending ? 'Sending...' : 'Comment'}\n </button>\n </div>\n );\n}\n```\n\n**formatRelativeTime** helper:\n```typescript\nfunction formatRelativeTime(isoString: string): string {\n const date = new Date(isoString);\n const now = new Date();\n const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);\n if (seconds < 60) return 'just now';\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return minutes + 'm ago';\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return hours + 'h ago';\n const days = Math.floor(hours / 24);\n if (days < 7) return days + 'd ago';\n return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\n}\n```\n\n**5. Add `useState` import** — needed for `CommentCompose`. Add to the existing React import at line 1 or import separately.\n\n### Styling notes\n- Matches existing NodeDetail style: `text-xs`, zinc palette, `mb-4` section spacing\n- Avatar uses plain `<img>` (not `next/image`) — consistent with AuthButton.tsx pattern\n- Count badge uses blue accent to match the comment badge on the graph nodes\n- Compose area uses emerald accent for the submit button (matches the app's primary color)\n\n### Acceptance criteria\n- [ ] 'Comments' section appears after 'Blocked by' in NodeDetail\n- [ ] Shows comment count badge when comments exist\n- [ ] Each comment shows avatar, handle/name, relative time, text\n- [ ] Empty state shows 'No comments yet' placeholder\n- [ ] Authenticated users see compose textarea + submit button\n- [ ] Unauthenticated users see 'Sign in to leave a comment'\n- [ ] Submit clears textarea and calls `onPostComment`\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:54.777115+13:00","updatedAt":"2026-02-11T00:44:03.284193+13:00","closedAt":"2026-02-11T00:44:03.284193+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi","beads-map-dyi.2"]},{"id":"beads-map-dyi.6","title":"Wire everything together in page.tsx and build verification","description":"## Wire everything together in page.tsx and build verification\n\n### Goal\nConnect all the pieces created in tasks .1-.5 in the main `app/page.tsx` orchestration file. This is the final integration task.\n\n### What to modify\n**File:** `app/page.tsx` (currently 811 lines)\n\n### Current file structure (key sections)\n- Line 1: `'use client'`\n- Lines 3-12: imports\n- Lines 14-67: helper functions (`findNeighborPosition`, etc.)\n- Lines 69-811: main `Home` component\n - Lines 73-90: state declarations\n - Lines 175-250: SSE/fetch data loading\n - Lines 280-320: event handlers (`handleNodeClick`, `handleNodeHover`, etc.)\n - Lines 680-695: BeadsGraph rendering\n - Lines 697-765: Desktop sidebar with NodeDetail\n - Lines 767-806: Mobile drawer with NodeDetail\n\n### Step 1: Add imports (near lines 3-12)\n\n```typescript\nimport { CommentTooltip } from '@/components/CommentTooltip'; // task .3\nimport { useBeadsComments } from '@/hooks/useBeadsComments'; // task .2\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // task .2\nimport { useAuth } from '@/lib/auth'; // already importable\n```\n\n### Step 2: Add state and hooks (near lines 73-90, after existing state declarations)\n\n```typescript\n// Auth state\nconst { isAuthenticated, session } = useAuth();\n\n// Comments from ATProto indexer\nconst { commentsByNode, commentedNodeIds, refetch: refetchComments } = useBeadsComments();\n\n// Context menu state for right-click tooltip\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n### Step 3: Create event handlers (near lines 280-320)\n\n**Right-click handler:**\n```typescript\nconst handleNodeRightClick = useCallback((node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n}, []);\n```\n\n**Post comment callback:**\n```typescript\nconst handlePostComment = useCallback(async (nodeId: string, text: string) => {\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n $type: 'org.impactindexer.review.comment',\n subject: {\n uri: `beads:${nodeId}`,\n type: 'record',\n },\n text,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n\n if (!response.ok) {\n const data = await response.json();\n throw new Error(data.error || 'Failed to post comment');\n }\n\n // Refetch comments to update the UI\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 4: Pass props to BeadsGraph (around lines 680-695)\n\nAdd the new props to the `<BeadsGraph>` component:\n```tsx\n<BeadsGraph\n ref={graphRef}\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n selectedNode={selectedNode}\n hoveredNode={hoveredNode}\n onNodeClick={handleNodeClick}\n onNodeHover={handleNodeHover}\n onBackgroundClick={handleBackgroundClick}\n onNodeRightClick={handleNodeRightClick} // NEW\n commentedNodeIds={commentedNodeIds} // NEW\n/>\n```\n\n### Step 5: Pass props to NodeDetail (desktop sidebar, around line 733-737)\n\n```tsx\n<NodeDetail\n node={selectedNode}\n allNodes={data.graphData.nodes}\n onNodeNavigate={handleNodeNavigate}\n comments={selectedNode ? commentsByNode.get(selectedNode.id) : undefined} // NEW\n onPostComment={selectedNode ? (text: string) => handlePostComment(selectedNode.id, text) : undefined} // NEW\n isAuthenticated={isAuthenticated} // NEW\n/>\n```\n\nDo the same for the mobile drawer NodeDetail (around line 799-803).\n\n### Step 6: Render CommentTooltip (after BeadsGraph, before the sidebar, around line 695)\n\n```tsx\n{/* Right-click comment tooltip */}\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n### Step 7: Close tooltip on background click\n\nModify `handleBackgroundClick` to also close the context menu:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null); // NEW — close tooltip too\n}, []);\n```\n\n### Step 8: Build and fix errors\n\nRun `pnpm build` and fix any type errors. Common issues to watch for:\n- Import path typos\n- Missing exports (e.g., `BeadsComment` type not exported from hook)\n- `useAuth` must be called inside `AuthProvider` (it already is — layout.tsx wraps children)\n- The `CommentTooltip` component must be exported as named export (check consistency with import)\n\n### Acceptance criteria\n- [ ] `useBeadsComments` hook called at top level of Home component\n- [ ] `useAuth` provides `isAuthenticated` state\n- [ ] `contextMenu` state manages right-click tooltip position + node\n- [ ] `handleNodeRightClick` creates context menu state\n- [ ] `handlePostComment` POSTs to `/api/records` with correct record shape\n- [ ] BeadsGraph receives `onNodeRightClick` and `commentedNodeIds`\n- [ ] Both desktop and mobile NodeDetail receive comments + postComment + isAuthenticated\n- [ ] CommentTooltip renders when `contextMenu` is set\n- [ ] Background click closes both selection and context menu\n- [ ] `pnpm build` passes with zero errors\n- [ ] All auth API routes visible in build output (`ƒ /api/records`, etc.)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:32:01.724819+13:00","updatedAt":"2026-02-11T00:44:03.398953+13:00","closedAt":"2026-02-11T00:44:03.398953+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":6,"blockerIds":[],"dependentIds":["beads-map-dyi","beads-map-dyi.1","beads-map-dyi.2","beads-map-dyi.3","beads-map-dyi.4","beads-map-dyi.5"]},{"id":"beads-map-dyi.7","title":"Add delete button for own comments","description":"## Add delete button for own comments\n\n### Goal\nAllow authenticated users to delete comments they have authored. Show a small trash/X icon on comments where the logged-in user's DID matches the comment's DID. Clicking it calls DELETE /api/records to remove the record from their PDS, then refetches comments.\n\n### What to modify\n\n#### 1. `components/NodeDetail.tsx` — CommentItem sub-component\n\nAdd a delete button that appears only when the comment's `did` matches the current user's DID.\n\n**Props change for CommentItem:**\n```typescript\nfunction CommentItem({ comment, currentDid, onDelete }: {\n comment: BeadsComment;\n currentDid?: string;\n onDelete?: (comment: BeadsComment) => Promise<void>;\n})\n```\n\n**UI:** A small X or trash icon button, only visible when `currentDid === comment.did`. Positioned at the top-right of the comment row. On hover, it becomes visible (use `group` + `group-hover:opacity-100` pattern or always-visible is fine for simplicity). Shows a confirmation or just deletes immediately. While deleting, show a subtle spinner or disabled state.\n\n**Delete call pattern** (from Hyperscan `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`):\n```typescript\n// The rkey is extracted from the comment's AT-URI: at://did/collection/rkey\n// comment.rkey is already available in BeadsComment\nawait fetch(`/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`, {\n method: 'DELETE',\n});\n```\n\n#### 2. `components/NodeDetail.tsx` — NodeDetailProps\n\nAdd `currentDid` to props:\n```typescript\ninterface NodeDetailProps {\n // ... existing props ...\n currentDid?: string; // NEW — the authenticated user's DID for ownership checks\n}\n```\n\n#### 3. `components/CommentTooltip.tsx` — existing comments preview\n\nOptionally add delete to the tooltip preview too, or skip for simplicity (tooltip is compact). Recommended: skip delete in tooltip, only in NodeDetail.\n\n#### 4. `app/page.tsx` — pass currentDid and onDeleteComment\n\nPass `session?.did` as `currentDid` to both desktop and mobile `<NodeDetail>` instances.\n\nCreate a `handleDeleteComment` callback:\n```typescript\nconst handleDeleteComment = useCallback(async (comment: BeadsComment) => {\n const response = await fetch(\n `/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`,\n { method: 'DELETE' }\n );\n if (!response.ok) {\n const errData = await response.json();\n throw new Error(errData.error || 'Failed to delete comment');\n }\n await refetchComments();\n}, [refetchComments]);\n```\n\nPass it to NodeDetail:\n```tsx\n<NodeDetail\n ...existing props...\n currentDid={session?.did}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n#### 5. `components/NodeDetail.tsx` — wire onDeleteComment\n\nAdd to NodeDetailProps:\n```typescript\nonDeleteComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nPass to CommentItem:\n```tsx\n<CommentItem\n key={comment.uri}\n comment={comment}\n currentDid={currentDid}\n onDelete={onDeleteComment}\n/>\n```\n\n### Existing infrastructure\n- `DELETE /api/records?collection=...&rkey=...` already exists (created in beads-map-dyi.1)\n- `BeadsComment` type has `rkey` and `did` fields (from useBeadsComments hook)\n- `useAuth()` provides `session.did` (from lib/auth.tsx)\n- `refetchComments()` from `useBeadsComments` hook refreshes the comment list\n\n### Acceptance criteria\n- [ ] Delete icon/button appears only on comments authored by the current user\n- [ ] Clicking delete calls `DELETE /api/records` with correct collection and rkey\n- [ ] After successful delete, comments list refreshes automatically\n- [ ] Shows loading/disabled state during deletion\n- [ ] `pnpm build` passes with zero errors","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:45:37.231167+13:00","updatedAt":"2026-02-11T00:47:32.38037+13:00","closedAt":"2026-02-11T00:47:32.38037+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-ecl","title":"Wire EventSource in page.tsx with merge logic","description":"Modify: app/page.tsx\n\nPURPOSE: Replace the one-shot fetch(\"/api/beads\") with an EventSource connected to /api/beads/stream. On each SSE message, diff the new data against current state, stamp animation metadata, and update React state. This is the central coordination point where server data meets client state.\n\nCHANGES TO page.tsx:\n\n1. ADD IMPORTS at top:\n import { diffBeadsData, linkKey } from \"@/lib/diff-beads\";\n import type { BeadsDiff } from \"@/lib/diff-beads\";\n\n2. ADD a ref to track the previous data for diffing:\n const prevDataRef = useRef<BeadsApiResponse | null>(null);\n\n3. REPLACE the existing fetch useEffect (lines 38-53) with EventSource logic:\n\n```typescript\n // Live-streaming beads data via SSE\n useEffect(() => {\n let eventSource: EventSource | null = null;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n function connect() {\n eventSource = new EventSource(\"/api/beads/stream\");\n\n eventSource.onmessage = (event) => {\n try {\n const newData = JSON.parse(event.data) as BeadsApiResponse;\n if ((newData as any).error) {\n setError((newData as any).error);\n setLoading(false);\n return;\n }\n\n const oldData = prevDataRef.current;\n const diff = diffBeadsData(oldData, newData);\n\n if (!oldData) {\n // Initial load — no animations, just set data\n prevDataRef.current = newData;\n setData(newData);\n setLoading(false);\n return;\n }\n\n if (!diff.hasChanges) return; // No-op if nothing changed\n\n // Merge: stamp animation metadata and preserve positions\n const mergedData = mergeBeadsData(oldData, newData, diff);\n prevDataRef.current = mergedData;\n setData(mergedData);\n } catch (err) {\n console.error(\"Failed to parse SSE message:\", err);\n }\n };\n\n eventSource.onerror = () => {\n // EventSource auto-reconnects, but we handle the gap\n if (eventSource?.readyState === EventSource.CLOSED) {\n // Permanent failure — try manual reconnect after delay\n reconnectTimer = setTimeout(connect, 5000);\n }\n };\n\n // If still loading after 5s, fall back to one-shot fetch\n setTimeout(() => {\n if (loading) {\n fetch(\"/api/beads\")\n .then(res => res.json())\n .then(data => {\n if (!prevDataRef.current) {\n prevDataRef.current = data;\n setData(data);\n setLoading(false);\n }\n })\n .catch(() => {});\n }\n }, 5000);\n }\n\n connect();\n\n return () => {\n eventSource?.close();\n if (reconnectTimer) clearTimeout(reconnectTimer);\n };\n }, []);\n```\n\n4. ADD the mergeBeadsData function (above the component or as a module-level function):\n\n```typescript\nfunction mergeBeadsData(\n oldData: BeadsApiResponse,\n newData: BeadsApiResponse,\n diff: BeadsDiff\n): BeadsApiResponse {\n const now = Date.now();\n\n // Build position map from old nodes (preserves x/y/fx/fy from simulation)\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));\n\n // Merge nodes: carry over positions, stamp animation metadata\n const mergedNodes = newData.graphData.nodes.map(node => {\n const oldNode = oldNodeMap.get(node.id);\n\n if (!oldNode) {\n // NEW NODE — stamp spawn time, place near a connected neighbor\n const neighbor = findNeighborPosition(node.id, newData.graphData.links, oldNodeMap);\n return {\n ...node,\n _spawnTime: now,\n x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,\n y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,\n };\n }\n\n // EXISTING NODE — preserve position, check for changes\n const merged = {\n ...node,\n x: oldNode.x,\n y: oldNode.y,\n fx: oldNode.fx,\n fy: oldNode.fy,\n };\n\n // Stamp change metadata if status changed\n if (diff.changedNodes.has(node.id)) {\n const changes = diff.changedNodes.get(node.id)!;\n const statusChange = changes.find(c => c.field === \"status\");\n if (statusChange) {\n merged._changedAt = now;\n merged._prevStatus = statusChange.from;\n }\n }\n\n return merged;\n });\n\n // Handle removed nodes: keep them briefly for exit animation\n for (const removedId of diff.removedNodeIds) {\n const oldNode = oldNodeMap.get(removedId);\n if (oldNode) {\n mergedNodes.push({\n ...oldNode,\n _removeTime: now,\n });\n }\n }\n\n // Merge links: stamp spawn time on new links\n const mergedLinks = newData.graphData.links.map(link => {\n const key = linkKey(link);\n if (!oldLinkKeySet.has(key)) {\n return { ...link, _spawnTime: now };\n }\n return link;\n });\n\n // Handle removed links: keep briefly for exit animation\n for (const removedKey of diff.removedLinkKeys) {\n const oldLink = oldData.graphData.links.find(l => linkKey(l) === removedKey);\n if (oldLink) {\n mergedLinks.push({\n source: typeof oldLink.source === \"object\" ? (oldLink.source as any).id : oldLink.source,\n target: typeof oldLink.target === \"object\" ? (oldLink.target as any).id : oldLink.target,\n type: oldLink.type,\n _removeTime: now,\n });\n }\n }\n\n return {\n ...newData,\n graphData: {\n nodes: mergedNodes as any,\n links: mergedLinks as any,\n },\n };\n}\n\n// Find position of a neighbor node (for placing new nodes near connections)\nfunction findNeighborPosition(\n nodeId: string,\n links: GraphLink[],\n nodeMap: Map<string, GraphNode>\n): { x: number; y: number } | null {\n for (const link of links) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n if (src === nodeId && nodeMap.has(tgt)) {\n const n = nodeMap.get(tgt)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n if (tgt === nodeId && nodeMap.has(src)) {\n const n = nodeMap.get(src)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n }\n return null;\n}\n```\n\n5. ADD cleanup of expired animation items.\n After a timeout, remove nodes/links that have _removeTime older than 600ms:\n\n```typescript\n // Clean up expired exit animations\n useEffect(() => {\n if (!data) return;\n const timer = setTimeout(() => {\n const now = Date.now();\n const EXPIRE_MS = 600;\n const nodes = data.graphData.nodes.filter(\n n => !n._removeTime || now - n._removeTime < EXPIRE_MS\n );\n const links = data.graphData.links.filter(\n l => !(l as any)._removeTime || now - (l as any)._removeTime < EXPIRE_MS\n );\n if (nodes.length !== data.graphData.nodes.length || links.length !== data.graphData.links.length) {\n setData(prev => prev ? {\n ...prev,\n graphData: { nodes, links },\n } : prev);\n }\n }, 700); // slightly after animation duration\n return () => clearTimeout(timer);\n }, [data]);\n```\n\n6. KEEP the existing /api/config fetch useEffect unchanged.\n\n7. UPDATE the stats display in the header to exclude nodes/links with _removeTime (so counts reflect real data, not animated ghosts).\n\nWHY FULL MERGE IN page.tsx:\nThe merge logic lives here because it's where we have access to both the old React state (with simulation positions) and the new server data. BeadsGraph.tsx just receives nodes/links props and renders — it doesn't need to know about the merge.\n\nPOSITION PRESERVATION IS CRITICAL:\nreact-force-graph-2d mutates node objects in-place, setting x/y/vx/vy during simulation. If we replace nodes with fresh objects from the server (which have no x/y), the entire graph layout resets. The mergeBeadsData function copies x/y/fx/fy from old nodes to preserve positions.\n\nDEPENDS ON: task .3 (SSE endpoint), task .4 (diff-beads.ts)\n\nACCEPTANCE CRITERIA:\n- EventSource connects to /api/beads/stream on mount\n- Initial data loads correctly (same as before)\n- When JSONL changes, new data streams in and state updates\n- New nodes get _spawnTime stamped\n- Changed nodes get _changedAt + _prevStatus stamped\n- Removed nodes/links kept briefly with _removeTime for exit animation\n- Existing node positions preserved across updates\n- New nodes placed near their connected neighbors\n- Expired animation items cleaned up after 600ms\n- Fallback to one-shot fetch if SSE fails after 5s\n- EventSource cleaned up on unmount\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:17:01.615466+13:00","updatedAt":"2026-02-10T23:36:14.609896+13:00","closedAt":"2026-02-10T23:36:14.609896+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-iyn"],"dependentIds":["beads-map-3jy","beads-map-7j2","beads-map-2fk"]},{"id":"beads-map-gjo","title":"Add animation timestamp fields to types + export getAdditionalRepoPaths","description":"Foundation task: add animation metadata fields to GraphNode/GraphLink types and export a currently-private function from parse-beads.ts.\n\nFILE 1: lib/types.ts\n\nAdd optional animation timestamp fields to GraphNode interface (after the fx/fy fields, around line 61):\n\n // Animation metadata (set by live-update merge logic, consumed by paintNode)\n _spawnTime?: number; // Date.now() when this node first appeared (for pop-in animation)\n _removeTime?: number; // Date.now() when this node was marked for removal (for shrink-out)\n _changedAt?: number; // Date.now() when status/priority changed (for ripple animation)\n _prevStatus?: string; // Previous status value before the change (for color transition)\n\nAdd optional animation timestamp fields to GraphLink interface (after the type field, around line 67):\n\n // Animation metadata (set by live-update merge logic, consumed by paintLink)\n _spawnTime?: number; // Date.now() when this link first appeared (for fade-in animation)\n _removeTime?: number; // Date.now() when this link was marked for removal (for fade-out)\n\nIMPORTANT: These fields use the underscore prefix convention to signal they are transient metadata not persisted to JSONL. They are set by the merge logic in page.tsx and consumed by paintNode/paintLink in BeadsGraph.tsx.\n\nIMPORTANT: GraphNode has an index signature [key: string]: unknown at line 37. The new fields must be declared as optional properties within the interface body (not via the index signature) so TypeScript knows their types.\n\nFILE 2: lib/parse-beads.ts\n\nThe function getAdditionalRepoPaths(beadsDir: string): string[] at line 26 is currently private (no export keyword). Change it to:\n\n export function getAdditionalRepoPaths(beadsDir: string): string[]\n\nThis is needed by lib/watch-beads.ts (task .2) to discover which JSONL files to watch.\n\nNo other changes to parse-beads.ts.\n\nACCEPTANCE CRITERIA:\n- GraphNode has _spawnTime, _removeTime, _changedAt, _prevStatus optional fields\n- GraphLink has _spawnTime, _removeTime optional fields\n- getAdditionalRepoPaths is exported from parse-beads.ts\n- pnpm build passes with zero errors\n- No runtime behavior changes (animation fields are just type declarations, unused until task .5/.6/.7)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:15:11.332936+13:00","updatedAt":"2026-02-10T23:24:57.300177+13:00","closedAt":"2026-02-10T23:24:57.300177+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-2fk","beads-map-m1o"],"dependentIds":["beads-map-3jy"]},{"id":"beads-map-iyn","title":"Add spawn/exit/change animations to paintNode","description":"Modify: components/BeadsGraph.tsx — paintNode() callback\n\nPURPOSE: Animate nodes based on the _spawnTime, _removeTime, and _changedAt timestamps set by the merge logic (task .5). New nodes pop in with a bouncy scale-up, removed nodes shrink out, and status-changed nodes flash a ripple effect.\n\nCHANGES TO paintNode (currently at line ~435, inside the useCallback):\n\n1. ADD EASING FUNCTIONS (above the component, near the helper functions around line 50):\n\n```typescript\n// Animation duration constants\nconst SPAWN_DURATION = 500; // ms for pop-in animation\nconst REMOVE_DURATION = 400; // ms for shrink-out animation\nconst CHANGE_DURATION = 800; // ms for status change ripple\n\n/**\n * easeOutBack: overshoots slightly then settles — gives \"pop\" feel.\n * t is 0..1, returns 0..~1.05 (overshoots before settling at 1)\n */\nfunction easeOutBack(t: number): number {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n}\n\n/**\n * easeOutQuad: smooth deceleration\n */\nfunction easeOutQuad(t: number): number {\n return 1 - (1 - t) * (1 - t);\n}\n```\n\n2. MODIFY paintNode() to add animation effects:\n\nAt the BEGINNING of paintNode, before any drawing, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (pop-in) ---\n let spawnScale = 1;\n const spawnTime = (graphNode as any)._spawnTime as number | undefined;\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n spawnScale = easeOutBack(elapsed / SPAWN_DURATION);\n }\n // After animation completes, _spawnTime is ignored (scale stays 1)\n }\n\n // --- Remove animation (shrink-out) ---\n let removeScale = 1;\n let removeOpacity = 1;\n const removeTime = (graphNode as any)._removeTime as number | undefined;\n if (removeTime) {\n const elapsed = now - removeTime;\n if (elapsed < REMOVE_DURATION) {\n const progress = elapsed / REMOVE_DURATION;\n removeScale = 1 - easeOutQuad(progress);\n removeOpacity = 1 - progress;\n } else {\n removeScale = 0; // fully gone\n removeOpacity = 0;\n }\n }\n\n const animScale = spawnScale * removeScale;\n if (animScale <= 0.01) return; // skip drawing invisible nodes\n\n const animatedSize = size * animScale;\n```\n\nREPLACE all references to `size` in the drawing code with `animatedSize`:\n- ctx.arc(node.x, node.y, size + 2, ...) → ctx.arc(node.x, node.y, animatedSize + 2, ...)\n- ctx.arc(node.x, node.y, size, ...) → ctx.arc(node.x, node.y, animatedSize, ...)\n- node.y + size + 3 → node.y + animatedSize + 3\n- node.y - size - 2 → node.y - animatedSize - 2\n\nAlso multiply the base opacity by removeOpacity:\n- ctx.globalAlpha = opacity → ctx.globalAlpha = opacity * removeOpacity\n\n3. ADD STATUS CHANGE RIPPLE after drawing the node body but before the label:\n\n```typescript\n // --- Status change ripple animation ---\n const changedAt = (graphNode as any)._changedAt as number | undefined;\n if (changedAt) {\n const elapsed = now - changedAt;\n if (elapsed < CHANGE_DURATION) {\n const progress = elapsed / CHANGE_DURATION;\n const rippleRadius = animatedSize + 4 + progress * 20;\n const rippleOpacity = (1 - progress) * 0.6;\n const newStatusColor = STATUS_COLORS[graphNode.status] || \"#a1a1aa\";\n\n ctx.beginPath();\n ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);\n ctx.strokeStyle = newStatusColor;\n ctx.lineWidth = 2 * (1 - progress);\n ctx.globalAlpha = rippleOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n4. ADD SPAWN GLOW: during the spawn animation, add a brief emerald glow ring:\n\n```typescript\n // --- Spawn glow ---\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n const glowProgress = elapsed / SPAWN_DURATION;\n const glowOpacity = (1 - glowProgress) * 0.4;\n const glowRadius = animatedSize + 6 + glowProgress * 8;\n ctx.beginPath();\n ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);\n ctx.strokeStyle = \"#10b981\";\n ctx.lineWidth = 3 * (1 - glowProgress);\n ctx.globalAlpha = glowOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n5. ADD CONTINUOUS REDRAW during active animations.\n\nThe canvas only redraws when the force simulation is active or when React state changes. During animations, we need continuous redraws. Add a useEffect that requests animation frames while animations are active:\n\n```typescript\n // Drive continuous canvas redraws during active animations\n useEffect(() => {\n let rafId: number;\n let active = true;\n\n function tick() {\n if (!active) return;\n const now = Date.now();\n const hasActiveAnimations = viewNodes.some((n: any) => {\n if (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) return true;\n if (n._removeTime && now - n._removeTime < REMOVE_DURATION) return true;\n if (n._changedAt && now - n._changedAt < CHANGE_DURATION) return true;\n return false;\n }) || viewLinks.some((l: any) => {\n if (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) return true;\n if (l._removeTime && now - l._removeTime < REMOVE_DURATION) return true;\n return false;\n });\n\n if (hasActiveAnimations) {\n refreshGraph(graphRef);\n }\n rafId = requestAnimationFrame(tick);\n }\n\n tick();\n return () => { active = false; cancelAnimationFrame(rafId); };\n }, [viewNodes, viewLinks]);\n```\n\nIMPORTANT: refreshGraph() already exists at line ~105 — it does an imperceptible zoom jitter to force canvas redraw. This is the exact right mechanism for animation frames.\n\nIMPORTANT: The paintNode callback has [] (empty) dependency array. This is correct and must NOT change — it reads from refs, not props. The animation timestamps are on the node objects themselves (passed as the first argument to paintNode by react-force-graph), so they're always current.\n\nDEPENDS ON: task .5 (page.tsx must stamp _spawnTime/_removeTime/_changedAt on nodes)\n\nACCEPTANCE CRITERIA:\n- New nodes pop in with easeOutBack scale animation (500ms)\n- New nodes show brief emerald glow ring during spawn\n- Removed nodes shrink to zero with fade-out (400ms)\n- Status-changed nodes show expanding ripple ring in new status color (800ms)\n- Animations are smooth (requestAnimationFrame drives redraws)\n- No visual glitches when multiple animations overlap\n- Non-animated nodes render identically to before (no regression)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:17:37.790522+13:00","updatedAt":"2026-02-10T23:39:22.776735+13:00","closedAt":"2026-02-10T23:39:22.776735+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-mq9"],"dependentIds":["beads-map-3jy","beads-map-ecl"]},{"id":"beads-map-m1o","title":"Create lib/watch-beads.ts — file watcher with debounce","description":"Create a new file: lib/watch-beads.ts\n\nPURPOSE: Watch all issues.jsonl files (primary + additional repos from config.yaml) for changes using Node.js fs.watch(). When any file changes, fire a debounced callback. This is the server-side foundation for the SSE endpoint (task .3).\n\nINTERFACE:\n```typescript\n/**\n * Watch all issues.jsonl files for a beads project.\n * Discovers files from the primary .beads dir and config.yaml repos.additional.\n * Debounces rapid changes (bd often writes multiple times per command).\n *\n * @param beadsDir - Absolute path to the primary .beads/ directory\n * @param onChange - Callback fired when any watched file changes (after debounce)\n * @param debounceMs - Debounce interval in milliseconds (default: 300)\n * @returns Cleanup function that closes all watchers\n */\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs?: number\n): () => void;\n\n/**\n * Get all issues.jsonl file paths that should be watched.\n * Returns the primary path plus any additional repo paths from config.yaml.\n */\nexport function getWatchPaths(beadsDir: string): string[];\n```\n\nIMPLEMENTATION:\n\n```typescript\nimport { watch, existsSync } from \"fs\";\nimport { join } from \"path\";\nimport { getAdditionalRepoPaths } from \"./parse-beads\";\n\nexport function getWatchPaths(beadsDir: string): string[] {\n const paths: string[] = [];\n\n // Primary JSONL\n const primary = join(beadsDir, \"issues.jsonl\");\n if (existsSync(primary)) paths.push(primary);\n\n // Additional repo JSONLs\n const additionalRepos = getAdditionalRepoPaths(beadsDir);\n for (const repoPath of additionalRepos) {\n const jsonlPath = join(repoPath, \".beads\", \"issues.jsonl\");\n if (existsSync(jsonlPath)) paths.push(jsonlPath);\n }\n\n return paths;\n}\n\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs = 300\n): () => void {\n const paths = getWatchPaths(beadsDir);\n let timer: ReturnType<typeof setTimeout> | null = null;\n const watchers: ReturnType<typeof watch>[] = [];\n\n const debouncedOnChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(onChange, debounceMs);\n };\n\n for (const filePath of paths) {\n try {\n const watcher = watch(filePath, { persistent: false }, (eventType) => {\n if (eventType === \"change\") {\n debouncedOnChange();\n }\n });\n watchers.push(watcher);\n } catch (err) {\n console.warn(`Failed to watch ${filePath}:`, err);\n }\n }\n\n if (paths.length === 0) {\n console.warn(\"No issues.jsonl files found to watch\");\n }\n\n // Return cleanup function\n return () => {\n if (timer) clearTimeout(timer);\n for (const w of watchers) {\n w.close();\n }\n };\n}\n```\n\nKEY DESIGN DECISIONS:\n- persistent: false — so the watcher doesn't prevent Node.js from exiting\n- Only watches for \"change\" events (not \"rename\") since bd writes in-place\n- 300ms debounce: bd typically does flush→sync→write in rapid succession\n- If a watched file disappears (repo deleted), the watcher silently dies — acceptable\n\nEDGE CASES:\n- No additional repos: only watches primary issues.jsonl\n- Empty project (no issues.jsonl yet): returns empty paths array, logs warning\n- File deleted while watching: fs.watch fires an event, but next re-parse returns empty — handled gracefully by parse-beads.ts\n\nDEPENDS ON: task .1 (getAdditionalRepoPaths must be exported from parse-beads.ts)\n\nACCEPTANCE CRITERIA:\n- lib/watch-beads.ts exports watchBeadsFiles and getWatchPaths\n- Debounces rapid changes correctly (only one onChange call per burst)\n- Watches all JSONL files (primary + additional repos)\n- Cleanup function closes all watchers\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:15:32.448347+13:00","updatedAt":"2026-02-10T23:25:49.410672+13:00","closedAt":"2026-02-10T23:25:49.410672+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7j2"],"dependentIds":["beads-map-3jy","beads-map-gjo"]},{"id":"beads-map-mfw","title":"Epic: Search comments by commenter username","description":"Allow searching for nodes by commenter username. Typing a Bluesky handle (e.g. 'daviddao') in the search bar should also surface nodes where that person has left comments. This extends the existing node-field search to include comment author handles.","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:37:59.835891+13:00","updatedAt":"2026-02-12T10:39:16.820832+13:00","closedAt":"2026-02-12T10:39:16.820832+13:00","closeReason":"Completed: e2a49e1 — all tasks done","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-mfw.1"],"dependentIds":["beads-map-8np","beads-map-vdg"]},{"id":"beads-map-mfw.1","title":"Include comment author handles in search matching","description":"In app/page.tsx: (1) Add a useMemo that builds a Map<string, string> from allComments — maps each nodeId to a space-joined string of unique commenter handles for that node. (2) In the searchResults useMemo, append the commenter handles string to the existing searchable string. This way typing 'daviddao.bsky.social' or just 'daviddao' surfaces nodes where that user commented. The searchResults useMemo needs allComments (or the derived map) in its dependency array.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:38:08.454443+13:00","updatedAt":"2026-02-12T10:39:16.735264+13:00","closedAt":"2026-02-12T10:39:16.735264+13:00","closeReason":"Completed: e2a49e1","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-mfw"]},{"id":"beads-map-mq9","title":"Add spawn/exit animations to paintLink","description":"Modify: components/BeadsGraph.tsx — paintLink() callback\n\nPURPOSE: Animate links based on _spawnTime and _removeTime timestamps. New links fade in smoothly, removed links fade out. This complements the node animations (task .6).\n\nCHANGES TO paintLink (currently at line ~544, inside the useCallback):\n\n1. At the BEGINNING of paintLink, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (fade-in + thickness) ---\n let linkSpawnAlpha = 1;\n let linkSpawnWidth = 1;\n const linkSpawnTime = (link as any)._spawnTime as number | undefined;\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < SPAWN_DURATION) {\n const progress = elapsed / SPAWN_DURATION;\n linkSpawnAlpha = easeOutQuad(progress);\n linkSpawnWidth = 1 + (1 - progress) * 1.5; // starts 2.5x thick, settles to 1x\n }\n }\n\n // --- Remove animation (fade-out) ---\n let linkRemoveAlpha = 1;\n const linkRemoveTime = (link as any)._removeTime as number | undefined;\n if (linkRemoveTime) {\n const elapsed = now - linkRemoveTime;\n if (elapsed < REMOVE_DURATION) {\n linkRemoveAlpha = 1 - easeOutQuad(elapsed / REMOVE_DURATION);\n } else {\n return; // fully gone, skip drawing\n }\n }\n\n const linkAnimAlpha = linkSpawnAlpha * linkRemoveAlpha;\n if (linkAnimAlpha <= 0.01) return; // skip invisible links\n```\n\n2. MULTIPLY the existing opacity by linkAnimAlpha:\n\nCurrently (line ~574-580), the opacity is computed as:\n```typescript\n const opacity = isParentChild\n ? hasHighlight\n ? isConnectedLink ? 0.5 : 0.05\n : 0.2\n : hasHighlight\n ? isConnectedLink ? 0.8 : 0.08\n : 0.35;\n```\n\nAfter this, multiply:\n```typescript\n ctx.globalAlpha = opacity * linkAnimAlpha;\n```\n\n3. MULTIPLY the line width by linkSpawnWidth:\n\nCurrently the line width is set separately for parent-child and blocks links. Multiply each by linkSpawnWidth:\n```typescript\n // For parent-child:\n ctx.lineWidth = Math.max(0.6, 1.5 / globalScale) * linkSpawnWidth;\n // For blocks:\n ctx.lineWidth = (isConnectedLink\n ? Math.max(2, 2.5 / globalScale)\n : Math.max(0.8, 1.2 / globalScale)) * linkSpawnWidth;\n```\n\n4. ADD SPAWN FLASH for new links (optional but nice):\n\nAfter drawing the link curve, if it's spawning, draw a brief bright flash along the path:\n\n```typescript\n // Brief bright flash for new links\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < 300) {\n const flashProgress = elapsed / 300;\n const flashAlpha = (1 - flashProgress) * 0.5;\n ctx.save();\n ctx.globalAlpha = flashAlpha;\n ctx.strokeStyle = \"#10b981\"; // emerald\n ctx.lineWidth = (isParentChild ? 3 : 4) / globalScale;\n ctx.beginPath();\n ctx.moveTo(start.x, start.y);\n ctx.quadraticCurveTo(cx, cy, end.x, end.y);\n ctx.stroke();\n ctx.restore();\n }\n }\n```\n\nThis creates a bright emerald line that fades out over 300ms, overlaid on the normal link.\n\nIMPORTANT: The paintLink callback has [] (empty) dependency array. Keep it that way. Animation timestamps are on the link objects themselves.\n\nIMPORTANT: The SPAWN_DURATION, REMOVE_DURATION, easeOutQuad constants are shared with paintNode (task .6). They should be declared at module level (above the component), not inside the callbacks. If task .6 is implemented first, they'll already exist.\n\nDEPENDS ON: task .5 (links must have _spawnTime/_removeTime), task .6 (shared animation constants + easing functions)\n\nACCEPTANCE CRITERIA:\n- New links fade in over 500ms with initial thickness burst\n- New links show brief emerald flash (300ms)\n- Removed links fade out over 400ms\n- Flow particles on new links are also affected by spawn alpha (not critical, nice-to-have)\n- No visual regression for non-animated links\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:18:20.715649+13:00","updatedAt":"2026-02-10T23:39:22.858151+13:00","closedAt":"2026-02-10T23:39:22.858151+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-2qg"],"dependentIds":["beads-map-3jy","beads-map-iyn"]},{"id":"beads-map-vdg","title":"Enhanced comments: all-comments panel, likes, and threaded replies","description":"## Enhanced comments: all-comments panel, likes, and threaded replies\n\n### Summary\nThree major enhancements to the beads-map comment system, modeled after Hyperscan's ReviewSection:\n\n1. **All Comments panel** — A pill button in the top-right header area that opens a sidebar showing ALL comments across all nodes, sorted newest-first. Each comment links to its target node. This gives users a global activity feed.\n\n2. **Likes on comments** — Heart toggle on each comment (rose-500 when liked, zinc-300 when not), using the `org.impactindexer.review.like` lexicon. The like subject URI is the comment's AT-URI. Likes fetched from Hypergoat indexer. Same create/delete pattern as Hyperscan.\n\n3. **Threaded replies** — Reply button on each comment that shows an inline reply form. Uses the `replyTo` field on `org.impactindexer.review.comment` lexicon. Replies are indented with a left border (Hyperscan pattern: `ml-4 pl-3 border-l border-zinc-100`). Thread tree built client-side from flat comment list.\n\n### Architecture\n\n**Data fetching changes (`hooks/useBeadsComments.ts`):**\n- Fetch BOTH `org.impactindexer.review.comment` AND `org.impactindexer.review.like` from Hypergoat\n- Extend `BeadsComment` type: add `replyTo?: string`, `likes: BeadsLike[]`, `replies: BeadsComment[]`\n- Add `BeadsLike` type: `{ did, handle, displayName?, avatar?, createdAt, uri, rkey }`\n- Build thread tree: flat comments with `replyTo` assembled into nested `replies` arrays\n- Attach likes to their target comments (like subject.uri === comment AT-URI)\n- Export `allComments: BeadsComment[]` (flat list, newest first, for the All Comments panel)\n\n**New component (`components/AllCommentsPanel.tsx`):**\n- Slide-in sidebar from right (same pattern as NodeDetail sidebar)\n- Header: 'All Comments' title + close button\n- List of all comments sorted newest-first\n- Each comment shows: avatar, handle, time, text, target node ID (clickable to navigate)\n- Like button + reply count shown per comment\n\n**NodeDetail comment section enhancements (`components/NodeDetail.tsx`):**\n- Add HeartIcon component (Hyperscan pattern: filled/outline toggle)\n- Add like button on each CommentItem (heart + count, rose-500 when liked)\n- Add 'reply' text button on each CommentItem\n- Add InlineReplyForm (appears below comment being replied to)\n- Render threaded replies with recursive CommentItem (depth-based indentation)\n\n**page.tsx wiring:**\n- Add `allCommentsPanelOpen` state\n- Add pill button in header area\n- Add like/reply handlers\n- Render AllCommentsPanel component\n- Wire node navigation from AllCommentsPanel\n\n### Subject URI conventions\n- Comment on a beads issue: `{ uri: 'beads:<issue-id>', type: 'record' }`\n- Like on a comment: `{ uri: 'at://<did>/org.impactindexer.review.comment/<rkey>', type: 'record' }`\n- Reply to a comment: comment record with `replyTo: 'at://<did>/org.impactindexer.review.comment/<rkey>'`\n\n### Dependency chain\n- .1 (extend hook) is independent — foundational data layer\n- .2 (likes on comments in NodeDetail) depends on .1\n- .3 (threaded replies in NodeDetail) depends on .1\n- .4 (AllCommentsPanel component) depends on .1\n- .5 (page.tsx wiring + pill button) depends on .2, .3, .4\n- .6 (build verification) depends on .5\n\n### Reference files\n- Hyperscan ReviewSection: `/Users/david/Projects/gainforest/hyperscan/src/components/ReviewSection.tsx`\n- Like lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/like.json`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Current hook: `hooks/useBeadsComments.ts`\n- Current NodeDetail: `components/NodeDetail.tsx`\n- Current page.tsx: `app/page.tsx`","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:24:13.04637+13:00","updatedAt":"2026-02-11T01:37:27.139182+13:00","closedAt":"2026-02-11T01:37:27.139182+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":10,"dependentCount":1,"blockerIds":["beads-map-7r6","beads-map-mfw","beads-map-vdg.1","beads-map-vdg.2","beads-map-vdg.3","beads-map-vdg.4","beads-map-vdg.5","beads-map-vdg.6","beads-map-vdg.7","beads-map-z5w"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-vdg.1","title":"Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees","description":"## Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees\n\n### Goal\nExtend `hooks/useBeadsComments.ts` to fetch likes from Hypergoat, parse the `replyTo` field on comments, and build threaded comment trees.\n\n### File to modify\n`hooks/useBeadsComments.ts` (currently 289 lines)\n\n### Step 1: Add new types\n\n```typescript\nexport interface BeadsLike {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n createdAt: string;\n uri: string; // AT-URI of the like record\n rkey: string;\n}\n\n// Extend BeadsComment:\nexport interface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string;\n rkey: string;\n replyTo?: string; // NEW — AT-URI of parent comment\n likes: BeadsLike[]; // NEW — likes on this comment\n replies: BeadsComment[]; // NEW — nested child comments\n}\n```\n\n### Step 2: Fetch likes from Hypergoat\n\nUse the same `FETCH_COMMENTS_QUERY` GraphQL query but with `collection: 'org.impactindexer.review.like'`. Create a `fetchLikeRecords()` function (same pagination pattern as `fetchCommentRecords()`).\n\n### Step 3: Parse replyTo from comment records\n\nIn the comment processing loop (currently line 217-238), extract `replyTo` from the record value:\n```typescript\nconst replyTo = (value.replyTo as string) || undefined;\n```\nAdd it to the BeadsComment object.\n\n### Step 4: Attach likes to comments\n\nAfter fetching both comments and likes:\n1. Filter likes to those whose `subject.uri` is an AT-URI of a comment (starts with `at://`)\n2. Build a `Map<commentUri, BeadsLike[]>` \n3. Attach likes to their target comments\n\n### Step 5: Build thread trees\n\nAfter grouping comments by node:\n1. For each node's comments, put all in a `Map<uri, BeadsComment>`\n2. For each comment with `replyTo`, push into `parent.replies`\n3. Root comments = those without `replyTo` (or whose parent is missing)\n4. Sort: root comments newest-first, replies oldest-first (chronological conversation)\n\n### Step 6: Export allComments\n\nAdd to the return value:\n```typescript\nallComments: BeadsComment[] // flat list of all root+reply comments, newest-first, for All Comments panel\n```\n\n### Step 7: Update UseBeadsCommentsResult interface\n\n```typescript\nexport interface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // now threaded trees\n commentedNodeIds: Map<string, number>;\n allComments: BeadsComment[]; // NEW — flat list, newest-first\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n```\n\n### Testing\n- `pnpm build` must pass\n- Verify that comments with `replyTo` fields are correctly nested\n- Verify that likes are attached to the correct comments\n- `allComments` should contain all comments (including replies) sorted newest-first","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:24:33.393775+13:00","updatedAt":"2026-02-11T01:33:11.516403+13:00","closedAt":"2026-02-11T01:33:11.516403+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-vdg.2","beads-map-vdg.3","beads-map-vdg.4"],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-vdg.2","title":"Add heart-toggle likes on comments in NodeDetail","description":"## Add heart-toggle likes on comments in NodeDetail\n\n### Goal\nAdd a heart icon like button on each comment in `components/NodeDetail.tsx`, following the Hyperscan pattern exactly.\n\n### File to modify\n`components/NodeDetail.tsx` (currently 511 lines)\n\n### Dependencies\n- beads-map-vdg.1 (likes data available on BeadsComment objects)\n\n### Step 1: Add HeartIcon component\n\nPort from Hyperscan — two variants (filled/outline):\n```typescript\nfunction HeartIcon({ className = 'w-3 h-3', filled = false }: { className?: string; filled?: boolean }) {\n if (filled) {\n return (\n <svg className={className} viewBox='0 0 24 24' fill='currentColor'>\n <path d='M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z' />\n </svg>\n );\n }\n return (\n <svg className={className} fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z' />\n </svg>\n );\n}\n```\n\n### Step 2: Add like button to CommentItem actions row\n\nIn the `CommentItem` component, add a like button in the actions area alongside the existing delete button.\n\nThe actions row for each comment should be: heart-like button | reply button | delete button (own only)\n\n```tsx\n// In CommentItem actions area\n<div className='flex items-center gap-2 mt-1 text-[10px]'>\n <button\n onClick={() => onLike?.(comment)}\n disabled={!isAuthenticated || isLiking}\n className={`flex items-center gap-0.5 transition-colors ${\n hasLiked ? 'text-rose-500' : 'text-zinc-300 hover:text-rose-500'\n } disabled:opacity-50`}\n >\n <HeartIcon className='w-3 h-3' filled={hasLiked} />\n {comment.likes.length > 0 && <span>{comment.likes.length}</span>}\n </button>\n {/* ... reply button (task .3) ... */}\n {/* ... delete button (existing) ... */}\n</div>\n```\n\n### Step 3: Add like handler props\n\nAdd to `NodeDetailProps`:\n```typescript\nonLikeComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nAdd to `CommentItem` props:\n```typescript\nonLike?: (comment: BeadsComment) => Promise<void>;\nisAuthenticated?: boolean;\n```\n\n### Step 4: Determine `hasLiked` state\n\nIn CommentItem, check if the current user has liked:\n```typescript\nconst hasLiked = currentDid ? comment.likes.some(l => l.did === currentDid) : false;\n```\n\n### Like create/delete will be wired in page.tsx (task .5)\nThe actual API calls (POST/DELETE to /api/records with org.impactindexer.review.like) will be handled by callbacks passed from page.tsx.\n\n### Testing\n- `pnpm build` must pass\n- Heart icon renders in outline state by default\n- Heart icon renders filled + rose-500 when liked by current user\n- Like count shows next to heart when > 0","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:24:55.516758+13:00","updatedAt":"2026-02-11T01:33:11.647637+13:00","closedAt":"2026-02-11T01:33:11.647637+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-vdg.5"],"dependentIds":["beads-map-vdg","beads-map-vdg.1"]},{"id":"beads-map-vdg.3","title":"Add threaded replies with inline reply form in NodeDetail","description":"## Add threaded replies with inline reply form in NodeDetail\n\n### Goal\nAdd reply functionality to comments in `components/NodeDetail.tsx`: a 'reply' text button on each comment, an inline reply form, and recursive threaded rendering with indentation.\n\n### File to modify\n`components/NodeDetail.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (BeadsComment now has `replies: BeadsComment[]` and `replyTo?: string`)\n\n### Step 1: Add InlineReplyForm component\n\nPort from Hyperscan ReviewSection:\n```tsx\nfunction InlineReplyForm({\n replyingTo,\n replyText,\n onTextChange,\n onSubmit,\n onCancel,\n isSubmitting,\n}: {\n replyingTo: BeadsComment;\n replyText: string;\n onTextChange: (text: string) => void;\n onSubmit: () => void;\n onCancel: () => void;\n isSubmitting: boolean;\n}) {\n return (\n <div className='mt-2 ml-4 pl-3 border-l border-emerald-200 space-y-1.5'>\n <div className='flex items-center gap-1.5 text-[10px] text-zinc-400'>\n <span>Replying to</span>\n <span className='font-medium text-zinc-600'>\n {replyingTo.displayName || replyingTo.handle}\n </span>\n </div>\n <div className='flex gap-2'>\n <input\n type='text'\n value={replyText}\n onChange={(e) => onTextChange(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && onSubmit()}\n placeholder='Write a reply...'\n disabled={isSubmitting}\n autoFocus\n className='flex-1 px-2 py-1 text-xs bg-white border border-zinc-200 rounded placeholder-zinc-400 focus:outline-none focus:border-emerald-400 disabled:opacity-50'\n />\n <button onClick={onSubmit} disabled={!replyText.trim() || isSubmitting}\n className='px-2 py-1 text-[10px] font-medium text-emerald-600 hover:text-emerald-700 disabled:opacity-50'>\n {isSubmitting ? '...' : 'Reply'}\n </button>\n <button onClick={onCancel} disabled={isSubmitting}\n className='px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-600 disabled:opacity-50'>\n Cancel\n </button>\n </div>\n </div>\n );\n}\n```\n\n### Step 2: Add reply button to CommentItem actions row\n\nAdd a 'reply' text button (Hyperscan pattern):\n```tsx\n<button\n onClick={() => onStartReply?.(comment)}\n disabled={!isAuthenticated}\n className={`transition-colors disabled:opacity-50 ${\n isReplyingToThis ? 'text-emerald-500' : 'text-zinc-300 hover:text-zinc-500'\n }`}\n>\n reply\n</button>\n```\n\n### Step 3: Make CommentItem recursive for threading\n\nAdd `depth` prop (default 0). When `depth > 0`, add indentation:\n```tsx\n<div className={`${depth > 0 ? 'ml-4 pl-3 border-l border-zinc-100' : ''}`}>\n```\n\nAfter the comment content, render replies recursively:\n```tsx\n{comment.replies.length > 0 && (\n <div className='space-y-0'>\n {comment.replies.map((reply) => (\n <CommentItem key={reply.uri} comment={reply} depth={depth + 1} ... />\n ))}\n </div>\n)}\n```\n\n### Step 4: Add reply state management props\n\nAdd to `NodeDetailProps`:\n```typescript\nonReplyComment?: (parentComment: BeadsComment, text: string) => Promise<void>;\n```\n\nAdd reply state to the Comments section (managed locally in NodeDetail):\n```typescript\nconst [replyingToUri, setReplyingToUri] = useState<string | null>(null);\nconst [replyText, setReplyText] = useState('');\nconst [isSubmittingReply, setIsSubmittingReply] = useState(false);\n```\n\n### Step 5: Wire InlineReplyForm rendering\n\nIn CommentItem, show InlineReplyForm when `replyingToUri === comment.uri`:\n```tsx\n{isReplyingToThis && (\n <InlineReplyForm\n replyingTo={comment}\n replyText={replyText}\n onTextChange={onReplyTextChange}\n onSubmit={onSubmitReply}\n onCancel={onCancelReply}\n isSubmitting={isSubmittingReply}\n />\n)}\n```\n\n### Testing\n- `pnpm build` must pass \n- Reply button appears on each comment\n- Clicking reply shows inline form below that comment\n- Replies render indented with left border\n- Nested replies (reply to a reply) indent further","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:25:16.081672+13:00","updatedAt":"2026-02-11T01:33:11.780553+13:00","closedAt":"2026-02-11T01:33:11.780553+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-vdg.5"],"dependentIds":["beads-map-vdg","beads-map-vdg.1"]},{"id":"beads-map-vdg.4","title":"Create AllCommentsPanel sidebar component","description":"## Create AllCommentsPanel sidebar component\n\n### Goal\nCreate `components/AllCommentsPanel.tsx` — a slide-in sidebar that shows ALL comments across all beads nodes, sorted newest-first. This provides a global activity feed view.\n\n### File to create\n`components/AllCommentsPanel.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (allComments flat list available from hook)\n\n### Props interface\n```typescript\ninterface AllCommentsPanelProps {\n isOpen: boolean;\n onClose: () => void;\n allComments: BeadsComment[]; // flat list, newest-first (from useBeadsComments)\n onNodeNavigate: (nodeId: string) => void; // click a comment's node ID to navigate\n isAuthenticated?: boolean;\n currentDid?: string;\n onLikeComment?: (comment: BeadsComment) => Promise<void>;\n onDeleteComment?: (comment: BeadsComment) => Promise<void>;\n}\n```\n\n### Design\nSame slide-in pattern as the NodeDetail sidebar:\n```tsx\n<aside className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${\n isOpen ? 'translate-x-0' : 'translate-x-full'\n}`}>\n```\n\n### Layout\n1. **Header**: 'All Comments' title + close X button + comment count badge\n2. **Scrollable list**: Each comment shows:\n - Target node pill: clickable badge with node ID (e.g., 'beads-map-cvh') in emerald — clicking calls `onNodeNavigate`\n - Avatar (24px circle) + handle + relative time\n - Comment text\n - Heart like button (rose-500 when liked) + like count\n - If it's a reply, show 'Re: {parentHandle}' label in zinc-400\n - Delete X for own comments\n3. **Empty state**: 'No comments yet' when list is empty\n4. **Footer**: count summary\n\n### Comment card design\n```tsx\n<div className='py-3 border-b border-zinc-50'>\n {/* Node target pill */}\n <button onClick={() => onNodeNavigate(comment.nodeId)}\n className='inline-flex items-center px-1.5 py-0.5 mb-1.5 rounded text-[10px] font-mono bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors'>\n {comment.nodeId}\n </button>\n {/* Rest of comment: avatar + name + time + text + actions */}\n</div>\n```\n\n### Important: nodeId on comments\nThe allComments list from the hook needs to include the target nodeId on each comment. Either:\n- Add `nodeId: string` to BeadsComment interface (set during processing in the hook)\n- Or derive it from `subject.uri.replace(/^beads:/, '')` at render time\n\nPreferred: add `nodeId` to BeadsComment in the hook (task .1) since it's needed here.\n\n### Testing\n- `pnpm build` must pass\n- Panel slides in/out with animation\n- Comments sorted newest-first\n- Clicking node ID navigates to that node\n- Like/delete actions work","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:25:35.922861+13:00","updatedAt":"2026-02-11T01:33:11.912418+13:00","closedAt":"2026-02-11T01:33:11.912418+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-vdg.5"],"dependentIds":["beads-map-vdg","beads-map-vdg.1"]},{"id":"beads-map-vdg.5","title":"Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel","description":"## Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel\n\n### Goal\nConnect all new components in `app/page.tsx`: add the 'Comments' pill button in the header, wire like/reply API handlers, render AllCommentsPanel.\n\n### File to modify\n`app/page.tsx` (currently 926 lines)\n\n### Dependencies\n- beads-map-vdg.1 (extended hook with allComments, likes, thread trees)\n- beads-map-vdg.2 (NodeDetail with like UI)\n- beads-map-vdg.3 (NodeDetail with reply UI) \n- beads-map-vdg.4 (AllCommentsPanel component)\n\n### Step 1: Update imports\n\n```typescript\nimport AllCommentsPanel from '@/components/AllCommentsPanel';\n```\n\n### Step 2: Update useBeadsComments destructuring\n\n```typescript\nconst { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } = useBeadsComments();\n```\n\n### Step 3: Add state\n\n```typescript\nconst [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);\n```\n\n### Step 4: Add like handler\n\n```typescript\nconst handleLikeComment = useCallback(async (comment: BeadsComment) => {\n // Check if already liked by current user\n const existingLike = comment.likes.find(l => l.did === session?.did);\n \n if (existingLike) {\n // Unlike: DELETE the like record\n const response = await fetch(\n \\`/api/records?collection=\\${encodeURIComponent('org.impactindexer.review.like')}&rkey=\\${encodeURIComponent(existingLike.rkey)}\\`,\n { method: 'DELETE' }\n );\n if (!response.ok) throw new Error('Failed to unlike');\n } else {\n // Like: POST a new like record\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.like',\n record: {\n subject: { uri: comment.uri, type: 'record' },\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to like');\n }\n \n await refetchComments();\n}, [session?.did, refetchComments]);\n```\n\n### Step 5: Add reply handler\n\n```typescript\nconst handleReplyComment = useCallback(async (parentComment: BeadsComment, text: string) => {\n // Extract the nodeId from the parent comment's subject\n // The parent comment targets beads:<nodeId>, replies still target the same node\n // but include replyTo pointing to the parent comment's AT-URI\n const nodeId = /* derive from parentComment — needs nodeId field from task .1 */;\n \n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n subject: { uri: \\`beads:\\${nodeId}\\`, type: 'record' },\n text,\n replyTo: parentComment.uri,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to post reply');\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 6: Add pill button in header\n\nIn the stats/right area of the header (around line 716), add a comments pill:\n```tsx\n<button\n onClick={() => setAllCommentsPanelOpen(prev => !prev)}\n className={\\`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border transition-colors \\${\n allCommentsPanelOpen\n ? 'bg-emerald-50 text-emerald-600 border-emerald-200'\n : 'bg-white text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:border-zinc-300'\n }\\`}\n>\n <svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z' />\n </svg>\n Comments\n {allComments.length > 0 && (\n <span className='px-1.5 py-0.5 bg-red-500 text-white rounded-full text-[10px] font-medium min-w-[18px] text-center'>\n {allComments.length}\n </span>\n )}\n</button>\n```\n\n### Step 7: Render AllCommentsPanel\n\nAfter the NodeDetail sidebar (around line 866), add:\n```tsx\n<AllCommentsPanel\n isOpen={allCommentsPanelOpen}\n onClose={() => setAllCommentsPanelOpen(false)}\n allComments={allComments}\n onNodeNavigate={(nodeId) => {\n handleNodeNavigate(nodeId);\n setAllCommentsPanelOpen(false);\n }}\n isAuthenticated={isAuthenticated}\n currentDid={session?.did}\n onLikeComment={handleLikeComment}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n### Step 8: Pass new props to NodeDetail (both desktop + mobile instances)\n\nAdd to both NodeDetail instances:\n```typescript\nonLikeComment={handleLikeComment}\nonReplyComment={handleReplyComment}\n```\n\n### Step 9: Close AllCommentsPanel when NodeDetail opens (and vice versa)\n\nWhen selecting a node, close the all-comments panel:\n```typescript\n// In handleNodeClick:\nsetAllCommentsPanelOpen(false);\n```\n\n### Testing\n- `pnpm build` must pass\n- Pill button visible in header\n- Clicking pill opens AllCommentsPanel\n- Like toggle works (creates/deletes like records)\n- Reply creates comment with replyTo field\n- Panel and NodeDetail don't overlap","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:26:04.167505+13:00","updatedAt":"2026-02-11T01:33:12.043078+13:00","closedAt":"2026-02-11T01:33:12.043078+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":4,"blockerIds":["beads-map-vdg.6"],"dependentIds":["beads-map-vdg","beads-map-vdg.2","beads-map-vdg.3","beads-map-vdg.4"]},{"id":"beads-map-vdg.6","title":"Build verification and final cleanup","description":"## Build verification and final cleanup\n\n### Goal\nVerify the full build passes, clean up any TypeScript errors, and ensure all features work together.\n\n### Steps\n1. Run `pnpm build` — must pass with zero errors\n2. Verify no unused imports or dead code from the refactoring\n3. Test the full flow mentally: \n - Comments pill shows in header with count badge\n - Clicking opens AllCommentsPanel with all comments newest-first\n - Each comment shows heart like button\n - Clicking heart toggles like (creates/deletes org.impactindexer.review.like)\n - Reply button shows inline form\n - Submitting reply creates comment with replyTo field\n - Replies render threaded with indentation\n - Node ID in AllCommentsPanel navigates to that node\n4. Update AGENTS.md with new component/hook documentation\n\n### Testing\n- `pnpm build` must pass with zero errors","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:26:12.40132+13:00","updatedAt":"2026-02-11T01:33:12.174234+13:00","closedAt":"2026-02-11T01:33:12.174234+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-vdg","beads-map-vdg.5"]},{"id":"beads-map-vdg.7","title":"Restyle Comments pill to match layout toggle pill styling, remove red counter badge","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:36:49.288253+13:00","updatedAt":"2026-02-11T01:37:27.025479+13:00","closedAt":"2026-02-11T01:37:27.025479+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-z5w","title":"Right-click context menu: show description or add comment","description":"## Right-Click Context Menu\n\n### Summary\nReplace the current right-click behavior (which directly opens CommentTooltip) with a two-step interaction:\n1. Right-click a graph node → small context menu appears at cursor with two options\n2. User picks \"Show description\" → opens description modal, OR \"Add comment\" → opens the existing CommentTooltip\n\n### Current behavior\n- Right-click a node in BeadsGraph → `handleNodeRightClick` in `app/page.tsx:425-430` sets `contextMenu: { node, x, y }`\n- `contextMenu` state directly renders `CommentTooltip` component at `app/page.tsx:997-1010`\n- CommentTooltip shows node info, existing comments preview, and compose area\n\n### New behavior\n- Right-click a node → `contextMenu` state renders a NEW `ContextMenu` component (small 2-item menu)\n- \"Show description\" → opens a description modal (same portal modal currently in NodeDetail.tsx:332-370)\n- \"Add comment\" → opens the existing CommentTooltip at the same cursor position\n\n### Architecture\nThree changes needed:\n1. **New `ContextMenu` component** — small floating menu at cursor position with 2 items\n2. **Extract `DescriptionModal` component** — lift the portal modal from NodeDetail into a reusable component\n3. **Wire in `page.tsx`** — new state for `commentTooltipState` and `descriptionModalNode`, replace direct CommentTooltip render with ContextMenu → action flow\n\n### State model (page.tsx)\n```\ncontextMenu: { node, x, y } | null // phase 1: shows ContextMenu\ncommentTooltipState: { node, x, y } | null // phase 2a: shows CommentTooltip\ndescriptionModalNode: GraphNode | null // phase 2b: shows DescriptionModal\n```\n\nRight-click → sets contextMenu → renders ContextMenu\nContextMenu \"Show description\" → sets descriptionModalNode, clears contextMenu\nContextMenu \"Add comment\" → sets commentTooltipState, clears contextMenu\n\n### Subtasks\n- .1 Create ContextMenu component\n- .2 Extract DescriptionModal component from NodeDetail\n- .3 Wire context menu + actions in page.tsx\n- .4 Build verify and push\n\n### Acceptance criteria\n- Right-clicking a graph node shows a small 2-item context menu\n- \"Show description\" opens a full-screen modal with the issue description (markdown rendered)\n- \"Add comment\" opens the existing CommentTooltip (unchanged behavior)\n- Escape / click-outside dismisses the context menu\n- \"View in window\" button in NodeDetail sidebar still works\n- pnpm build passes","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:18:37.401674+13:00","updatedAt":"2026-02-11T10:47:46.54973+13:00","closedAt":"2026-02-11T10:47:46.54973+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":12,"dependentCount":1,"blockerIds":["beads-map-z5w.1","beads-map-z5w.10","beads-map-z5w.11","beads-map-z5w.12","beads-map-z5w.2","beads-map-z5w.3","beads-map-z5w.4","beads-map-z5w.5","beads-map-z5w.6","beads-map-z5w.7","beads-map-z5w.8","beads-map-z5w.9"],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-z5w.1","title":"Create ContextMenu component","description":"## Create ContextMenu component\n\n### What\nA small floating context menu that appears on right-click of a graph node. Shows two options: \"Show description\" and \"Add comment\". Styled to match the existing app aesthetic.\n\n### New file: `components/ContextMenu.tsx`\n\n#### Props interface\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number; // clientX from right-click event\n y: number; // clientY from right-click event\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n```\n\n#### Positioning\n- `position: fixed`, placed at `(x + 4, y + 4)` — slightly offset from cursor\n- Viewport clamping: if menu would overflow right edge, shift left; if overflow bottom, shift up\n- The menu is small (~160px wide, ~80px tall) so clamping is simple\n- Use `useEffect` + `getBoundingClientRect()` on mount to measure and clamp (same pattern as CommentTooltip.tsx:35-55 but simpler)\n\n#### Visual design\n```\n┌─────────────────┐\n│ 📄 Show description │\n│ 💬 Add comment │\n└─────────────────┘\n```\n\n- Container: `bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden`\n- Shadow: `box-shadow: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.06)`\n- Each item: `px-3 py-2 text-xs text-zinc-700 hover:bg-zinc-50 cursor-pointer flex items-center gap-2 transition-colors`\n- Icons: small SVGs (w-3.5 h-3.5 text-zinc-400)\n - \"Show description\": document/page icon\n - \"Add comment\": chat bubble icon (same as CommentTooltip.tsx:172-183)\n- Divider between items: `border-b border-zinc-100` on first item\n- Animate in: opacity 0→1, translateY(2px)→0, transition 0.15s\n\n#### Dismiss behavior\n- Escape key → calls `onClose()`\n- Click outside → calls `onClose()` (with 50ms delay, same as CommentTooltip.tsx:76-94)\n- Prevent browser context menu on the component itself: `onContextMenu={(e) => e.preventDefault()}`\n\n#### Item click behavior\n- \"Show description\" → calls `onShowDescription()`\n- \"Add comment\" → calls `onAddComment()`\n- Both should also implicitly close the menu (parent handles this by clearing contextMenu state)\n\n#### Full component structure\n```tsx\n\"use client\";\nimport { useState, useRef, useEffect } from \"react\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n\nexport function ContextMenu({ node, x, y, onShowDescription, onAddComment, onClose }: ContextMenuProps) {\n const menuRef = useRef<HTMLDivElement>(null);\n const [pos, setPos] = useState({ x: 0, y: 0 });\n const [visible, setVisible] = useState(false);\n\n // Position + clamp to viewport\n useEffect(() => {\n if (!menuRef.current) return;\n const rect = menuRef.current.getBoundingClientRect();\n const vw = window.innerWidth;\n const vh = window.innerHeight;\n let nx = x + 4;\n let ny = y + 4;\n if (nx + rect.width > vw - 16) nx = vw - rect.width - 16;\n if (nx < 16) nx = 16;\n if (ny + rect.height > vh - 16) ny = vh - rect.height - 16;\n if (ny < 16) ny = 16;\n setPos({ x: nx, y: ny });\n setVisible(true);\n }, [x, y]);\n\n // Escape key\n useEffect(() => {\n const handler = (e: KeyboardEvent) => { if (e.key === \"Escape\") onClose(); };\n window.addEventListener(\"keydown\", handler);\n return () => window.removeEventListener(\"keydown\", handler);\n }, [onClose]);\n\n // Click outside (with delay)\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();\n };\n const timer = setTimeout(() => window.addEventListener(\"mousedown\", handler), 50);\n return () => { clearTimeout(timer); window.removeEventListener(\"mousedown\", handler); };\n }, [onClose]);\n\n return (\n <div ref={menuRef} style={{ position: \"fixed\", left: pos.x, top: pos.y, zIndex: 100,\n opacity: visible ? 1 : 0, transform: visible ? \"translateY(0)\" : \"translateY(2px)\",\n transition: \"opacity 0.15s ease, transform 0.15s ease\" }}\n onContextMenu={(e) => e.preventDefault()}\n >\n <div className=\"bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden\" style={{ minWidth: 180 }}>\n <button onClick={onShowDescription}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-b border-zinc-100\">\n {/* Document icon SVG */}\n Show description\n </button>\n <button onClick={onAddComment}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors\">\n {/* Chat bubble icon SVG */}\n Add comment\n </button>\n </div>\n </div>\n );\n}\n```\n\n### SVG Icons\n**Document icon** (Show description):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z\" />\n</svg>\n```\n\n**Chat bubble icon** (Add comment):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z\" />\n</svg>\n```\n\n### Files to create\n- `components/ContextMenu.tsx`\n\n### Acceptance criteria\n- ContextMenu renders at cursor position with 2 items\n- Hover states on items (bg-zinc-50)\n- Escape key dismisses\n- Click outside dismisses\n- Clicking \"Show description\" calls onShowDescription\n- Clicking \"Add comment\" calls onAddComment\n- Viewport clamping works (menu stays on screen)\n- Animate-in transition (opacity + translateY)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:19:10.934581+13:00","updatedAt":"2026-02-11T09:23:40.14949+13:00","closedAt":"2026-02-11T09:23:40.14949+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-z5w.3"],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.10","title":"Avatar visual tuning: full opacity, persistent in clusters, minimap display","description":"## Avatar visual tuning\n\n### Changes\n\n#### 1. Full opacity (components/BeadsGraph.tsx paintNode)\n- Changed `ctx.globalAlpha = Math.min(opacity, 0.6)` → `ctx.globalAlpha = 1`\n- Avatars are now always at full opacity, not subtle/translucent\n\n#### 2. Never fade in clusters (components/BeadsGraph.tsx paintNode)\n- Removed `globalScale > 0.4` threshold from the avatar drawing condition\n- Changed `if (claimInfo && globalScale > 0.4)` → `if (claimInfo)`\n- Avatars remain visible even when zoomed out to cluster view\n\n#### 3. Constant screen-space size on zoom (components/BeadsGraph.tsx paintNode)\n- Changed `avatarSize = Math.min(8, Math.max(4, 10 / globalScale))` → `avatarSize = Math.max(4, 10 / globalScale)`\n- Removed the `Math.min(8, ...)` cap so avatar grows in graph-space as you zoom out, maintaining roughly the same pixel size on screen\n\n#### 4. Minimap avatar display (components/BeadsGraph.tsx redrawMinimap)\n- Added avatar drawing loop after the node dots loop in `redrawMinimap`\n- For each claimed node, draws a small circular avatar (radius 5px) at the node position on the minimap\n- Uses the same `getAvatarImage` cache for image loading\n- Fallback: gray circle if image not loaded\n- White border ring for contrast\n\n### Commits\n- `8693bec` Reduce claim avatar opacity to 0.6, constant screen-space size on zoom\n- `0a23755` Avatar: full opacity, never fade in clusters, show on minimap\n\n### Files changed\n- `components/BeadsGraph.tsx` — paintNode avatar section + redrawMinimap avatar loop\n\n### Status: DONE","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:26:08.912736+13:00","updatedAt":"2026-02-11T10:26:45.656242+13:00","closedAt":"2026-02-11T10:26:45.656242+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.11"],"dependentIds":["beads-map-z5w","beads-map-z5w.9"]},{"id":"beads-map-z5w.11","title":"Avatar hover tooltip: show profile info on mouseover","description":"## Avatar hover tooltip\n\n### What\nWhen hovering over a claimed node avatar on the graph, a small tooltip appears showing the profile picture and @handle.\n\n### Implementation\n\n#### 1. New prop on BeadsGraph (`components/BeadsGraph.tsx`)\n- Added `onAvatarHover?: (info: { handle: string; avatar?: string; x: number; y: number } | null) => void` to `BeadsGraphProps`\n- Added `onAvatarHoverRef` (stable ref for the callback, avoids stale closures)\n- Added `hoveredAvatarNodeRef` (tracks which avatar is hovered to avoid redundant callbacks)\n- Added `viewNodesRef` (ref synced from `viewNodes` memo, so mousemove respects epics view)\n\n#### 2. Mousemove hit-testing (`components/BeadsGraph.tsx`)\n- `useEffect` registers a `mousemove` listener on the container div\n- Converts screen coords → graph coords via `fg.screen2GraphCoords()`\n- Iterates `viewNodesRef.current` (respects full/epics view mode)\n- For each node with a claim, computes the avatar circle position (`node.x + size * 0.7`, `node.y + size * 0.7`) and radius (`Math.max(4, 10 / globalScale)`)\n- Hit-tests: if mouse is inside the avatar circle, emits `onAvatarHover({ handle, avatar, x, y })`\n- If mouse leaves all avatar circles, emits `onAvatarHover(null)`\n- Uses `[]` dependency (refs for everything) so listener is registered once\n\n#### 3. Epics view fix\n- Initially the hit-test iterated `nodes` (raw prop) which broke in epics view since child nodes are collapsed\n- Fixed to iterate `viewNodesRef.current` which reflects the current view mode\n- `viewNodesRef.current` is synced inline after the `viewNodes` useMemo\n\n#### 4. Tooltip rendering (`app/page.tsx`)\n- Added `avatarTooltip` state: `{ handle, avatar, x, y } | null`\n- Passed `onAvatarHover={setAvatarTooltip}` to `<BeadsGraph>`\n- Renders a `position: fixed` tooltip at `(x+12, y-8)` from cursor with `pointerEvents: none`\n- Tooltip shows: profile pic (20x20 rounded circle) + `@handle` text\n- White bg, zinc border, rounded-lg, shadow-lg — matches app aesthetic\n- Fallback: gray circle with first letter if no avatar URL\n\n### Commits\n- `ea0a905` Add avatar hover tooltip showing profile pic and handle\n- `5817941` Fix avatar tooltip in epics view: hit-test against viewNodes not raw nodes\n\n### Files changed\n- `components/BeadsGraph.tsx` — onAvatarHover prop, refs, mousemove useEffect, viewNodesRef\n- `app/page.tsx` — avatarTooltip state, onAvatarHover prop, tooltip JSX\n\n### Status: DONE","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:26:37.012238+13:00","updatedAt":"2026-02-11T10:26:45.776772+13:00","closedAt":"2026-02-11T10:26:45.776772+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-z5w","beads-map-z5w.10"]},{"id":"beads-map-z5w.12","title":"Unclaim task: right-click to remove claim from a node","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:47:43.376019+13:00","updatedAt":"2026-02-11T10:47:46.432125+13:00","closedAt":"2026-02-11T10:47:46.432125+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.2","title":"Extract DescriptionModal component from NodeDetail","description":"## Extract DescriptionModal component from NodeDetail\n\n### What\nThe description modal currently lives inside `components/NodeDetail.tsx` (lines 332-370) using local `descriptionExpanded` state. We need the same modal accessible from the right-click context menu (which lives in `page.tsx`, not inside NodeDetail). \n\nExtract the modal into a standalone `DescriptionModal` component, then use it from both NodeDetail and page.tsx.\n\n### Current implementation in NodeDetail.tsx\n\n**State (line 52):**\n```typescript\nconst [descriptionExpanded, setDescriptionExpanded] = useState(false);\n```\n\n**\"View in window\" button (lines 317-322):**\n```tsx\n<button\n onClick={() => setDescriptionExpanded(true)}\n className=\"text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors\"\n>\n View in window\n</button>\n```\n\n**Portal modal (lines 332-370):**\n```tsx\n{descriptionExpanded && node.description && createPortal(\n <div className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={() => setDescriptionExpanded(false)}>\n <div className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}>\n {/* Header: node.id + node.title + X button */}\n {/* Body: ReactMarkdown with node.description */}\n </div>\n </div>,\n document.body\n)}\n```\n\n### New file: `components/DescriptionModal.tsx`\n\n```typescript\n\"use client\";\nimport { createPortal } from \"react-dom\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface DescriptionModalProps {\n node: GraphNode;\n onClose: () => void;\n}\n\nexport function DescriptionModal({ node, onClose }: DescriptionModalProps) {\n if (!node.description) return null;\n\n return createPortal(\n <div\n className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={onClose}\n >\n <div\n className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}\n >\n {/* Modal header */}\n <div className=\"flex items-center justify-between px-5 py-3 border-b border-zinc-100\">\n <div className=\"flex items-center gap-2 min-w-0\">\n <span className=\"text-xs font-mono font-semibold text-emerald-600 shrink-0\">\n {node.id}\n </span>\n <span className=\"text-sm font-semibold text-zinc-900 truncate\">\n {node.title}\n </span>\n </div>\n <button\n onClick={onClose}\n className=\"shrink-0 p-1 text-zinc-400 hover:text-zinc-600 transition-colors\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {/* Modal body */}\n <div className=\"flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed\">\n <ReactMarkdown remarkPlugins={[remarkGfm]}>\n {node.description}\n </ReactMarkdown>\n </div>\n </div>\n </div>,\n document.body\n );\n}\n```\n\n### Refactor NodeDetail.tsx\n\n**Remove from NodeDetail.tsx:**\n- The `descriptionExpanded` state (line 52)\n- The portal modal JSX (lines 332-370)\n\n**Keep in NodeDetail.tsx:**\n- The \"View in window\" button (lines 317-322)\n- But change its onClick to use the new component locally\n\n**Two approaches for NodeDetail:**\n\n**Option A (keep local state):** NodeDetail keeps its own `descriptionExpanded` state and renders `<DescriptionModal>` when true. Simple, no prop drilling needed. The \"View in window\" button works exactly as before.\n\n**Option B (lift state via callback):** Add an `onExpandDescription?: () => void` prop to NodeDetail. \"View in window\" calls this callback, parent (page.tsx) sets `descriptionModalNode`. More centralized but adds a prop.\n\n**Recommended: Option A.** Keep NodeDetail self-contained. It renders `<DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />` instead of the inline portal. The context menu in page.tsx independently renders `<DescriptionModal>` from its own state. Two independent entry points, same component. No coupling needed.\n\n### Changes to NodeDetail.tsx\n\n1. **Add import:**\n```typescript\nimport { DescriptionModal } from \"./DescriptionModal\";\n```\n\n2. **Keep `descriptionExpanded` state** (line 52) — unchanged\n\n3. **Replace lines 332-370** (the inline createPortal block) with:\n```tsx\n{descriptionExpanded && node.description && (\n <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />\n)}\n```\n\n4. **Remove import** of `createPortal` from NodeDetail.tsx IF it is no longer used elsewhere in the file. Check: `createPortal` is only used for the description modal, so remove `import { createPortal } from \"react-dom\"` from NodeDetail.tsx.\n\n5. **Remove import** of `ReactMarkdown` and `remarkGfm` from NodeDetail.tsx IF they are no longer used. Check: ReactMarkdown is still used for the inline description preview (line 325-327), so KEEP these imports.\n\nActually wait — `createPortal` is imported at the top of NodeDetail.tsx. Let me check if it is used anywhere else in the file besides the description modal. Looking at the NodeDetail component, createPortal is ONLY used for the description modal (lines 332-370). So yes, remove the createPortal import.\n\n### Files to create\n- `components/DescriptionModal.tsx`\n\n### Files to edit \n- `components/NodeDetail.tsx`:\n - Add import for DescriptionModal\n - Remove import for createPortal (from \"react-dom\")\n - Replace inline portal JSX (lines 332-370) with `<DescriptionModal>` usage\n - Keep descriptionExpanded state and \"View in window\" button unchanged\n\n### Acceptance criteria\n- New `DescriptionModal` component renders the same modal as before\n- \"View in window\" button in NodeDetail sidebar still works identically\n- DescriptionModal uses createPortal to document.body with z-[100]\n- Backdrop click and X button close the modal\n- Markdown rendering with remarkGfm works\n- No visual regression in the modal appearance\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:19:41.231435+13:00","updatedAt":"2026-02-11T09:23:40.327949+13:00","closedAt":"2026-02-11T09:23:40.327949+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-z5w.3"],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.3","title":"Wire context menu and actions in page.tsx","description":"## Wire context menu and actions in page.tsx\n\n### What\nConnect the new ContextMenu and DescriptionModal components into the main page. Replace the direct CommentTooltip render with a two-phase flow: ContextMenu → action (description modal OR comment tooltip).\n\n### Current code to change\n\n**State (line 184-188):**\n```typescript\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n**Right-click handler (lines 425-430):**\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n },\n []\n);\n```\n\n**CommentTooltip render (lines 997-1010):**\n```tsx\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n**Background click (lines 420-423):**\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n}, []);\n```\n\n### New state\n\nAdd after existing `contextMenu` state (~line 188):\n```typescript\n// Separate state for CommentTooltip (opened from context menu \"Add comment\")\nconst [commentTooltipState, setCommentTooltipState] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n\n// Description modal (opened from context menu \"Show description\")\nconst [descriptionModalNode, setDescriptionModalNode] = useState<GraphNode | null>(null);\n```\n\n### New imports\n\nAdd at top of file:\n```typescript\nimport { ContextMenu } from \"@/components/ContextMenu\";\nimport { DescriptionModal } from \"@/components/DescriptionModal\";\n```\n\n### Changes to handleNodeRightClick\n\nNo change needed — it still sets `contextMenu` state. But now `contextMenu` renders `ContextMenu` instead of `CommentTooltip`.\n\n### Changes to handleBackgroundClick\n\nAlso clear the new states:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n setCommentTooltipState(null);\n}, []);\n```\n\nNote: do NOT clear `descriptionModalNode` on background click — the modal has its own backdrop click handler.\n\n### Replace CommentTooltip render block (lines 997-1010)\n\nReplace the entire block with:\n\n```tsx\n{/* Right-click context menu */}\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => {\n setDescriptionModalNode(contextMenu.node);\n setContextMenu(null);\n }}\n onAddComment={() => {\n setCommentTooltipState({\n node: contextMenu.node,\n x: contextMenu.x,\n y: contextMenu.y,\n });\n setContextMenu(null);\n }}\n onClose={() => setContextMenu(null)}\n />\n)}\n\n{/* Comment tooltip (opened from context menu \"Add comment\") */}\n{commentTooltipState && (\n <CommentTooltip\n node={commentTooltipState.node}\n x={commentTooltipState.x}\n y={commentTooltipState.y}\n onClose={() => setCommentTooltipState(null)}\n onSubmit={async (text) => {\n await handlePostComment(commentTooltipState.node.id, text);\n setCommentTooltipState(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(commentTooltipState.node.id)}\n />\n)}\n\n{/* Description modal (opened from context menu \"Show description\") */}\n{descriptionModalNode && (\n <DescriptionModal\n node={descriptionModalNode}\n onClose={() => setDescriptionModalNode(null)}\n />\n)}\n```\n\n### Placement in JSX\n\nThe three blocks above should go at the same location where the CommentTooltip currently renders (around line 997, just before `</div>` closing the graph area div at line 1012).\n\nThe `DescriptionModal` uses createPortal to document.body with z-[100], so its placement in the JSX tree does not matter for visual layering. But keeping it near the other overlays is cleaner.\n\n### Edge cases\n\n1. **Right-click while context menu is open**: Existing handler overwrites `contextMenu` state — ContextMenu repositions. Works correctly.\n2. **Right-click while CommentTooltip is open**: The `handleNodeRightClick` sets `contextMenu` which shows ContextMenu. The CommentTooltip from a previous action stays open (its state is separate). The user can dismiss CommentTooltip via its own Escape/click-outside, or just interact with the new context menu. To be cleaner, we should clear `commentTooltipState` when a new right-click happens:\n ```typescript\n const handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n setCommentTooltipState(null); // dismiss any open comment tooltip\n },\n []\n );\n ```\n3. **Right-click while description modal is open**: The modal has z-[100] with backdrop. A right-click on the graph behind the backdrop would not fire (backdrop captures click). If the modal IS open and user clicks backdrop to dismiss it, then right-clicks a node, the normal flow happens. No special handling needed.\n4. **Node with no description**: If user picks \"Show description\" on a node without a description, `DescriptionModal` receives a node with `node.description` being undefined/empty. The component should handle this gracefully — show a \"No description\" message, or the ContextMenu could disable/hide the option. **Recommended: hide \"Show description\" if `!node.description`** — add a `hasDescription` check in ContextMenu.\n\n### Update ContextMenu to conditionally show \"Show description\"\n\nPass `hasDescription` or check `node.description` inside ContextMenu. If no description, either:\n- (a) Hide the item entirely (cleaner)\n- (b) Show it grayed out / disabled\n\n**Recommended: (a) hide it.** If the node has no description, the context menu shows only \"Add comment\". If it has a description, both items show.\n\nTo handle this: ContextMenu already receives the `node` prop. It can check `node.description` internally:\n```tsx\n{node.description && (\n <button onClick={onShowDescription} ...>Show description</button>\n)}\n<button onClick={onAddComment} ...>Add comment</button>\n```\n\nBut wait — if a node has no description and the context menu only shows 1 item, the right-click context menu is pointless overhead (same as before — just open CommentTooltip directly). \n\n**Better approach:** In `handleNodeRightClick`, if the node has no description, skip the context menu and directly open the comment tooltip:\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setCommentTooltipState(null);\n if (!node.description) {\n // No description → skip context menu, open comment tooltip directly\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n },\n []\n);\n```\n\nThis preserves the existing UX for nodes without descriptions (identical to current behavior) and only shows the context menu when there is a meaningful choice.\n\n### Files to edit\n- `app/page.tsx`:\n - Add imports for ContextMenu and DescriptionModal\n - Add `commentTooltipState` and `descriptionModalNode` state\n - Update `handleNodeRightClick` to check for description\n - Update `handleBackgroundClick` to clear new states\n - Replace CommentTooltip render block with ContextMenu + CommentTooltip + DescriptionModal\n\n### Acceptance criteria\n- Right-click a node WITH description → context menu with 2 items\n- Right-click a node WITHOUT description → CommentTooltip opens directly (no context menu)\n- \"Show description\" → description modal opens, context menu closes\n- \"Add comment\" → CommentTooltip opens at same position, context menu closes\n- Right-click another node while CommentTooltip is open → CommentTooltip closes, new context menu opens\n- Background click clears context menu and comment tooltip\n- Escape closes whichever overlay is topmost\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:20:21.369074+13:00","updatedAt":"2026-02-11T09:23:40.50276+13:00","closedAt":"2026-02-11T09:23:40.50276+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-z5w.4"],"dependentIds":["beads-map-z5w","beads-map-z5w.1","beads-map-z5w.2"]},{"id":"beads-map-z5w.4","title":"Build verify and push context menu feature","description":"## Build verify and push\n\n### What\nFinal task: run pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.1\nbd close beads-map-z5w.2\nbd close beads-map-z5w.3\nbd close beads-map-z5w.4\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add right-click context menu with show description and add comment options (beads-map-z5w)\"\ngit push\n```\n\n### Edge cases to verify\n- Right-click node with description → context menu → \"Show description\" → modal opens\n- Right-click node with description → context menu → \"Add comment\" → CommentTooltip opens\n- Right-click node without description → CommentTooltip opens directly (no context menu)\n- Escape dismisses context menu / comment tooltip / description modal\n- Click outside dismisses context menu / comment tooltip\n- Backdrop click dismisses description modal\n- \"View in window\" in NodeDetail sidebar still works\n- Right-click during timeline replay still works\n- Context menu does not overlap viewport edges\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks and epic closed in beads","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:20:31.850081+13:00","updatedAt":"2026-02-11T09:23:40.674292+13:00","closedAt":"2026-02-11T09:23:40.674292+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-z5w","beads-map-z5w.3"]},{"id":"beads-map-z5w.5","title":"Add 'Claim task' menu item to ContextMenu and post claim comment","description":"## Add \"Claim task\" menu item to ContextMenu and post claim comment\n\n### What\nAdd a third option \"Claim task\" to the right-click context menu. When clicked, it posts a comment on the node with the text `@<handle>` (e.g., `@satyam2.climateai.org`). The menu item only appears when the user is authenticated AND the node is not already claimed by anyone.\n\n### Detecting if a node is already claimed\nA node is \"claimed\" if any of its comments has text that starts with `@` and matches a handle pattern. We need to check `commentsByNode.get(nodeId)` to see if any comment text starts with `@`. Since claims are just `@handle`, a simple check is:\n\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\nThis checks: text starts with `@`, and is a single word (no spaces) — i.e., just a handle tag.\n\n### Changes to `components/ContextMenu.tsx`\n\n#### New props:\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClaimTask?: () => void; // NEW — undefined if not authenticated or already claimed\n onClose: () => void;\n}\n```\n\n#### New menu item (after \"Add comment\", before closing `</div>`):\n```tsx\n{onClaimTask && (\n <button\n onClick={onClaimTask}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-t border-zinc-100\"\n >\n <svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z\" />\n </svg>\n Claim task\n </button>\n)}\n```\n\nThe person/user icon SVG is from Heroicons (user outline).\n\nAlso add `border-t border-zinc-100` to the \"Add comment\" button when \"Claim task\" follows it. Actually simpler: just add `border-t border-zinc-100` to the claim button itself (as shown above), and keep \"Add comment\" unchanged — it already has no bottom border.\n\n### Changes to `app/page.tsx`\n\n#### 1. Add claim handler function (after `handlePostComment`, around line 485):\n```typescript\nconst handleClaimTask = useCallback(\n async (nodeId: string) => {\n if (!session?.handle) return;\n await handlePostComment(nodeId, `@${session.handle}`);\n },\n [session?.handle, handlePostComment]\n);\n```\n\nThis reuses the existing `handlePostComment` which:\n- POSTs to `/api/records` with collection `org.impactindexer.review.comment`\n- Creates a comment with `subject.uri = \"beads:<nodeId>\"` and `text = \"@handle\"`\n- Calls `refetchComments()` to update the UI\n\n#### 2. Add `isNodeClaimed` helper (at module level or as a function in page.tsx):\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n // A claim comment is just \"@handle\" — starts with @ and has no spaces\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\n#### 3. Update ContextMenu render (around line 1022):\n```tsx\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => { ... }} // unchanged\n onAddComment={() => { ... }} // unchanged\n onClaimTask={\n isAuthenticated && !isNodeClaimed(commentsByNode.get(contextMenu.node.id))\n ? () => {\n handleClaimTask(contextMenu.node.id);\n setContextMenu(null);\n }\n : undefined\n }\n onClose={() => setContextMenu(null)}\n />\n)}\n```\n\nWhen `onClaimTask` is undefined, the ContextMenu hides the \"Claim task\" button.\n\n### Edge cases\n1. **Not authenticated**: `onClaimTask` is undefined → button hidden\n2. **Already claimed**: `isNodeClaimed` returns true → button hidden\n3. **User claims their own node**: Works fine, comment is posted\n4. **Node with no description + not authenticated**: Right-click opens CommentTooltip directly (existing behavior in `handleNodeRightClick` which checks `!node.description`)\n5. **Node with no description + authenticated + not claimed**: Currently skips context menu. Need to update `handleNodeRightClick` to show context menu even for nodes without description IF the user is authenticated (so they can see \"Claim task\"). Update the condition:\n ```typescript\n if (!node.description && !isAuthenticated) {\n // No description and not logged in → only action is comment → skip menu\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n ```\n Wait, but if `isAuthenticated` but node has no description AND is already claimed, the menu would show just \"Add comment\" which is pointless overhead. The clean rule is: show context menu if there are 2+ items to choose from. For now, keep it simple: always show context menu when authenticated (even if only \"Add comment\" + \"Claim task\", or just \"Add comment\" if claimed). The overhead of one extra click is fine for the authenticated UX.\n\n### Files to edit\n- `components/ContextMenu.tsx` — add `onClaimTask` prop and conditional button\n- `app/page.tsx` — add `handleClaimTask`, `isNodeClaimed` helper, update ContextMenu render, update `handleNodeRightClick` condition\n\n### Acceptance criteria\n- \"Claim task\" appears in context menu when authenticated AND node not already claimed\n- \"Claim task\" hidden when not authenticated OR node already claimed\n- Clicking \"Claim task\" posts a comment `@<handle>` on the node\n- Comments refetch after claiming\n- Context menu closes after claiming\n- When authenticated, context menu always shows (even for nodes without description)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:47:43.132495+13:00","updatedAt":"2026-02-11T09:54:17.219995+13:00","closedAt":"2026-02-11T09:54:17.219995+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-z5w.6"],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.6","title":"Compute claimed-node avatar map from comments","description":"## Compute claimed-node avatar map from comments\n\n### What\nDerive a `Map<string, { avatar?: string; handle: string }>` from `allComments` that maps node IDs to the claimant profile info. This map is then passed to `BeadsGraph` for rendering the avatar on the canvas.\n\n### How claims are detected\nA claim comment has text that starts with `@` and is a single word (no spaces). For example: `@satyam2.climateai.org`. Only the first claim per node counts (one claim only).\n\nThe comment object (`BeadsComment`) already has the resolved profile info: `handle`, `avatar`, `displayName`, `did`. So when we find a claim comment, we already have the avatar URL.\n\n### Implementation in `app/page.tsx`\n\n#### 1. Add a useMemo to compute claimed nodes (after `commentsByNode` is available):\n\n```typescript\n// Compute claimed node avatars from comments\n// A claim comment has text \"@handle\" (starts with @, no spaces)\nconst claimedNodeAvatars = useMemo(() => {\n const map = new Map<string, { avatar?: string; handle: string }>();\n if (!allComments) return map;\n for (const comment of allComments) {\n // Skip if this node already has a claimant (first claim wins)\n if (map.has(comment.nodeId)) continue;\n const text = comment.text.trim();\n if (text.startsWith(\"@\") && text.indexOf(\" \") === -1) {\n map.set(comment.nodeId, {\n avatar: comment.avatar,\n handle: comment.handle,\n });\n }\n }\n return map;\n}, [allComments]);\n```\n\nNote: `allComments` is the flat array from `useBeadsComments()` (already available in page.tsx at line 179). It contains all comments across all nodes, each with resolved profile info.\n\n#### 2. Pass to BeadsGraph:\n\n```tsx\n<BeadsGraph\n // ... existing props ...\n claimedNodeAvatars={claimedNodeAvatars}\n/>\n```\n\n#### 3. Add prop to BeadsGraphProps:\n\nIn `components/BeadsGraph.tsx`, add to the props interface:\n```typescript\nclaimedNodeAvatars?: Map<string, { avatar?: string; handle: string }>;\n```\n\nAnd add a ref to sync it (same pattern as `commentedNodeIdsRef`):\n```typescript\nconst claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string }>>(\n claimedNodeAvatars || new Map()\n);\n\nuseEffect(() => {\n claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map();\n refreshGraph(graphRef);\n}, [claimedNodeAvatars]);\n```\n\n### Why use a ref in BeadsGraph\nSame reason as `commentedNodeIdsRef`: the `paintNode` callback has `[]` dependencies (never recreated). It reads from refs, not from props or state. If we used props directly, we would need to add `claimedNodeAvatars` to the paintNode dependency array, which would cause the ForceGraph component to re-render and re-heat the simulation. The ref pattern avoids this.\n\n### Data flow summary\n```\nuseBeadsComments() → allComments (flat array with resolved profiles)\n ↓\nuseMemo → claimedNodeAvatars: Map<nodeId, { avatar, handle }>\n ↓\n<BeadsGraph claimedNodeAvatars={...}>\n ↓\nclaimedNodeAvatarsRef.current (ref, synced via useEffect)\n ↓\npaintNode reads claimedNodeAvatarsRef.current.get(nodeId)\n```\n\n### Files to edit\n- `app/page.tsx` — add `claimedNodeAvatars` useMemo, pass to BeadsGraph\n- `components/BeadsGraph.tsx` — add `claimedNodeAvatars` prop, add ref + sync effect\n\n### Acceptance criteria\n- `claimedNodeAvatars` correctly maps node IDs to claimant profile info\n- Only the first claim comment per node is used\n- Map updates reactively when comments change (useMemo dependency on allComments)\n- BeadsGraph receives the map and syncs it to a ref\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:48:05.021917+13:00","updatedAt":"2026-02-11T09:54:17.341949+13:00","closedAt":"2026-02-11T09:54:17.341949+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.7"],"dependentIds":["beads-map-z5w","beads-map-z5w.5"]},{"id":"beads-map-z5w.7","title":"Draw claimant avatar on canvas nodes in BeadsGraph paintNode","description":"## Draw claimant avatar on canvas nodes in BeadsGraph paintNode\n\n### What\nWhen a node has a claimant (from `claimedNodeAvatarsRef`), draw their profile picture as a small circular avatar at the bottom-right of the node on the canvas. If no avatar URL is available, draw a fallback circle with the first letter of the handle.\n\n### Avatar image cache\nCanvas `ctx.drawImage()` requires an `HTMLImageElement`. We need to pre-load avatar images and cache them. Use a **module-level cache** (outside the component, like the existing `_ForceGraph2DModule` pattern) to persist across re-renders:\n\n```typescript\n// Module-level avatar image cache\nconst avatarImageCache = new Map<string, HTMLImageElement | \"loading\" | \"failed\">();\n\nfunction getAvatarImage(url: string, onLoad: () => void): HTMLImageElement | null {\n const cached = avatarImageCache.get(url);\n if (cached === \"loading\" || cached === \"failed\") return null;\n if (cached) return cached;\n\n // Start loading\n avatarImageCache.set(url, \"loading\");\n const img = new Image();\n img.crossOrigin = \"anonymous\"; // Required for canvas drawImage with external URLs\n img.onload = () => {\n avatarImageCache.set(url, img);\n onLoad(); // Trigger a canvas redraw\n };\n img.onerror = () => {\n avatarImageCache.set(url, \"failed\");\n };\n img.src = url;\n return null;\n}\n```\n\nThe `onLoad` callback should call `refreshGraph(graphRef)` to trigger a canvas redraw once the image is loaded. We need `graphRef` accessible from the cache callback. Two approaches:\n\n**Approach A:** Store `graphRef` in a module-level variable that gets set on component mount. Ugly but simple.\n\n**Approach B:** Instead of `onLoad` callback, just call `refreshGraph` from within `paintNode` when cache state changes. But `paintNode` runs every frame anyway, so once the image loads and is in cache, the next frame will pick it up. The issue is that we need ONE extra redraw after the image loads to show it. Solution: in `getAvatarImage`, when image loads, set a module-level flag `avatarCacheDirty = true`. In `paintNode`, if `avatarCacheDirty`, call `refreshGraph` once and reset the flag. Actually this is complicated.\n\n**Approach C (recommended):** The simplest approach. In `paintNode`, just try to get the cached image. If not cached yet, start loading and draw fallback. On next `paintNode` call (which happens on every frame when force simulation is running, or on next user interaction), the image will be ready. For the case where simulation has settled (no movement), the image load triggers no redraw. Fix: attach `img.onload` that triggers `refreshGraph(graphRef)`. Since `graphRef` is available in the component scope, pass a `refreshFn` to the cache function.\n\nActually, the cleanest approach: keep the image cache at module level, but have `paintNode` call a helper that uses the graphRef from the component:\n\n```typescript\n// Inside the BeadsGraph component:\nconst avatarRefreshRef = useRef<() => void>(() => {});\nuseEffect(() => {\n avatarRefreshRef.current = () => refreshGraph(graphRef);\n}, []);\n```\n\nThen in paintNode:\n```typescript\nconst claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id);\nif (claimInfo && globalScale > 0.4) {\n const avatarSize = Math.min(8, Math.max(4, 10 / globalScale));\n const avatarX = node.x + animatedSize * 0.7;\n const avatarY = node.y + animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = Math.min(opacity, 0.95);\n\n // Circular clipping path for avatar\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n\n if (claimInfo.avatar) {\n const img = getAvatarImage(claimInfo.avatar, () => avatarRefreshRef.current());\n if (img) {\n ctx.save();\n ctx.clip();\n ctx.drawImage(\n img,\n avatarX - avatarSize,\n avatarY - avatarSize,\n avatarSize * 2,\n avatarSize * 2\n );\n ctx.restore();\n } else {\n // Loading fallback — gray circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n } else {\n // No avatar URL — fallback circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n\n // White border ring around avatar for contrast\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n ctx.strokeStyle = \"#ffffff\";\n ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);\n ctx.stroke();\n\n ctx.restore();\n}\n```\n\n### Fallback avatar drawing\n```typescript\nfunction drawAvatarFallback(\n ctx: CanvasRenderingContext2D,\n x: number, y: number, radius: number,\n handle: string, globalScale: number\n) {\n // Light gray circle\n ctx.beginPath();\n ctx.arc(x, y, radius, 0, Math.PI * 2);\n ctx.fillStyle = \"#e4e4e7\"; // zinc-200\n ctx.fill();\n\n // First letter of handle\n const letter = handle.replace(\"@\", \"\").charAt(0).toUpperCase();\n const fontSize = Math.min(7, Math.max(3, radius * 1.3));\n ctx.font = `600 ${fontSize}px \"Inter\", system-ui, sans-serif`;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillStyle = \"#71717a\"; // zinc-500\n ctx.fillText(letter, x, y + 0.3);\n}\n```\n\n### Placement: bottom-right of node\nThe existing comment badge is at top-right:\n```\nbadgeX = node.x + animatedSize * 0.75 // top-right\nbadgeY = node.y - animatedSize * 0.75 // top-right (negative Y = up)\n```\n\nFor the avatar at bottom-right:\n```\navatarX = node.x + animatedSize * 0.7 // right\navatarY = node.y + animatedSize * 0.7 // bottom (positive Y = down)\n```\n\n### Drawing order in paintNode\nAdd the avatar drawing AFTER the comment badge (line ~746), before the `ctx.restore()` at the end of paintNode. The order:\n1. ... existing drawing (body, ring, label, etc.) ...\n2. Comment count badge at top-right (lines 716-746) — existing\n3. **Claimant avatar at bottom-right** — NEW (lines ~748+)\n\n### `crossOrigin = \"anonymous\"` \nRequired because avatar URLs are from `cdn.bsky.app` (external domain). Without this, canvas becomes \"tainted\" and some operations may fail. The `crossOrigin = \"anonymous\"` on the Image element tells the browser to request the image with CORS headers. Bluesky CDN supports CORS.\n\n### Visibility threshold\nSame as comment badges: `globalScale > 0.4`. When zoomed out too far, avatars are invisible (too small to see anyway).\n\n### Files to edit\n- `components/BeadsGraph.tsx`:\n - Add module-level `avatarImageCache` and `getAvatarImage()` function\n - Add module-level `drawAvatarFallback()` function\n - Add `avatarRefreshRef` inside component\n - Add avatar drawing section in `paintNode` after comment badge\n - Destructure `claimedNodeAvatars` from props (already added in .6)\n\n### Acceptance criteria\n- Claimed nodes show a small circular avatar at bottom-right\n- Avatar loads asynchronously and appears after image loads\n- Fallback shows gray circle with first letter of handle when no avatar URL\n- Fallback shows while image is loading\n- Avatar has white border ring for contrast\n- Avatar only visible when zoomed in enough (globalScale > 0.4)\n- Avatar scales appropriately with zoom level\n- No canvas tainting errors (crossOrigin = \"anonymous\")\n- Multiple claimed nodes each show their respective claimant avatars\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:48:47.291793+13:00","updatedAt":"2026-02-11T09:54:17.463406+13:00","closedAt":"2026-02-11T09:54:17.463406+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.8"],"dependentIds":["beads-map-z5w","beads-map-z5w.6"]},{"id":"beads-map-z5w.8","title":"Build verify and push claim task feature","description":"## Build verify and push claim task feature\n\n### What\nFinal task: run pnpm build, fix any errors, close beads tasks, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.5\nbd close beads-map-z5w.6\nbd close beads-map-z5w.7\nbd close beads-map-z5w.8\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add claim task feature: right-click to claim with avatar on node (beads-map-z5w.5-8)\"\ngit push\n```\n\n### Edge cases to verify\n1. **Not authenticated** → \"Claim task\" not in context menu\n2. **Already claimed by someone** → \"Claim task\" not in context menu\n3. **Claim with avatar** → small circular avatar appears at bottom-right of node\n4. **Claim without avatar (no profile pic)** → gray circle with first letter of handle\n5. **Multiple nodes claimed by different users** → each shows correct avatar\n6. **Zoom out far** → avatars disappear (globalScale < 0.4)\n7. **Avatar image fails to load** → fallback circle shown\n8. **Timeline replay** → claimed avatars still show on visible nodes\n9. **Comment badges + avatar** → both visible (top-right badge, bottom-right avatar, no overlap)\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks (.5, .6, .7, .8) and epic closed in beads","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:48:59.026151+13:00","updatedAt":"2026-02-11T09:54:17.602817+13:00","closedAt":"2026-02-11T09:54:17.602817+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.9"],"dependentIds":["beads-map-z5w","beads-map-z5w.7"]},{"id":"beads-map-z5w.9","title":"Fix claim avatar loading: optimistic display, Bluesky API fallback, CORS fix","description":"## Fix claim avatar loading\n\n### Problem\nAfter implementing the claim feature (.5–.8), two bugs were found:\n1. **Avatar only appeared after page refresh**, not immediately after claiming — the Hypergoat indexer has latency before it indexes new comments, so `refetchComments()` returned stale data.\n2. **Avatar image never loaded (only fallback letter \"S\" shown)** — two causes:\n a. `session.avatar` was undefined (OAuth profile fetch failed silently during login)\n b. `crossOrigin = \"anonymous\"` on the HTMLImageElement caused CORS rejection from Bluesky CDN (`cdn.bsky.app`), triggering `onerror` and permanently caching the image as \"failed\"\n\n### Fixes applied\n\n#### 1. Optimistic claim display (`app/page.tsx`)\n- Added `optimisticClaims` state: `Map<string, { avatar?: string; handle: string }>`\n- `handleClaimTask` immediately sets the claimant avatar in `optimisticClaims` before posting the comment\n- `claimedNodeAvatars` useMemo merges optimistic claims (priority) with comment-derived claims\n- `isNodeClaimed` check in ContextMenu now uses `claimedNodeAvatars.has()` instead of `isNodeClaimed(commentsByNode)` so \"Claim task\" button hides immediately\n- Added 3-second delayed `refetchComments()` after claiming to eventually pick up the indexed comment\n\n#### 2. Bluesky public API avatar fallback (`app/page.tsx`)\n- In `handleClaimTask`, if `session.avatar` is undefined, fetches avatar from `public.api.bsky.app/xrpc/app.bsky.actor.getProfile` using `session.did`\n- This is the same API that `useBeadsComments` uses for profile resolution\n\n#### 3. Removed crossOrigin restriction (`components/BeadsGraph.tsx`)\n- Removed `img.crossOrigin = \"anonymous\"` from `getAvatarImage()` function\n- Canvas `drawImage()` works without CORS — canvas becomes \"tainted\" (cant read pixels back) but we never need `getImageData`/`toDataURL`\n- This fixed the Bluesky CDN image loading failure\n\n### Commits\n- `877b037` Fix claim: optimistic avatar display + delayed refetch for indexer latency\n- `efd6275` Fix claim avatar: remove crossOrigin restriction, fetch avatar from Bluesky public API as fallback\n\n### Files changed\n- `app/page.tsx` — optimisticClaims state, handleClaimTask with API fallback, claimedNodeAvatars merge, isNodeClaimed check\n- `components/BeadsGraph.tsx` — removed crossOrigin from Image element\n\n### Status: DONE","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:25:55.858566+13:00","updatedAt":"2026-02-11T10:26:45.538096+13:00","closedAt":"2026-02-11T10:26:45.538096+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.10"],"dependentIds":["beads-map-z5w","beads-map-z5w.8"]}],"links":[{"source":"beads-map-3jy","target":"beads-map-21c","type":"blocks","createdAt":"2026-02-12T10:39:55.244292+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.1","type":"parent-child","createdAt":"2026-02-11T01:47:27.389228+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.10","type":"parent-child","createdAt":"2026-02-11T02:07:34.243147+13:00"},{"source":"beads-map-21c.9","target":"beads-map-21c.10","type":"blocks","createdAt":"2026-02-11T02:07:38.658953+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.11","type":"parent-child","createdAt":"2026-02-11T02:12:07.010331+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.12","type":"parent-child","createdAt":"2026-02-11T02:12:14.930729+13:00"},{"source":"beads-map-21c.11","target":"beads-map-21c.12","type":"blocks","createdAt":"2026-02-11T02:12:15.066268+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.2","type":"parent-child","createdAt":"2026-02-11T01:47:41.591486+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.3","type":"parent-child","createdAt":"2026-02-11T01:48:09.027391+13:00"},{"source":"beads-map-21c.2","target":"beads-map-21c.3","type":"blocks","createdAt":"2026-02-11T01:51:32.440174+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.4","type":"parent-child","createdAt":"2026-02-11T01:48:40.961908+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.5","type":"parent-child","createdAt":"2026-02-11T01:49:28.830802+13:00"},{"source":"beads-map-21c.2","target":"beads-map-21c.5","type":"blocks","createdAt":"2026-02-11T01:51:32.557476+13:00"},{"source":"beads-map-21c.3","target":"beads-map-21c.5","type":"blocks","createdAt":"2026-02-11T01:51:32.669716+13:00"},{"source":"beads-map-21c.4","target":"beads-map-21c.5","type":"blocks","createdAt":"2026-02-11T01:51:32.780421+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.6","type":"parent-child","createdAt":"2026-02-11T01:49:44.216474+13:00"},{"source":"beads-map-21c.5","target":"beads-map-21c.6","type":"blocks","createdAt":"2026-02-11T01:51:32.89299+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.7","type":"parent-child","createdAt":"2026-02-11T02:00:49.847169+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.8","type":"parent-child","createdAt":"2026-02-11T02:00:58.652019+13:00"},{"source":"beads-map-21c.7","target":"beads-map-21c.8","type":"blocks","createdAt":"2026-02-11T02:01:02.543151+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.9","type":"parent-child","createdAt":"2026-02-11T02:07:25.358814+13:00"},{"source":"beads-map-3jy","target":"beads-map-2fk","type":"parent-child","createdAt":"2026-02-10T23:19:22.39819+13:00"},{"source":"beads-map-gjo","target":"beads-map-2fk","type":"blocks","createdAt":"2026-02-10T23:19:28.995145+13:00"},{"source":"beads-map-3jy","target":"beads-map-2qg","type":"parent-child","createdAt":"2026-02-10T23:19:22.706041+13:00"},{"source":"beads-map-mq9","target":"beads-map-2qg","type":"blocks","createdAt":"2026-02-10T23:19:29.394542+13:00"},{"source":"beads-map-3jy","target":"beads-map-7j2","type":"parent-child","createdAt":"2026-02-10T23:19:22.316735+13:00"},{"source":"beads-map-m1o","target":"beads-map-7j2","type":"blocks","createdAt":"2026-02-10T23:19:28.909987+13:00"},{"source":"beads-map-vdg","target":"beads-map-7r6","type":"blocks","createdAt":"2026-02-12T10:39:55.410329+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.1","type":"parent-child","createdAt":"2026-02-11T11:54:21.795118+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.2","type":"parent-child","createdAt":"2026-02-11T11:54:21.923002+13:00"},{"source":"beads-map-7r6.1","target":"beads-map-7r6.2","type":"blocks","createdAt":"2026-02-11T12:12:24.073985+13:00"},{"source":"beads-map-7r6.7","target":"beads-map-7r6.2","type":"blocks","createdAt":"2026-02-11T12:12:27.830152+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.3","type":"parent-child","createdAt":"2026-02-11T11:54:22.048183+13:00"},{"source":"beads-map-7r6.1","target":"beads-map-7r6.3","type":"blocks","createdAt":"2026-02-11T12:12:12.799635+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.4","type":"parent-child","createdAt":"2026-02-11T11:54:22.174711+13:00"},{"source":"beads-map-7r6.3","target":"beads-map-7r6.4","type":"blocks","createdAt":"2026-02-11T12:12:16.524399+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.5","type":"parent-child","createdAt":"2026-02-11T11:54:22.303116+13:00"},{"source":"beads-map-7r6.3","target":"beads-map-7r6.5","type":"blocks","createdAt":"2026-02-11T12:12:20.162124+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.6","type":"parent-child","createdAt":"2026-02-11T11:54:22.428287+13:00"},{"source":"beads-map-7r6.2","target":"beads-map-7r6.6","type":"blocks","createdAt":"2026-02-11T12:12:31.588158+13:00"},{"source":"beads-map-7r6.4","target":"beads-map-7r6.6","type":"blocks","createdAt":"2026-02-11T12:12:35.542205+13:00"},{"source":"beads-map-7r6.5","target":"beads-map-7r6.6","type":"blocks","createdAt":"2026-02-11T12:12:39.650845+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.7","type":"parent-child","createdAt":"2026-02-11T11:54:22.552867+13:00"},{"source":"beads-map-7r6.1","target":"beads-map-7r6.7","type":"blocks","createdAt":"2026-02-11T12:12:09.2907+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.8","type":"parent-child","createdAt":"2026-02-11T11:54:22.675891+13:00"},{"source":"beads-map-7r6.6","target":"beads-map-7r6.8","type":"blocks","createdAt":"2026-02-11T12:12:44.251892+13:00"},{"source":"beads-map-9d3","target":"beads-map-8np","type":"blocks","createdAt":"2026-02-12T10:39:55.489578+13:00"},{"source":"beads-map-8np","target":"beads-map-8np.1","type":"parent-child","createdAt":"2026-02-12T10:33:56.34421+13:00"},{"source":"beads-map-8np","target":"beads-map-8np.2","type":"parent-child","createdAt":"2026-02-12T10:34:01.699953+13:00"},{"source":"beads-map-8np.1","target":"beads-map-8np.2","type":"blocks","createdAt":"2026-02-12T10:34:12.820355+13:00"},{"source":"beads-map-8np","target":"beads-map-8np.3","type":"parent-child","createdAt":"2026-02-12T10:34:07.490637+13:00"},{"source":"beads-map-8np.1","target":"beads-map-8np.3","type":"blocks","createdAt":"2026-02-12T10:34:12.951842+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.1","type":"parent-child","createdAt":"2026-02-12T10:50:17.43527+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.2","type":"parent-child","createdAt":"2026-02-12T10:50:27.399923+13:00"},{"source":"beads-map-8z1.1","target":"beads-map-8z1.2","type":"blocks","createdAt":"2026-02-12T10:50:56.363125+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.3","type":"parent-child","createdAt":"2026-02-12T10:50:36.643125+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.4","type":"parent-child","createdAt":"2026-02-12T10:50:48.970023+13:00"},{"source":"beads-map-8z1.1","target":"beads-map-8z1.4","type":"blocks","createdAt":"2026-02-12T10:50:56.478692+13:00"},{"source":"beads-map-8z1.2","target":"beads-map-8z1.4","type":"blocks","createdAt":"2026-02-12T10:50:56.600112+13:00"},{"source":"beads-map-8z1.3","target":"beads-map-8z1.4","type":"blocks","createdAt":"2026-02-12T10:50:56.718812+13:00"},{"source":"beads-map-9d3","target":"beads-map-9d3.2","type":"parent-child","createdAt":"2026-02-12T10:26:40.269874+13:00"},{"source":"beads-map-9d3","target":"beads-map-9d3.3","type":"parent-child","createdAt":"2026-02-12T10:26:46.062066+13:00"},{"source":"beads-map-9d3","target":"beads-map-9d3.4","type":"parent-child","createdAt":"2026-02-12T10:26:51.725102+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.1","type":"parent-child","createdAt":"2026-02-12T11:23:50.371471+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.3","type":"parent-child","createdAt":"2026-02-12T11:24:08.11827+13:00"},{"source":"beads-map-9lm.1","target":"beads-map-9lm.3","type":"blocks","createdAt":"2026-02-12T11:24:35.58504+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.4","type":"parent-child","createdAt":"2026-02-12T11:24:14.898283+13:00"},{"source":"beads-map-9lm.1","target":"beads-map-9lm.4","type":"blocks","createdAt":"2026-02-12T11:24:35.764197+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.5","type":"parent-child","createdAt":"2026-02-12T11:24:20.513717+13:00"},{"source":"beads-map-9lm.1","target":"beads-map-9lm.5","type":"blocks","createdAt":"2026-02-12T11:24:35.949509+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.6","type":"parent-child","createdAt":"2026-02-12T11:24:29.192269+13:00"},{"source":"beads-map-9lm.3","target":"beads-map-9lm.6","type":"blocks","createdAt":"2026-02-12T11:24:36.145483+13:00"},{"source":"beads-map-9lm.4","target":"beads-map-9lm.6","type":"blocks","createdAt":"2026-02-12T11:24:36.312505+13:00"},{"source":"beads-map-9lm.5","target":"beads-map-9lm.6","type":"blocks","createdAt":"2026-02-12T11:24:36.484971+13:00"},{"source":"beads-map-3jy","target":"beads-map-cvh","type":"blocks","createdAt":"2026-02-12T10:39:55.001081+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.1","type":"parent-child","createdAt":"2026-02-10T23:56:38.694406+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.2","type":"parent-child","createdAt":"2026-02-10T23:57:01.112211+13:00"},{"source":"beads-map-cvh.1","target":"beads-map-cvh.2","type":"blocks","createdAt":"2026-02-10T23:57:01.113311+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.3","type":"parent-child","createdAt":"2026-02-10T23:57:16.26232+13:00"},{"source":"beads-map-cvh.2","target":"beads-map-cvh.3","type":"blocks","createdAt":"2026-02-10T23:57:16.263416+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.4","type":"parent-child","createdAt":"2026-02-10T23:57:32.924539+13:00"},{"source":"beads-map-cvh.3","target":"beads-map-cvh.4","type":"blocks","createdAt":"2026-02-10T23:57:32.926286+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.5","type":"parent-child","createdAt":"2026-02-10T23:57:56.263692+13:00"},{"source":"beads-map-cvh.4","target":"beads-map-cvh.5","type":"blocks","createdAt":"2026-02-10T23:57:56.264726+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.6","type":"parent-child","createdAt":"2026-02-10T23:58:15.699689+13:00"},{"source":"beads-map-cvh.5","target":"beads-map-cvh.6","type":"blocks","createdAt":"2026-02-10T23:58:15.700911+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.7","type":"parent-child","createdAt":"2026-02-10T23:58:28.65065+13:00"},{"source":"beads-map-cvh.3","target":"beads-map-cvh.7","type":"blocks","createdAt":"2026-02-10T23:58:28.65195+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.8","type":"parent-child","createdAt":"2026-02-10T23:58:49.015822+13:00"},{"source":"beads-map-cvh.6","target":"beads-map-cvh.8","type":"blocks","createdAt":"2026-02-10T23:58:49.016931+13:00"},{"source":"beads-map-cvh.7","target":"beads-map-cvh.8","type":"blocks","createdAt":"2026-02-10T23:58:49.017826+13:00"},{"source":"beads-map-cvh","target":"beads-map-dyi","type":"blocks","createdAt":"2026-02-12T10:39:55.083326+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.1","type":"parent-child","createdAt":"2026-02-11T00:31:20.161533+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.2","type":"parent-child","createdAt":"2026-02-11T00:31:28.754207+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.3","type":"parent-child","createdAt":"2026-02-11T00:31:39.227376+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.4","type":"parent-child","createdAt":"2026-02-11T00:31:47.745514+13:00"},{"source":"beads-map-dyi.2","target":"beads-map-dyi.4","type":"blocks","createdAt":"2026-02-11T00:38:43.253835+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.5","type":"parent-child","createdAt":"2026-02-11T00:31:54.778714+13:00"},{"source":"beads-map-dyi.2","target":"beads-map-dyi.5","type":"blocks","createdAt":"2026-02-11T00:38:43.395175+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.6","type":"parent-child","createdAt":"2026-02-11T00:32:01.725925+13:00"},{"source":"beads-map-dyi.1","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.522633+13:00"},{"source":"beads-map-dyi.2","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.647344+13:00"},{"source":"beads-map-dyi.3","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.773371+13:00"},{"source":"beads-map-dyi.4","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.895718+13:00"},{"source":"beads-map-dyi.5","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:44.013093+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.7","type":"parent-child","createdAt":"2026-02-11T00:45:37.232842+13:00"},{"source":"beads-map-3jy","target":"beads-map-ecl","type":"parent-child","createdAt":"2026-02-10T23:19:22.476318+13:00"},{"source":"beads-map-7j2","target":"beads-map-ecl","type":"blocks","createdAt":"2026-02-10T23:19:29.07598+13:00"},{"source":"beads-map-2fk","target":"beads-map-ecl","type":"blocks","createdAt":"2026-02-10T23:19:29.155362+13:00"},{"source":"beads-map-3jy","target":"beads-map-gjo","type":"parent-child","createdAt":"2026-02-10T23:19:22.148777+13:00"},{"source":"beads-map-3jy","target":"beads-map-iyn","type":"parent-child","createdAt":"2026-02-10T23:19:22.553429+13:00"},{"source":"beads-map-ecl","target":"beads-map-iyn","type":"blocks","createdAt":"2026-02-10T23:19:29.234083+13:00"},{"source":"beads-map-3jy","target":"beads-map-m1o","type":"parent-child","createdAt":"2026-02-10T23:19:22.23277+13:00"},{"source":"beads-map-gjo","target":"beads-map-m1o","type":"blocks","createdAt":"2026-02-10T23:19:28.823723+13:00"},{"source":"beads-map-8np","target":"beads-map-mfw","type":"blocks","createdAt":"2026-02-12T10:39:55.570556+13:00"},{"source":"beads-map-vdg","target":"beads-map-mfw","type":"blocks","createdAt":"2026-02-12T10:39:55.652022+13:00"},{"source":"beads-map-mfw","target":"beads-map-mfw.1","type":"parent-child","createdAt":"2026-02-12T10:38:08.455822+13:00"},{"source":"beads-map-3jy","target":"beads-map-mq9","type":"parent-child","createdAt":"2026-02-10T23:19:22.630363+13:00"},{"source":"beads-map-iyn","target":"beads-map-mq9","type":"blocks","createdAt":"2026-02-10T23:19:29.312556+13:00"},{"source":"beads-map-dyi","target":"beads-map-vdg","type":"blocks","createdAt":"2026-02-11T01:26:33.09446+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.1","type":"parent-child","createdAt":"2026-02-11T01:24:33.395429+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.2","type":"parent-child","createdAt":"2026-02-11T01:24:55.518315+13:00"},{"source":"beads-map-vdg.1","target":"beads-map-vdg.2","type":"blocks","createdAt":"2026-02-11T01:26:28.248408+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.3","type":"parent-child","createdAt":"2026-02-11T01:25:16.083107+13:00"},{"source":"beads-map-vdg.1","target":"beads-map-vdg.3","type":"blocks","createdAt":"2026-02-11T01:26:28.371142+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.4","type":"parent-child","createdAt":"2026-02-11T01:25:35.924466+13:00"},{"source":"beads-map-vdg.1","target":"beads-map-vdg.4","type":"blocks","createdAt":"2026-02-11T01:26:28.487447+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.5","type":"parent-child","createdAt":"2026-02-11T01:26:04.1688+13:00"},{"source":"beads-map-vdg.2","target":"beads-map-vdg.5","type":"blocks","createdAt":"2026-02-11T01:26:28.611742+13:00"},{"source":"beads-map-vdg.3","target":"beads-map-vdg.5","type":"blocks","createdAt":"2026-02-11T01:26:28.725946+13:00"},{"source":"beads-map-vdg.4","target":"beads-map-vdg.5","type":"blocks","createdAt":"2026-02-11T01:26:28.84169+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.6","type":"parent-child","createdAt":"2026-02-11T01:26:12.402978+13:00"},{"source":"beads-map-vdg.5","target":"beads-map-vdg.6","type":"blocks","createdAt":"2026-02-11T01:26:28.956024+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.7","type":"parent-child","createdAt":"2026-02-11T01:36:49.289175+13:00"},{"source":"beads-map-vdg","target":"beads-map-z5w","type":"blocks","createdAt":"2026-02-12T10:39:55.328556+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.1","type":"parent-child","createdAt":"2026-02-11T09:19:10.936853+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.10","type":"parent-child","createdAt":"2026-02-11T10:26:08.914469+13:00"},{"source":"beads-map-z5w.9","target":"beads-map-z5w.10","type":"blocks","createdAt":"2026-02-11T10:26:08.915791+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.11","type":"parent-child","createdAt":"2026-02-11T10:26:37.013442+13:00"},{"source":"beads-map-z5w.10","target":"beads-map-z5w.11","type":"blocks","createdAt":"2026-02-11T10:26:37.015186+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.12","type":"parent-child","createdAt":"2026-02-11T10:47:43.377971+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.2","type":"parent-child","createdAt":"2026-02-11T09:19:41.234513+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.3","type":"parent-child","createdAt":"2026-02-11T09:20:21.370692+13:00"},{"source":"beads-map-z5w.1","target":"beads-map-z5w.3","type":"blocks","createdAt":"2026-02-11T09:20:21.372378+13:00"},{"source":"beads-map-z5w.2","target":"beads-map-z5w.3","type":"blocks","createdAt":"2026-02-11T09:20:21.374047+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.4","type":"parent-child","createdAt":"2026-02-11T09:20:31.852407+13:00"},{"source":"beads-map-z5w.3","target":"beads-map-z5w.4","type":"blocks","createdAt":"2026-02-11T09:20:31.854127+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.5","type":"parent-child","createdAt":"2026-02-11T09:47:43.133427+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.6","type":"parent-child","createdAt":"2026-02-11T09:48:05.022796+13:00"},{"source":"beads-map-z5w.5","target":"beads-map-z5w.6","type":"blocks","createdAt":"2026-02-11T09:48:05.023797+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.7","type":"parent-child","createdAt":"2026-02-11T09:48:47.292966+13:00"},{"source":"beads-map-z5w.6","target":"beads-map-z5w.7","type":"blocks","createdAt":"2026-02-11T09:48:47.301709+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.8","type":"parent-child","createdAt":"2026-02-11T09:48:59.028136+13:00"},{"source":"beads-map-z5w.7","target":"beads-map-z5w.8","type":"blocks","createdAt":"2026-02-11T09:48:59.029382+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.9","type":"parent-child","createdAt":"2026-02-11T10:25:55.859836+13:00"},{"source":"beads-map-z5w.8","target":"beads-map-z5w.9","type":"blocks","createdAt":"2026-02-11T10:25:55.860815+13:00"}]},"stats":{"total":92,"open":0,"inProgress":0,"blocked":0,"closed":92,"actionable":0,"edges":156,"prefixes":["beads-map"]}}
1
+ {"issues":[{"id":"beads-map-21c","title":"Timeline replay: scrubber bar to animate project history","description":"## Timeline Replay: scrubber bar to animate project history\n\n### Summary\nAdd a timeline replay feature that lets users watch the project's history unfold. A playback bar at the bottom-right of the graph (replacing the current floating legend hint) provides play/pause, a draggable scrubber, and speed controls. As the virtual clock advances, nodes pop into existence (at their createdAt time), show status changes (at updatedAt), and fade to closed (at closedAt). Links appear when their dependency was created.\n\n### Architecture\n\n**Data layer (`lib/timeline.ts` — new file):**\n- `TimelineEvent` type: `{ time: number, type: 'node-created'|'node-closed'|'link-created', id: string }`\n- `buildTimelineEvents(nodes, links)`: extracts all timestamped events from nodes (createdAt, closedAt) and links (createdAt), sorts chronologically, returns `{ events: TimelineEvent[], minTime: number, maxTime: number }`\n- `filterDataAtTime(allNodes, allLinks, currentTime)`: returns `{ nodes: GraphNode[], links: GraphLink[] }` containing only items visible at `currentTime`. Nodes visible when `createdAt <= currentTime`. Node status = closed if `closedAt && closedAt <= currentTime`, else original status. Links visible when both endpoints visible AND `link.createdAt <= currentTime`.\n\n**Component (`components/TimelineBar.tsx` — new file):**\n- Positioned absolute bottom-right inside BeadsGraph, replaces the floating legend hint\n- Contains: play/pause button (svg icons), horizontal range slider, current date/time label, speed toggle (1x/2x/4x)\n- `requestAnimationFrame` loop advances currentTime when playing\n- Dragging slider pauses playback and updates currentTime\n- Props: `minTime`, `maxTime`, `currentTime`, `isPlaying`, `speed`, `onTimeChange`, `onPlayPause`, `onSpeedChange`\n- Tick marks on slider for event density (optional visual enhancement)\n\n**Wiring (`app/page.tsx` + `components/BeadsGraph.tsx`):**\n- New state in page.tsx: `timelineActive: boolean`, `timelineTime: number`, `timelinePlaying: boolean`, `timelineSpeed: number`\n- New pill button in header (same style as Force/DAG/Comments pills) to toggle timeline mode\n- When timelineActive: compute filtered nodes/links via filterDataAtTime, stamp _spawnTime on newly-visible nodes, pass filtered data to BeadsGraph\n- SSE live updates still accumulate into `data` but filtered view controls what's shown\n- TimelineBar rendered inside BeadsGraph (or as overlay in graph area)\n\n**NodeDetail date format enhancement:**\n- Change formatDate() to include hour:minute — \"Feb 10, 2026 at 11:48\"\n\n**GraphLink.createdAt:**\n- Add optional `createdAt?: string` to GraphLink type\n- Populate from BeadDependency.created_at in buildGraphData()\n\n### Subject areas\n- `lib/types.ts` — GraphLink.createdAt addition\n- `lib/parse-beads.ts` — populate link createdAt\n- `lib/timeline.ts` — new file, pure functions for event extraction and time-filtering\n- `components/TimelineBar.tsx` — new component\n- `components/BeadsGraph.tsx` — render TimelineBar, replace legend hint when timeline active\n- `components/NodeDetail.tsx` — formatDate with time\n- `app/page.tsx` — state, pill button, filtering logic, wiring\n\n### Status at a point in time\nSince we only have createdAt/updatedAt/closedAt (not per-status-change history), the replay shows:\n- Before createdAt: node doesn't exist\n- Between createdAt and closedAt: node shows as \"open\" (original non-closed status)\n- At closedAt: node transitions to \"closed\" status with ripple animation\n- updatedAt: can trigger a subtle pulse to indicate activity\n\n### Speed mapping\n1x = 1 real second per calendar day of project time. 2x = 2 days/sec. 4x = 4 days/sec.\n\n### Dependency chain\n.1 (formatDate) is independent\n.2 (GraphLink.createdAt) is independent\n.3 (timeline.ts) depends on .2 (needs link createdAt)\n.4 (TimelineBar component) is independent (pure UI)\n.5 (wiring in page.tsx + BeadsGraph) depends on .2, .3, .4\n.6 (build verification) depends on .5","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T01:47:14.847191+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:13:05.329913+13:00","closed_at":"2026-02-11T02:13:05.329913+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.244292+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.1","title":"Add hour:minute to date display in NodeDetail","description":"## Add hour:minute to date display in NodeDetail\n\n### What\nChange the formatDate() function in components/NodeDetail.tsx to include hour and minute alongside the existing date.\n\n### Current code (components/NodeDetail.tsx, lines 132-144)\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n } catch {\n return dateStr;\n }\n};\n```\n\nCurrent output: \"Feb 10, 2026\"\n\n### Target output\n\"Feb 10, 2026 at 11:48\"\n\n### Implementation\nReplace the formatDate function body. Use toLocaleDateString for the date part and toLocaleTimeString for the time part:\n\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n const date = d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n const time = d.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n hour12: false,\n });\n return `${date} at ${time}`;\n } catch {\n return dateStr;\n }\n};\n```\n\n### Where it's used\nThe formatDate function is called in three places in the same file (lines 220-258):\n- `formatDate(node.createdAt)` — Created row\n- `formatDate(node.updatedAt)` — Updated row\n- `formatDate(node.closedAt)` — Closed row (conditional)\n\nAll three will automatically pick up the new format.\n\n### Files to edit\n- `components/NodeDetail.tsx` — lines 132-144, formatDate function only\n\n### Acceptance criteria\n- Date rows in NodeDetail show \"Feb 10, 2026 at 11:48\" format\n- Hours use 24h format (no AM/PM) for compactness\n- No other files changed\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:47:27.387689+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:53:53.448072+13:00","closed_at":"2026-02-11T01:53:53.448072+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.1","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:27.389228+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.10","title":"Build verify and push timeline link/preamble/speed fix","description":"## Build verify and push\n\nRun pnpm build, fix any type errors, commit and push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.10\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline: links with both nodes, empty preamble, 2s per event (beads-map-21c.9)\"\ngit push\n```\n\n### Edge cases\n- Link between two nodes that appear on the same step — link should appear immediately\n- Preamble (step -1) shows empty canvas, then step 0 shows first event\n- Scrubbing slider to step 0 shows first event (not preamble)\n- Speed change during playback — interval restarts correctly\n- Toggle off during preamble — clears state\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:07:34.241545+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:09:44.108526+13:00","closed_at":"2026-02-11T02:09:44.108526+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:34.243147+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c.9","type":"blocks","created_at":"2026-02-11T02:07:38.658953+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.11","title":"Fix timeline replay links: normalize source/target to string IDs before diff/merge","description":"## Fix timeline replay links: normalize source/target to string IDs\n\n### Bug\nLinks during timeline replay appear but are NOT connected to their nodes — they float/draw to wrong positions.\n\n### Root cause\nreact-force-graph-2d mutates link objects in-place, replacing link.source and link.target from string IDs to object references pointing to actual node objects in the simulation.\n\nWhen filterDataAtTime() is called with data.graphData.links, those links already have mutated source/target (object refs pointing to the MAIN graph's node objects). These mutated links flow through mergeBeadsData() and get passed to BeadsGraph as timeline data. ForceGraph2D sees already-resolved object references and uses them directly — but they point to the WRONG node objects (main graph nodes, not timeline nodes). Links draw to invisible ghost positions.\n\n### Fix\nIn filterDataAtTime() in lib/timeline.ts, line 126, normalize source/target back to string IDs when pushing links into the result:\n\nCurrent code (lib/timeline.ts, lines 111-128):\n```typescript\nfor (const link of allLinks) {\n const src =\n typeof link.source === \"object\"\n ? (link.source as { id: string }).id\n : link.source;\n const tgt =\n typeof link.target === \"object\"\n ? (link.target as { id: string }).id\n : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n links.push(link); // <-- BUG: pushes mutated link with object refs\n}\n```\n\nFix — replace the last line:\n```typescript\n links.push({\n ...link,\n source: src,\n target: tgt,\n });\n```\n\nThe src and tgt variables are already extracted as string IDs (lines 112-118). By spreading a new link object with string source/target, d3-force will resolve them to the correct node objects in the timeline's node array.\n\n### Files to edit\n- lib/timeline.ts — line 126: replace links.push(link) with links.push({ ...link, source: src, target: tgt })\n\n### Acceptance criteria\n- During timeline replay, links visually connect to their nodes\n- Links draw correctly at every step, including first appearance and after scrubbing\n- pnpm build passes","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:12:07.008838+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:13:05.073793+13:00","closed_at":"2026-02-11T02:13:05.073793+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.11","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:07.010331+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.12","title":"Build verify and push timeline link connection fix","description":"## Build verify and push\n\npnpm build, close tasks, sync, commit, push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.11\nbd close beads-map-21c.12\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline links: normalize source/target to string IDs (beads-map-21c.11)\"\ngit push\n```\n\n### Acceptance criteria\n- pnpm build passes\n- git status clean after push","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:12:14.928323+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:13:05.202556+13:00","closed_at":"2026-02-11T02:13:05.202556+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:14.930729+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c.11","type":"blocks","created_at":"2026-02-11T02:12:15.066268+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.2","title":"Add createdAt field to GraphLink type and populate from dependency data","description":"## Add createdAt to GraphLink\n\n### What\nGraphLink currently has no timestamp. Add an optional createdAt field and populate it from BeadDependency.created_at so links can be time-filtered in the timeline replay.\n\n### Current GraphLink type (lib/types.ts, lines 70-78)\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 1: lib/types.ts\nAdd `createdAt?: string;` to GraphLink, after `type` and before `_spawnTime`:\n\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n createdAt?: string; // <-- ADD THIS: ISO 8601 from BeadDependency.created_at\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 2: lib/parse-beads.ts\nIn buildGraphData(), the link mapping (lines 161-175) currently drops created_at:\n\n```typescript\nconst links: GraphLink[] = dependencies\n .filter(\n (d) =>\n (d.type === \"blocks\" || d.type === \"parent-child\") &&\n issueMap.has(d.issue_id) &&\n issueMap.has(d.depends_on_id)\n )\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n }));\n```\n\nAdd `createdAt: d.created_at,` to the .map() return object:\n\n```typescript\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n createdAt: d.created_at, // <-- ADD THIS\n }));\n```\n\n### Files to edit\n- `lib/types.ts` — add createdAt to GraphLink interface\n- `lib/parse-beads.ts` — add createdAt to link mapping in buildGraphData()\n\n### What NOT to change\n- Do NOT change BeadDependency type (it already has created_at)\n- Do NOT change diff-beads.ts (link diffing uses linkKey which only considers source/target/type)\n- Do NOT change mergeBeadsData in page.tsx (it spreads link objects, so createdAt will be preserved)\n\n### Acceptance criteria\n- GraphLink.createdAt is optional string type\n- Links built from JSONL data carry their dependency creation timestamp\n- pnpm build passes\n- No runtime behavior changes (createdAt is informational until timeline feature uses it)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:47:41.589951+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:53:53.622113+13:00","closed_at":"2026-02-11T01:53:53.622113+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.2","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:41.591486+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.3","title":"Build timeline event extraction and time-filter logic (lib/timeline.ts)","description":"## Build timeline.ts: event extraction and time-filtering\n\n### What\nCreate a new file lib/timeline.ts with pure functions for:\n1. Extracting a sorted list of temporal events from graph data\n2. Filtering nodes/links to only show what exists at a given point in time\n\n### New file: lib/timeline.ts\n\n```typescript\nimport type { GraphNode, GraphLink } from \"./types\";\n\n// --- Types ---\n\nexport type TimelineEventType = \"node-created\" | \"node-closed\" | \"link-created\";\n\nexport interface TimelineEvent {\n time: number; // unix ms\n type: TimelineEventType;\n id: string; // node ID or link key (source->target)\n}\n\nexport interface TimelineRange {\n events: TimelineEvent[];\n minTime: number; // earliest event (unix ms)\n maxTime: number; // latest event (unix ms)\n}\n\n// --- Event extraction ---\n\n/**\n * Extract all temporal events from nodes and links, sorted chronologically.\n *\n * Events:\n * - node-created: from node.createdAt\n * - node-closed: from node.closedAt (if present)\n * - link-created: from link.createdAt (if present)\n *\n * Nodes/links missing timestamps are skipped.\n * Returns { events, minTime, maxTime }.\n */\nexport function buildTimelineEvents(\n nodes: GraphNode[],\n links: GraphLink[]\n): TimelineRange {\n const events: TimelineEvent[] = [];\n\n for (const node of nodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (!isNaN(createdMs)) {\n events.push({ time: createdMs, type: \"node-created\", id: node.id });\n }\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs)) {\n events.push({ time: closedMs, type: \"node-closed\", id: node.id });\n }\n }\n }\n\n for (const link of links) {\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs)) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n events.push({ time: linkMs, type: \"link-created\", id: `${src}->${tgt}` });\n }\n }\n }\n\n events.sort((a, b) => a.time - b.time);\n\n const times = events.map(e => e.time);\n const minTime = times.length > 0 ? times[0] : Date.now();\n const maxTime = times.length > 0 ? times[times.length - 1] : Date.now();\n\n return { events, minTime, maxTime };\n}\n\n// --- Time filtering ---\n\n/**\n * Filter nodes and links to only include items visible at `currentTime`.\n *\n * Node visibility: createdAt <= currentTime (parsed as Date).\n * Node status override: if closedAt && closedAt <= currentTime, force status to \"closed\".\n * Link visibility: both source and target nodes are visible AND link.createdAt <= currentTime.\n * If link has no createdAt, it appears when both endpoints are visible.\n *\n * Returns shallow copies of node objects with status potentially overridden.\n * Does NOT mutate input arrays.\n */\nexport function filterDataAtTime(\n allNodes: GraphNode[],\n allLinks: GraphLink[],\n currentTime: number\n): { nodes: GraphNode[]; links: GraphLink[] } {\n // Filter visible nodes\n const visibleNodeIds = new Set<string>();\n const nodes: GraphNode[] = [];\n\n for (const node of allNodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (isNaN(createdMs) || createdMs > currentTime) continue;\n\n visibleNodeIds.add(node.id);\n\n // Check if node should show as closed at this time\n let status = node.status;\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs) && closedMs <= currentTime) {\n status = \"closed\";\n } else if (node.status === \"closed\") {\n // Node is closed in current data but we're before closedAt — show as open\n status = \"open\";\n }\n } else if (node.status === \"closed\") {\n // Closed but no closedAt timestamp — show as closed always (legacy data)\n status = \"closed\";\n }\n\n // Shallow copy with potentially overridden status\n if (status !== node.status) {\n nodes.push({ ...node, status } as GraphNode);\n } else {\n nodes.push(node);\n }\n }\n\n // Filter visible links\n const links: GraphLink[] = [];\n for (const link of allLinks) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n // If link has createdAt, check it\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n }\n\n links.push(link);\n }\n\n return { nodes, links };\n}\n```\n\n### Key design decisions\n- Pure functions, no React, no side effects — easy to test\n- filterDataAtTime returns shallow copies when status is overridden, original objects when not (preserves x/y positions from force simulation)\n- Link source/target can be string or object (force-graph mutates these) — handle both\n- Links without createdAt appear as soon as both endpoints are visible (graceful fallback)\n- For nodes that are \"closed\" in current data but we're scrubbing to before closedAt, we show them as \"open\"\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt must exist in the type)\n\n### Files to create\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- buildTimelineEvents extracts events from nodes and links, sorted by time\n- filterDataAtTime correctly shows only nodes/links that exist at a given time\n- Closed nodes appear as \"open\" when scrubbing before their closedAt\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:48:09.026383+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:54:26.759387+13:00","closed_at":"2026-02-11T01:54:26.759387+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:09.027391+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.440174+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.4","title":"Create TimelineBar component with play/pause, scrubber, speed controls","description":"## TimelineBar component\n\n### What\nA horizontal playback bar positioned at the bottom-right of the graph area. Replaces the current floating legend hint when timeline mode is active. Contains play/pause, a scrubber slider, date/time display, and speed toggle.\n\n### Layout & positioning\nThe TimelineBar replaces the existing floating legend hint (currently at bottom-4 right-4 z-10 in BeadsGraph.tsx lines 1227-1234):\n```tsx\n{!selectedNode && !hoveredNode && (\n <div className=\"absolute bottom-4 right-4 z-10 text-xs text-zinc-400 bg-white/90 ...\">\n Node size = dependency importance | Color = status | Ring = project\n </div>\n)}\n```\n\nThe TimelineBar should be rendered in the same position: `absolute bottom-4 right-4 z-10` with similar styling (`bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm`). It should NOT overlap with the minimap (bottom-4 left-4, 160x120px).\n\n### New file: components/TimelineBar.tsx\n\nProps interface:\n```typescript\ninterface TimelineBarProps {\n minTime: number; // earliest event timestamp (unix ms)\n maxTime: number; // latest event timestamp (unix ms)\n currentTime: number; // current playback position (unix ms)\n isPlaying: boolean;\n speed: number; // 1, 2, or 4\n onTimeChange: (time: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\n### Visual design\n```\n┌──────────────────────────────────────────────────────────┐\n│ ▶ ──────────────●────────────────── Feb 10, 2026 2x │\n└──────────────────────────────────────────────────────────┘\n```\n\nElements left to right:\n1. **Play/Pause button**: SVG icon, toggle between play (triangle) and pause (two bars). Size: w-6 h-6. Color: emerald-500 when playing, zinc-500 when paused.\n2. **Scrubber slider**: HTML `<input type=\"range\">` styled with Tailwind. Min=minTime, max=maxTime, value=currentTime, step=1. Full width (flex-1). Track: h-1 bg-zinc-200 rounded. Thumb: w-3 h-3 bg-emerald-500 rounded-full. Filled portion: emerald-500.\n3. **Current date/time label**: Shows the date at the scrubber position. Format: \"Feb 10, 2026\" (compact). Font: text-xs text-zinc-500 font-medium. Fixed width to prevent layout shift (~100px).\n4. **Speed button**: Cycles through 1x -> 2x -> 4x -> 1x on click. Shows current speed as text. Font: text-xs font-medium. Color: emerald-500 background pill when not 1x, zinc border when 1x. Style: same pill as layout buttons.\n\n### Styling\n- Container: `bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2`\n- Width: auto-sized, roughly 300-400px, using `min-w-[300px] max-w-[480px]`\n- Height: compact, ~40px\n- Flex row layout: `flex items-center gap-2`\n- On mobile (sm:hidden for the full bar, show just play/pause + date)\n\n### Range input custom styling\nUse CSS in globals.css or inline styles to customize the range slider:\n```css\n/* In globals.css */\n.timeline-slider::-webkit-slider-track {\n height: 4px;\n background: #e4e4e7; /* zinc-200 */\n border-radius: 2px;\n}\n.timeline-slider::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n background: #10b981; /* emerald-500 */\n border-radius: 50%;\n margin-top: -4px;\n cursor: pointer;\n}\n```\n\n### Date formatting\n```typescript\nfunction formatTimelineDate(ms: number): string {\n const d = new Date(ms);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n}\n```\n\n### Play/Pause SVG icons\nPlay icon (triangle pointing right):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M4 2l10 6-10 6V2z\" />\n</svg>\n```\n\nPause icon (two vertical bars):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <rect x=\"3\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n <rect x=\"9.5\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n</svg>\n```\n\n### Interaction behavior\n- Dragging the slider calls onTimeChange(newTime) continuously\n- The component does NOT manage the rAF playback loop — that lives in page.tsx\n- Clicking speed cycles: 1 -> 2 -> 4 -> 1\n- The component is purely controlled (all state via props)\n\n### Files to create\n- `components/TimelineBar.tsx`\n\n### Files to edit\n- `app/globals.css` — add .timeline-slider custom range input styles\n\n### Acceptance criteria\n- TimelineBar renders play/pause button, slider, date label, speed toggle\n- Slider responds to drag, calls onTimeChange\n- Play/pause button calls onPlayPause\n- Speed button cycles through 1x/2x/4x\n- Matches existing UI style (white/90, backdrop-blur, rounded-lg, zinc borders)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:48:40.960221+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:53:53.797119+13:00","closed_at":"2026-02-11T01:53:53.797119+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.4","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:40.961908+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.5","title":"Wire timeline into page.tsx and BeadsGraph: state, filtering, animations, pill button","description":"## Wire timeline into page.tsx and BeadsGraph\n\n### What\nThis is the integration task. Connect the timeline data layer (lib/timeline.ts), the TimelineBar component, and the existing graph rendering. Add a pill button to toggle timeline mode, manage playback state, and filter nodes/links based on the virtual clock.\n\n### Overview of changes\n\n**page.tsx** — Add state, pill button, rAF playback loop, filtering, and TimelineBar wiring\n**BeadsGraph.tsx** — Accept optional timeline props, conditionally render TimelineBar instead of legend hint\n\n---\n\n### Change 1: page.tsx — New imports\n\nAdd at top of file:\n```typescript\nimport { buildTimelineEvents, filterDataAtTime } from \"@/lib/timeline\";\nimport type { TimelineRange } from \"@/lib/timeline\";\nimport TimelineBar from \"@/components/TimelineBar\";\n```\n\n### Change 2: page.tsx — New state variables\n\nAdd after existing state declarations (around line 190):\n```typescript\n// Timeline replay state\nconst [timelineActive, setTimelineActive] = useState(false);\nconst [timelineTime, setTimelineTime] = useState(0); // current virtual clock (unix ms)\nconst [timelinePlaying, setTimelinePlaying] = useState(false);\nconst [timelineSpeed, setTimelineSpeed] = useState(1); // 1x, 2x, 4x\n```\n\n### Change 3: page.tsx — Compute timeline range\n\nAdd a useMemo that computes the timeline event range from the full data. This MUST be computed from the full (unfiltered) data set:\n\n```typescript\nconst timelineRange = useMemo<TimelineRange | null>(() => {\n if (!data) return null;\n return buildTimelineEvents(data.graphData.nodes, data.graphData.links);\n}, [data]);\n```\n\n### Change 4: page.tsx — Initialize timelineTime when activating\n\nWhen timeline mode is activated, set timelineTime to minTime:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next && timelineRange) {\n setTimelineTime(timelineRange.minTime);\n setTimelinePlaying(false);\n }\n if (!next) {\n setTimelinePlaying(false);\n }\n return next;\n });\n}, [timelineRange]);\n```\n\n### Change 5: page.tsx — rAF playback loop\n\nAdd a useEffect that advances timelineTime when playing. Speed mapping: 1x = 1 real second advances 1 calendar day of project time. So:\n- msPerFrame = (1000/60) * speed * (86400000 / 1000) = speed * 1440000 / 60 = speed * 24000 per frame at 60fps\n\nActually simpler: track last rAF timestamp, compute real elapsed ms, multiply by speed factor:\n- 1x: 1 real second = 1 day (86400000ms) of project time -> factor = 86400\n- 2x: factor = 172800\n- 4x: factor = 345600\n\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n\n let rafId: number;\n let lastTs: number | null = null;\n const factor = timelineSpeed * 86400; // 1 real ms = factor project ms\n\n function tick(ts: number) {\n if (lastTs !== null) {\n const realElapsed = ts - lastTs;\n setTimelineTime(prev => {\n const next = prev + realElapsed * factor;\n if (next >= timelineRange!.maxTime) {\n setTimelinePlaying(false);\n return timelineRange!.maxTime;\n }\n return next;\n });\n }\n lastTs = ts;\n rafId = requestAnimationFrame(tick);\n }\n\n rafId = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(rafId);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\n### Change 6: page.tsx — Filter data for timeline mode\n\nCompute the filtered nodes/links using a useMemo. This is what gets passed to BeadsGraph when timeline is active:\n\n```typescript\nconst timelineFilteredData = useMemo(() => {\n if (!timelineActive || !data) return null;\n return filterDataAtTime(\n data.graphData.nodes,\n data.graphData.links,\n timelineTime\n );\n}, [timelineActive, data, timelineTime]);\n```\n\n### Change 7: page.tsx — Stamp _spawnTime on newly visible nodes\n\nTo get pop-in animations as nodes appear during playback, track previously visible node IDs and stamp _spawnTime on new ones:\n\n```typescript\nconst prevTimelineNodeIdsRef = useRef<Set<string>>(new Set());\n\nconst timelineNodes = useMemo(() => {\n if (!timelineFilteredData) return null;\n const prevIds = prevTimelineNodeIdsRef.current;\n const now = Date.now();\n const nodes = timelineFilteredData.nodes.map(node => {\n if (!prevIds.has(node.id)) {\n return { ...node, _spawnTime: now } as GraphNode;\n }\n return node;\n });\n // Update prev set for next frame\n prevTimelineNodeIdsRef.current = new Set(timelineFilteredData.nodes.map(n => n.id));\n return nodes;\n}, [timelineFilteredData]);\n\nconst timelineLinks = useMemo(() => {\n if (!timelineFilteredData) return null;\n return timelineFilteredData.links;\n}, [timelineFilteredData]);\n```\n\n**IMPORTANT**: This runs in useMemo which is a pure computation. The ref update inside useMemo is a known pattern but impure. An alternative: use useEffect to update the ref. Choose whichever approach doesn't cause visual glitches. If useMemo causes double-stamping in StrictMode, move to useEffect with a separate state.\n\n### Change 8: page.tsx — Pass filtered or full data to BeadsGraph\n\nCurrently (line 863-864):\n```tsx\n<BeadsGraph\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n```\n\nChange to:\n```tsx\n<BeadsGraph\n nodes={timelineActive && timelineNodes ? timelineNodes : data.graphData.nodes}\n links={timelineActive && timelineLinks ? timelineLinks : data.graphData.links}\n```\n\n### Change 9: page.tsx — Timeline pill button in header\n\nAdd a pill button next to the Comments pill (before the `<span className=\"w-px h-4 bg-zinc-200\" />` separator before AuthButton). Same styling as Comments/layout pills:\n\n```tsx\n<span className=\"w-px h-4 bg-zinc-200\" />\n{/* Timeline pill */}\n<div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\">\n <button\n onClick={handleTimelineToggle}\n className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n timelineActive\n ? \"bg-emerald-500 text-white\"\n : \"text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n >\n <span className=\"flex items-center gap-1.5\">\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n {/* Clock/replay icon */}\n <circle cx=\"8\" cy=\"8\" r=\"6\" />\n <polyline points=\"8,4 8,8 11,10\" />\n </svg>\n <span className=\"hidden sm:inline\">Replay</span>\n </span>\n </button>\n</div>\n```\n\nPlace this BEFORE the Comments pill separator.\n\n### Change 10: page.tsx — Render TimelineBar\n\nAdd TimelineBar inside the graph area div (after BeadsGraph, before the closing </div> of the graph area). It should render only when timeline is active:\n\n```tsx\n{timelineActive && timelineRange && (\n <div className=\"absolute bottom-4 right-4 z-10\">\n <TimelineBar\n minTime={timelineRange.minTime}\n maxTime={timelineRange.maxTime}\n currentTime={timelineTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onTimeChange={setTimelineTime}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n />\n </div>\n)}\n```\n\n### Change 11: BeadsGraph.tsx — Hide legend hint when timeline is active\n\nAdd a new prop to BeadsGraphProps:\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n timelineActive?: boolean; // <-- ADD THIS\n}\n```\n\nChange the legend hint conditional (lines 1227-1234) from:\n```tsx\n{!selectedNode && !hoveredNode && (\n```\nto:\n```tsx\n{!selectedNode && !hoveredNode && !timelineActiveRef.current && (\n```\n\nAdd a ref for timelineActive (same pattern as selectedNodeRef etc):\n```typescript\nconst timelineActiveRef = useRef(false);\nuseEffect(() => { timelineActiveRef.current = timelineActive ?? false; }, [timelineActive]);\n```\n\nWait — actually the legend hint is in the JSX return, not in paintNode, so we can use the prop directly:\n```tsx\n{!selectedNode && !hoveredNode && !props.timelineActive && (\n```\n\nBut BeadsGraph destructures props at the top. Add `timelineActive` to the destructured props and use it directly in the JSX conditional. No ref needed for this since it's in JSX, not in a useCallback.\n\n### Change 12: page.tsx — Pass timelineActive to BeadsGraph\n\n```tsx\n<BeadsGraph\n ...\n timelineActive={timelineActive} // <-- ADD THIS\n/>\n```\n\n### Summary of files to edit\n- `app/page.tsx` — state, imports, memos, pill button, TimelineBar render, data filtering\n- `components/BeadsGraph.tsx` — timelineActive prop, hide legend when active\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt)\n- beads-map-21c.3 (lib/timeline.ts)\n- beads-map-21c.4 (TimelineBar component)\n\n### Acceptance criteria\n- \"Replay\" pill button in header toggles timeline mode\n- When active, graph shows only nodes/links that exist at the virtual clock time\n- Pressing play animates nodes appearing over time with pop-in animations\n- Scrubbing the slider immediately updates visible nodes\n- Speed toggle cycles 1x/2x/4x\n- Legend hint hidden when timeline is active (TimelineBar replaces it)\n- When timeline is deactivated, full live data is shown again\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:49:28.829389+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:56:10.694555+13:00","closed_at":"2026-02-11T01:56:10.694555+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:28.830802+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.557476+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.3","type":"blocks","created_at":"2026-02-11T01:51:32.669716+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.4","type":"blocks","created_at":"2026-02-11T01:51:32.780421+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.6","title":"Build verification, edge cases, and polish for timeline replay","description":"## Build verification and polish\n\n### What\nFinal task: verify the build passes, test edge cases, and polish any rough edges.\n\n### Build gate\n```bash\npnpm build\n```\nMust pass with zero errors. If there are type errors, fix them.\n\n### Edge cases to verify\n\n1. **Empty graph**: If no nodes have timestamps, timeline should gracefully handle minTime === maxTime (slider disabled or shows single point)\n2. **Single node**: Timeline with one node should still work (slider shows one point in time)\n3. **All nodes already closed**: Scrubbing to maxTime should show all nodes as closed\n4. **Scrubbing backward**: Moving slider left should remove nodes (they should just disappear, no exit animation needed for scrub-back — only forward playback gets spawn animations)\n5. **Rapid scrubbing**: Fast slider movement should not cause performance issues. The filterDataAtTime function should be fast (O(n) where n = total nodes + links)\n6. **Toggle off during playback**: Turning off timeline while playing should stop playback and restore full data\n7. **Node selection during timeline**: Clicking a node during timeline playback should work normally (open NodeDetail sidebar)\n8. **Comments during timeline**: Comment badges should still work on visible nodes (commentedNodeIds filtering still applies)\n\n### Polish items\n- Ensure TimelineBar doesn't overlap with minimap on small screens\n- Verify the rAF loop cleans up properly on unmount\n- Check that prevTimelineNodeIdsRef resets when timeline is deactivated\n- Verify nodes retain their force-simulation positions when switching between timeline and live mode (x/y should be preserved since we're using the same node objects from data.graphData.nodes)\n\n### Stale .next cache\nIf you see module resolution errors, run:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Files potentially needing fixes\n- `app/page.tsx`\n- `components/BeadsGraph.tsx`\n- `components/TimelineBar.tsx`\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- All edge cases handled gracefully\n- No console errors during timeline playback\n- Clean git status after commit","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:49:44.214947+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:56:40.00756+13:00","closed_at":"2026-02-11T01:56:40.00756+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:44.216474+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c.5","type":"blocks","created_at":"2026-02-11T01:51:32.89299+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.7","title":"Rewrite timeline to event-step playback using diff/merge pipeline","description":"## Rewrite timeline to event-step playback using diff/merge pipeline\n\n### Problem\nTwo bugs in the current timeline replay:\n1. **Too fast**: rAF loop maps real time to project time (1 sec = 1 day), so months of history play in seconds\n2. **Graph mess**: Timeline bypasses the diff/merge pipeline, so nodes appear without x/y positions and the force simulation doesn't organize them. Links and nodes are disconnected.\n\n### Root cause\nThe current timeline creates a parallel data path: filterDataAtTime() returns raw nodes (no x/y), stamps _spawnTime manually in useMemo, and passes them directly to BeadsGraph. This skips:\n- mergeBeadsData() which preserves x/y from old nodes and places new ones near neighbors\n- diffBeadsData() which detects added/removed/changed for proper animation stamps\n- The force simulation reheat that react-force-graph does when graphData changes\n\n### Fix: event-step model + diff/merge pipeline\n\n**Playback model change:**\n- Replace continuous time scrubber with discrete event steps\n- Each step = one event from the sorted events array\n- Playback advances one step every 5 seconds (at 1x), giving force simulation time to settle\n- Speed: 1x = 5s/step, 2x = 2.5s/step, 4x = 1.25s/step\n- Slider maps to step index (0 to events.length-1), not unix timestamps\n\n**Data pipeline change:**\n- Maintain `timelineData: BeadsApiResponse | null` state (the \"current timeline snapshot\")\n- On each step change:\n 1. Get timestamp from events[step].time\n 2. Call filterDataAtTime(allNodes, allLinks, timestamp) to get visible nodes/links\n 3. Wrap as BeadsApiResponse-shaped object\n 4. Call diffBeadsData(prevTimelineData, newSnapshot) to get the diff\n 5. Call mergeBeadsData(prevTimelineData, newSnapshot, diff) to get positioned + animated data\n 6. Set timelineData = merged result\n- Pass timelineData.graphData.nodes/.links to BeadsGraph\n- Force simulation naturally reheats when the node/link arrays change\n\n### Files to edit\n\n**app/page.tsx** — The big one. Replace the entire timeline section (lines ~196-410):\n\nState changes:\n- REMOVE: timelineTime (unix ms)\n- REMOVE: prevTimelineNodeIdsRef\n- ADD: timelineStep (number, 0-based index into events array)\n- ADD: timelineData (BeadsApiResponse | null)\n\nRemove these memos/computations:\n- timelineFilteredData useMemo\n- timelineNodes useMemo\n- timelineLinks useMemo\n\nReplace rAF playback loop with setInterval:\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n const intervalMs = 5000 / timelineSpeed;\n const interval = setInterval(() => {\n setTimelineStep(prev => {\n const next = prev + 1;\n if (next >= timelineRange.events.length) {\n setTimelinePlaying(false);\n return prev;\n }\n return next;\n });\n }, intervalMs);\n return () => clearInterval(interval);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\nAdd effect to compute timelineData when step changes:\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange || timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n\n const filtered = filterDataAtTime(data.graphData.nodes, data.graphData.links, event.time);\n const newSnapshot: BeadsApiResponse = {\n ...data,\n graphData: { nodes: filtered.nodes, links: filtered.links },\n };\n\n setTimelineData(prev => {\n if (!prev) return newSnapshot; // first frame — no merge needed\n const diff = diffBeadsData(prev, newSnapshot);\n if (!diff.hasChanges) return prev;\n return mergeBeadsData(prev, newSnapshot, diff);\n });\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChange BeadsGraph props:\n```tsx\nnodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}\nlinks={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}\n```\n\nChange TimelineBar props:\n```tsx\n<TimelineBar\n totalSteps={timelineRange.events.length}\n currentStep={timelineStep}\n currentTime={timelineRange.events[timelineStep]?.time ?? timelineRange.minTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onStepChange={setTimelineStep}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n/>\n```\n\nUpdate handleTimelineToggle:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next) {\n setTimelineStep(0);\n setTimelinePlaying(false);\n setTimelineData(null);\n } else {\n setTimelinePlaying(false);\n setTimelineData(null);\n }\n return next;\n });\n}, []);\n```\n\n**components/TimelineBar.tsx** — Change from time-based to step-based props:\n\nNew props:\n```typescript\ninterface TimelineBarProps {\n totalSteps: number; // events.length\n currentStep: number; // 0-based index\n currentTime: number; // unix ms of current event (for date display)\n isPlaying: boolean;\n speed: number; // 1, 2, 4\n onStepChange: (step: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\nSlider: min=0, max=totalSteps-1, value=currentStep, onChange calls onStepChange\nDate label: formatTimelineDate(currentTime)\nAdd step counter: \"3 / 47\" next to date\nhasRange = totalSteps > 1\n\n**lib/timeline.ts** — No changes needed. buildTimelineEvents and filterDataAtTime work as-is.\n\n**components/BeadsGraph.tsx** — No changes needed. Force simulation reheats naturally.\n\n### Depends on\nNothing new — this replaces parts of beads-map-21c.5\n\n### Acceptance criteria\n- Playing timeline advances one event at a time, 5 seconds between events at 1x\n- 2x = 2.5s between events, 4x = 1.25s between events\n- Nodes appear with pop-in animation and get properly positioned by force simulation\n- Links connect to their nodes correctly\n- Scrubbing the slider jumps between event steps\n- Graph layout matches the active layout mode (Force or DAG)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:00:49.845818+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:03:04.087128+13:00","closed_at":"2026-02-11T02:03:04.087128+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.7","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:49.847169+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.8","title":"Build verify and push timeline event-step rewrite","description":"## Build verify and push\n\nRun pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\npnpm build\ngit add -A\ngit commit -m \"Rewrite timeline to event-step playback with diff/merge pipeline (beads-map-21c.7)\"\nbd sync\ngit push\n```\n\n### Edge cases to check\n- Empty events array (no timestamps) — slider disabled, play disabled\n- Single event — slider shows one point\n- Scrubbing backward — nodes removed via diff/merge exit animation\n- Toggle off during playback — stops interval, clears timelineData\n- Speed change during playback — interval restarts with new timing\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:00:58.650469+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:03:27.972704+13:00","closed_at":"2026-02-11T02:03:27.972704+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:58.652019+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c.7","type":"blocks","created_at":"2026-02-11T02:01:02.543151+13:00","created_by":"daviddao"}]},{"id":"beads-map-21c.9","title":"Fix timeline: links appear with both nodes, empty preamble, 2s per event","description":"## Fix timeline: links appear with both nodes, empty preamble, 2s per event\n\n### Three issues to fix\n\n#### Issue 1: Links don't appear when both nodes are visible\n**Root cause:** filterDataAtTime() in lib/timeline.ts lines 148-151 checks link.createdAt independently:\n```typescript\nif (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n}\n```\nEven when both endpoints are on canvas, the link is hidden until its own timestamp.\n\n**Fix in lib/timeline.ts:** Remove the link.createdAt check entirely (lines 147-151). A link should appear the moment both endpoints are visible. The visibleNodeIds check on line 145 is sufficient:\n```typescript\n// Both endpoints must be visible\nif (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n// REMOVE the link.createdAt check below this\n```\n\nAlso remove link-created events from buildTimelineEvents() (lines 54-73) since link timing is now derived from node visibility, not link timestamps. This simplifies the event list to just node-created and node-closed.\n\n#### Issue 2: Zoom crash into first node on play\n**Root cause:** BeadsGraph.tsx line 432-440 has a zoomToFit effect:\n```typescript\nuseEffect(() => {\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n}, [nodes.length]);\n```\nWhen timeline starts at step 0 (1 node), this zooms to fit that single node = extreme zoom in.\n\n**Fix — two parts:**\n\n**Part A: Prevent zoomToFit during timeline mode.**\nThe timelineActive prop is already passed to BeadsGraph. Use it to skip the zoomToFit:\n```typescript\nuseEffect(() => {\n if (timelineActive) return; // skip during timeline replay\n if (graphRef.current && nodes.length > 0) {\n ...\n }\n}, [nodes.length, timelineActive]);\n```\n\n**Part B: Add 2-second empty preamble before first event.**\nIn the playback setInterval in page.tsx, when play starts and timelineStep is -1 (a new \"preamble\" step), show zero nodes for 2 seconds, then advance to step 0.\n\nImplementation approach: Use step index -1 as the preamble. When timeline is activated or play starts from the beginning, set step to -1. The effect that computes timelineData should check: if step === -1, set timelineData to an empty snapshot (no nodes, no links). The setInterval advances from -1 to 0, then 0 to 1, etc.\n\nChanges in app/page.tsx:\n- handleTimelineToggle: setTimelineStep(-1) instead of 0\n- The setInterval already does prev + 1, so -1 + 1 = 0 (first real event). Works naturally.\n- The timelineData effect: add check for timelineStep === -1 -> empty snapshot\n- TimelineBar: totalSteps stays as events.length (preamble is \"step -1\", not counted in steps)\n- TimelineBar slider: min stays 0, but current step shows as 0 when preamble is active\n\nChanges in page.tsx effect that computes timelineData (lines 367-389):\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange) return;\n \n // Preamble step: empty canvas\n if (timelineStep === -1) {\n setTimelineData({\n ...data,\n graphData: { nodes: [], links: [] },\n });\n return;\n }\n \n if (timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n // ... rest of diff/merge logic\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChanges in page.tsx TimelineBar rendering:\n```tsx\ncurrentStep={Math.max(timelineStep, 0)}\ncurrentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}\n```\n\n#### Issue 3: 5 seconds per event is too slow\n**Fix in app/page.tsx line 353:** Change 5000 to 2000:\n```typescript\nconst intervalMs = 2000 / timelineSpeed;\n```\nThis gives 2s per event at 1x, 1s at 2x, 0.5s at 4x.\n\n### Files to edit\n- lib/timeline.ts — remove link.createdAt check in filterDataAtTime, remove link-created events from buildTimelineEvents\n- app/page.tsx — step -1 preamble, 2s interval, TimelineBar prop adjustments \n- components/BeadsGraph.tsx — skip zoomToFit during timeline mode\n\n### Also remove link-created from TimelineEventType\nSince links no longer have their own timeline events, simplify:\n- TimelineEventType becomes \"node-created\" | \"node-closed\" (remove \"link-created\")\n- buildTimelineEvents() removes the link loop (lines 54-73)\n\n### Acceptance criteria\n- Links appear the instant both connected nodes are on canvas\n- Pressing play shows empty canvas for 2 seconds (preamble), then first node appears\n- Each event takes 2 seconds at 1x speed\n- No zoom-crash into a single node when timeline starts\n- Scrubbing slider still works (slider min=0, preamble is before slider range)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T02:07:25.357205+13:00","created_by":"daviddao","updated_at":"2026-02-11T02:09:24.013992+13:00","closed_at":"2026-02-11T02:09:24.013992+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-21c.9","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:25.358814+13:00","created_by":"daviddao"}]},{"id":"beads-map-2fk","title":"Create lib/diff-beads.ts — diff engine for nodes and links","description":"Create a new file: lib/diff-beads.ts\n\nPURPOSE: Compare two BeadsApiResponse objects and identify what changed — which nodes/links were added, removed, or modified. The diff output drives animation metadata stamping in the merge logic (task .5).\n\nINTERFACE:\n\n```typescript\nimport type { BeadsApiResponse, GraphNode, GraphLink } from \"./types\";\n\nexport interface NodeChange {\n field: string; // e.g. \"status\", \"priority\", \"title\"\n from: string; // previous value (stringified)\n to: string; // new value (stringified)\n}\n\nexport interface BeadsDiff {\n addedNodeIds: Set<string>; // IDs of nodes not in old data\n removedNodeIds: Set<string>; // IDs of nodes not in new data\n changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes\n addedLinkKeys: Set<string>; // \"source->target:type\" keys\n removedLinkKeys: Set<string>; // \"source->target:type\" keys\n hasChanges: boolean; // true if anything changed at all\n}\n\n/**\n * Build a stable key for a link.\n * Links may have string or object source/target (after force-graph mutation).\n */\nexport function linkKey(link: GraphLink): string;\n\n/**\n * Compute the diff between old and new beads data.\n * Compares nodes by ID and links by source->target:type key.\n */\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff;\n```\n\nIMPLEMENTATION:\n\n```typescript\nexport function linkKey(link: GraphLink): string {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n return `${src}->${tgt}:${link.type}`;\n}\n\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff {\n // If no old data, everything is \"added\"\n if (!oldData) {\n return {\n addedNodeIds: new Set(newData.graphData.nodes.map(n => n.id)),\n removedNodeIds: new Set(),\n changedNodes: new Map(),\n addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),\n removedLinkKeys: new Set(),\n hasChanges: true,\n };\n }\n\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const newNodeMap = new Map(newData.graphData.nodes.map(n => [n.id, n]));\n\n // Node diffs\n const addedNodeIds = new Set<string>();\n const removedNodeIds = new Set<string>();\n const changedNodes = new Map<string, NodeChange[]>();\n\n for (const [id, node] of newNodeMap) {\n if (!oldNodeMap.has(id)) {\n addedNodeIds.add(id);\n } else {\n const old = oldNodeMap.get(id)!;\n const changes: NodeChange[] = [];\n if (old.status !== node.status) {\n changes.push({ field: \"status\", from: old.status, to: node.status });\n }\n if (old.priority !== node.priority) {\n changes.push({ field: \"priority\", from: String(old.priority), to: String(node.priority) });\n }\n if (old.title !== node.title) {\n changes.push({ field: \"title\", from: old.title, to: node.title });\n }\n if (changes.length > 0) {\n changedNodes.set(id, changes);\n }\n }\n }\n for (const id of oldNodeMap.keys()) {\n if (!newNodeMap.has(id)) {\n removedNodeIds.add(id);\n }\n }\n\n // Link diffs\n const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));\n const newLinkKeys = new Set(newData.graphData.links.map(linkKey));\n\n const addedLinkKeys = new Set<string>();\n const removedLinkKeys = new Set<string>();\n\n for (const key of newLinkKeys) {\n if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);\n }\n for (const key of oldLinkKeys) {\n if (!newLinkKeys.has(key)) removedLinkKeys.add(key);\n }\n\n const hasChanges =\n addedNodeIds.size > 0 ||\n removedNodeIds.size > 0 ||\n changedNodes.size > 0 ||\n addedLinkKeys.size > 0 ||\n removedLinkKeys.size > 0;\n\n return { addedNodeIds, removedNodeIds, changedNodes, addedLinkKeys, removedLinkKeys, hasChanges };\n}\n```\n\nWHY linkKey() HANDLES OBJECTS:\nreact-force-graph-2d mutates link.source and link.target from string IDs to node objects during simulation. When we compare old links (which have been mutated) against new links (which have string IDs from the server), we need to handle both cases.\n\nDEPENDS ON: task .1 (animation timestamp types in GraphNode/GraphLink)\n\nACCEPTANCE CRITERIA:\n- lib/diff-beads.ts exports diffBeadsData and linkKey\n- Correctly identifies added/removed/changed nodes\n- Correctly identifies added/removed links\n- Handles null oldData (initial load — everything is \"added\")\n- Handles object-form source/target in links (post-simulation mutation)\n- hasChanges is false when data is identical\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:16:20.792858+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:25:49.501958+13:00","closed_at":"2026-02-10T23:25:49.501958+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.39819+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.995145+13:00","created_by":"daviddao"}]},{"id":"beads-map-2qg","title":"Integration testing — live update end-to-end verification","description":"Final verification that the live update system works end-to-end with all animations.\n\nSETUP:\n Terminal 1: cd to any beads project (e.g. ~/Projects/gainforest/gainforest-beads)\n Terminal 2: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev (in beads-map)\n Browser: http://localhost:3000\n\nTEST MATRIX:\n\n1. NEW NODE (bd create):\n Terminal 1: bd create --title \"Live test node\" --priority 2\n Browser: Within ~300ms, a new node should POP IN with:\n - Scale animation from 0 to 1 (bouncy easeOutBack)\n - Brief green glow ring around it\n - Node placed near its connected neighbors (or at random if standalone)\n - Force simulation gently incorporates it into the layout\n VERIFY: No graph position reset, existing nodes stay where they are\n\n2. STATUS CHANGE (bd update):\n Terminal 1: bd update <id-from-step-1> --status in_progress\n Browser: The node should show:\n - Expanding ripple ring in amber (in_progress color)\n - Node body color transitions from emerald (open) to amber\n - Ripple fades out over ~800ms\n VERIFY: No position change, other nodes unaffected\n\n3. NEW LINK (bd link):\n Terminal 1: bd link <id1> blocks <id2>\n Browser: A new link should appear:\n - Fades in over 500ms\n - Brief emerald flash along the link path (300ms)\n - Starts thicker, settles to normal width\n - Flow particles appear on it\n VERIFY: Both endpoints stay in position\n\n4. CLOSE ISSUE (bd close):\n Terminal 1: bd close <id-from-step-1>\n Browser: The node should SHRINK OUT:\n - Scale animation from 1 to 0 (400ms)\n - Opacity fades to 0\n - Connected links also fade out\n - After ~600ms, the ghost node/links are removed from the array\n VERIFY: Stats update (total count decreases)\n\n5. RAPID CHANGES (debounce test):\n Terminal 1: for i in 1 2 3 4 5; do bd create --title \"Rapid $i\" --priority 3; done\n Browser: Nodes should NOT pop in one-by-one with 300ms delays. They should all appear in a single batch after the debounce settles (~300ms after the last command).\n VERIFY: All 5 nodes spawn simultaneously with pop-in animations\n\n6. MULTI-REPO (if using gainforest-beads hub):\n Terminal 1: cd ../audiogoat && bd create --title \"Cross-repo test\" --priority 3\n Browser: The new audiogoat node should appear in the graph\n VERIFY: Node has audiogoat prefix color ring\n\n7. RECONNECTION:\n Stop and restart the dev server.\n Browser: EventSource should auto-reconnect and load fresh data.\n VERIFY: No stale data, no duplicate nodes\n\n8. EPIC COLLAPSE VIEW:\n Switch to \"Epics\" view mode, then create a child task.\n Terminal 1: bd create --title \"Child of epic\" --priority 2 --parent <epic-id>\n Browser: In Epics mode, the parent epic node should update:\n - Child count badge increments\n - Epic node briefly flashes (change animation)\n - No child node appears (it's collapsed)\n Switch to Full mode: child node should be visible (already in data)\n\n9. BUILD CHECK:\n pnpm build — must pass with zero errors\n\n10. CLEANUP:\n Delete test issues: bd delete <id> for each test issue created\n Browser: Nodes shrink out on deletion\n\nFUNCTIONAL CHECKS:\n- Force/DAG layout toggle still works during/after animations\n- Full/Epics toggle still works\n- Search still finds nodes (including newly spawned ones)\n- Minimap updates with new nodes\n- Click node -> sidebar shows correct data (including newly added nodes)\n- Header stats update in real-time (issue count, dep count, project count)\n- No memory leaks (EventSource properly cleaned up on page navigation)\n- No console errors during any test\n\nPERFORMANCE CHECKS:\n- Animation frame rate stays smooth (60fps) during spawn/exit\n- No jitter or \"graph explosion\" when new data merges\n- File watcher doesn't cause excessive CPU usage during idle\n\nDEPENDS ON: All previous tasks (.1-.7) must be complete\n\nACCEPTANCE CRITERIA:\n- All 10 test scenarios pass\n- All functional checks pass\n- All performance checks pass\n- pnpm build clean\n- No console errors","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:18:51.905378+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:40:49.415522+13:00","closed_at":"2026-02-10T23:40:49.415522+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.706041+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-mq9","type":"blocks","created_at":"2026-02-10T23:19:29.394542+13:00","created_by":"daviddao"}]},{"id":"beads-map-2u2","title":"Unify all prefix colors to Catppuccin palette (rings, clusters, tooltip)","description":"## What (retroactive — already done)\n\nUpdated all prefix-colored elements in the app to consistently use the Catppuccin accent palette instead of the old FNV-hash HSL colors. This ensures visual consistency: the node outer ring, cluster background circles (zoomed-out view), and hover tooltip accent bar all use the same deterministic Catppuccin color for a given prefix.\n\n## Commits\n- 31ae0c7 — Match cluster circle color to Catppuccin prefix palette when in prefix color mode\n- 13e5bc8 — Use Catppuccin prefix colors for node rings, cluster circles, and tooltip accent bar\n\n## Changes\n\n### components/BeadsGraph.tsx\n- **getPrefixColor → getPrefixRingColor**: Renamed the module-level function and changed it to call \\`getCatppuccinPrefixColor(node.prefix)\\` instead of looking up \\`PREFIX_COLORS[node.prefix]\\` (old FNV-hash HSL).\n- **Cluster circle color** (line ~1393 in paintClusterLabels): Changed from \\`PREFIX_COLORS[cluster.prefix]\\` to \\`getCatppuccinPrefixColor(cluster.prefix)\\`. Always uses Catppuccin regardless of color mode since clusters always represent projects.\n- **Removed PREFIX_COLORS import** from BeadsGraph.tsx (no longer needed there).\n\n### app/page.tsx\n- **Tooltip accent bar** (line ~1388): Changed from \\`getPrefixColor(nodeTooltip.node.prefix)\\` to \\`getCatppuccinPrefixColor(nodeTooltip.node.prefix)\\`.\n- Updated import: replaced \\`getPrefixColor\\` with \\`getCatppuccinPrefixColor\\` from \\`@/lib/types\\`.\n\n## Result\nAll prefix-colored elements now use the same 14-color Catppuccin palette via \\`getCatppuccinPrefixColor()\\`, ensuring a given project prefix always maps to the same color across rings, clusters, and tooltips.","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T14:22:02.396229+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:22:50.425852+13:00","closed_at":"2026-02-12T14:22:50.425852+13:00","close_reason":"Closed"},{"id":"beads-map-3jy","title":"Live updates via SSE with animated node/link transitions","description":"Add real-time live updates to beads-map so that when .beads/issues.jsonl changes on disk (via bd create, bd close, bd link, bd update, etc.), the graph automatically updates with smooth animations — new nodes pop in, removed nodes shrink out, status changes flash, and new links fade in.\n\nARCHITECTURE:\n- Server: New SSE endpoint (/api/beads/stream) watches all JSONL files with fs.watch()\n- Server: On file change, re-parses all data and pushes the full dataset over SSE\n- Client: EventSource in page.tsx receives updates, diffs against current state\n- Client: Diff metadata (added/removed/changed) drives animations in paintNode/paintLink\n- Animations: spawn pop-in (easeOutBack), exit shrink-out, status change ripple, link fade-in\n\nKEY DESIGN DECISIONS:\n1. SSE over polling: true push, instant updates, no wasted requests\n2. Full data push (not incremental diffs): simpler, avoids sync issues, JSONL files are small\n3. Debounce 300ms: bd often writes multiple times per command (flush + sync)\n4. Position preservation: merge new data while keeping existing node x/y/fx/fy positions\n5. Animation via timestamps: stamp _spawnTime/_removeTime/_changedAt on items, animate in paintNode/paintLink based on elapsed time\n\nFILES TO CREATE:\n- lib/watch-beads.ts — file watcher utility wrapping fs.watch with debounce\n- lib/diff-beads.ts — diff engine comparing old vs new BeadsApiResponse\n- app/api/beads/stream/route.ts — SSE endpoint\n\nFILES TO MODIFY:\n- lib/parse-beads.ts — export getAdditionalRepoPaths (currently private)\n- lib/types.ts — add animation timestamp fields to GraphNode/GraphLink\n- app/page.tsx — replace one-shot fetch with EventSource + merge logic\n- components/BeadsGraph.tsx — spawn/exit/change animations in paintNode + paintLink\n\nDEPENDENCY CHAIN:\n.1 (types + parse-beads exports) → .2 (watch-beads.ts) → .3 (SSE endpoint) → .5 (page.tsx EventSource)\n.1 → .4 (diff-beads.ts) → .5\n.5 → .6 (paintNode animations) → .7 (paintLink animations) → .8 (integration test)","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-10T23:14:54.798302+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:40:49.541566+13:00","closed_at":"2026-02-10T23:40:49.541566+13:00","close_reason":"Closed"},{"id":"beads-map-3pg","title":"v0.3.3: Auto-fit toggle and top-right layout reorganization","description":"## Overview\n\nAdd a toggle button that enables/disables automatic camera zoom-to-fit after graph data updates. Currently, every SSE data update and layout switch triggers `graphRef.current.zoomToFit(400, 60)`, which snaps the camera away from whatever the user was looking at. This is confusing when inspecting a specific node.\n\nThe toggle lives in the top-right corner of the graph canvas. The top-right area is also reorganized from a single row to a **stacked vertical layout** (flex-col) so the new auto-fit toggle and the existing ActivityOverlay sit in separate rows.\n\n## Problem\n\nTwo `zoomToFit` call sites in `components/BeadsGraph.tsx` fire automatically:\n\n1. **Line 838** — `useEffect([layoutMode, viewNodes, viewLinks])`: Fires on layout mode changes AND on every data update (because `viewNodes`/`viewLinks` change when SSE pushes new data). Waits 600ms (or 1000ms on first apply) then calls `graphRef.current.zoomToFit(400, 60)`.\n2. **Line 865** — `useEffect([nodes.length, timelineActive])`: Fires when `nodes.length` changes (new nodes arrive via SSE) or `timelineActive` toggles. Already skipped during timeline replay.\n\n**Both calls should be gated** by an `autoFit` boolean prop. When `autoFit` is false, the camera never moves automatically — the user has full manual control.\n\n## Design decisions\n\n- **Default: ON** — `autoFit` starts as `true` to preserve current behavior. Users who want to explore manually toggle it off.\n- **Gates ALL zoomToFit** — Even layout switches (Force→DAG→Radial) do NOT auto-zoom when toggle is off. User asked for full manual control.\n- **Button placement: top-right** with ActivityOverlay, not top-left with layout buttons. This separates \"graph shape controls\" (top-left) from \"view/camera controls\" (top-right).\n- **Button style: Pattern C** (cluster labels toggle from BeadsGraph.tsx lines 1985-2008). Frosted glass pill when inactive, emerald background when active. Uses a crosshairs/frame icon.\n- **Stacked layout**: The `<div className=\"absolute top-3 right-3 ...\">` wrapper in `page.tsx` (line 1346) changes from wrapping only ActivityOverlay to a `flex flex-col items-end gap-2` container holding both the auto-fit toggle (always visible) and ActivityOverlay (conditionally visible).\n\n## Files\n\n### Modified files\n- `components/BeadsGraph.tsx` — Accept `autoFit?: boolean` prop, gate both `zoomToFit` calls\n- `app/page.tsx` — Add `autoFit` state, pass prop, reorganize top-right layout, render toggle button\n\n### No new files needed\nThe toggle button is small enough to inline in `page.tsx` within the top-right container.\n\n## Current state (as of commit e834d07)\n\n- `BeadsGraphProps` interface: lines 29-55 of `BeadsGraph.tsx`\n- zoomToFit call #1: line 838 of `BeadsGraph.tsx`, deps `[layoutMode, viewNodes, viewLinks]`\n- zoomToFit call #2: line 865 of `BeadsGraph.tsx`, deps `[nodes.length, timelineActive]`\n- ActivityOverlay wrapper: lines 1344-1363 of `page.tsx`, `<div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10\">`\n- State declarations area: around lines 265-315 of `page.tsx`\n- BeadsGraph rendering: lines 1302-1323 of `page.tsx`\n- Best toggle pattern to follow: cluster labels toggle at lines 1985-2008 of `BeadsGraph.tsx` (emerald active, frosted glass inactive)","notes":"## Plan change (during session)\n\nOriginally placed auto-fit toggle in top-right with ActivityOverlay. Changed to top-left because:\n1. Top-right controls are hidden when HelpPanel is open (`!helpPanelOpen` condition in page.tsx line 1345)\n2. During tutorial, HelpPanel IS open, so the top-right area would be invisible — spotlight target not found\n3. Moving to top-left (inside BeadsGraph.tsx, always rendered) eliminates this problem entirely\n\nSuperseded tasks:\n- beads-map-3pg.4 (top-right layout) → closed, replaced by beads-map-3pg.7 (top-left two-row layout)\n- beads-map-3pg.6 (tutorial with visibility hack) → closed, replaced by beads-map-3pg.8 (simple tutorial step)","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-12T16:07:56.23929+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:19:07.97407+13:00","closed_at":"2026-02-12T16:19:07.97407+13:00","close_reason":"Closed"},{"id":"beads-map-3pg.1","title":"Add autoFit prop to BeadsGraphProps interface","description":"## What\n\nAdd `autoFit?: boolean` to the `BeadsGraphProps` interface in `components/BeadsGraph.tsx`.\n\n## Where\n\nFile: `components/BeadsGraph.tsx`\nLines 29-55: `interface BeadsGraphProps { ... }`\n\n## Exact change\n\nAfter line 54 (`onColorModeChange?: (mode: ColorMode) => void;`), add:\n\n```typescript\n /** Whether to auto-zoom to fit all nodes after data updates and layout changes */\n autoFit?: boolean;\n```\n\nThen in the component destructuring (find `const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>((props, ref) => {` or similar), add `autoFit = true` to the destructured props with a default of `true`.\n\n## Acceptance criteria\n- `BeadsGraphProps` includes `autoFit?: boolean`\n- Component destructures it with default `true`\n- No other behavioral changes yet (that is task .2)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:08:05.402809+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:16:00.71156+13:00","closed_at":"2026-02-12T16:16:00.71156+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-3pg.1","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:08:05.404061+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.2","title":"Gate both zoomToFit calls with autoFit prop","description":"## What\n\nWrap both `zoomToFit` calls in `components/BeadsGraph.tsx` with `if (autoFit)` guards so the camera stays fixed when the user has disabled auto-fit.\n\n## Where\n\nFile: `components/BeadsGraph.tsx`\n\n### Call site 1: Layout mode / data change effect (line 838)\n\nCurrent code (lines 835-844):\n```typescript\n // Fit to view after layout settles\n const delay = initialLayoutApplied.current ? 600 : 1000;\n const timer = setTimeout(() => {\n if (graphRef.current) graphRef.current.zoomToFit(400, 60);\n }, delay);\n\n initialLayoutApplied.current = true;\n\n return () => clearTimeout(timer);\n }, [layoutMode, viewNodes, viewLinks]);\n```\n\nChange to:\n```typescript\n // Fit to view after layout settles (only if auto-fit is enabled)\n let timer: ReturnType<typeof setTimeout> | undefined;\n if (autoFit) {\n const delay = initialLayoutApplied.current ? 600 : 1000;\n timer = setTimeout(() => {\n if (graphRef.current) graphRef.current.zoomToFit(400, 60);\n }, delay);\n }\n\n initialLayoutApplied.current = true;\n\n return () => { if (timer) clearTimeout(timer); };\n }, [layoutMode, viewNodes, viewLinks, autoFit]);\n```\n\n**IMPORTANT**: Add `autoFit` to the dependency array since the effect reads it.\n\n### Call site 2: Initial load / node count change effect (line 865)\n\nCurrent code (lines 860-869):\n```typescript\n // Fit to view on initial load (skip during timeline replay)\n useEffect(() => {\n if (timelineActive) return;\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n }, [nodes.length, timelineActive]);\n```\n\nChange to:\n```typescript\n // Fit to view on initial load (skip during timeline replay or when auto-fit disabled)\n useEffect(() => {\n if (timelineActive) return;\n if (!autoFit) return;\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n }, [nodes.length, timelineActive, autoFit]);\n```\n\n**IMPORTANT**: Add `autoFit` to the dependency array.\n\n## Edge cases\n\n- When `autoFit` toggles from false→true, the effect will re-run. This is desirable — re-enabling auto-fit should immediately zoom to fit as feedback that it is working.\n- The bootstrap trick (lines 849-858) that switches from DAG→Force on initial load will still trigger the layout effect, but the `autoFit` guard will skip the zoom. The layout change itself (force configuration) still applies — only the camera zoom is suppressed.\n- `d3ReheatSimulation()` (line 833) still fires regardless of `autoFit` — the simulation reheat is separate from the camera zoom.\n\n## Acceptance criteria\n- With `autoFit={true}` (default): behavior is identical to current (both zoom calls fire)\n- With `autoFit={false}`: neither `zoomToFit` call fires; camera stays wherever user left it\n- Toggling `autoFit` from false→true triggers an immediate zoom-to-fit\n- `autoFit` is in both dependency arrays","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:08:31.186009+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:16:33.773936+13:00","closed_at":"2026-02-12T16:16:33.773936+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-3pg.2","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:08:31.187092+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.2","depends_on_id":"beads-map-3pg.1","type":"blocks","created_at":"2026-02-12T16:08:31.188462+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.3","title":"Add autoFit state to page.tsx and pass to BeadsGraph","description":"## What\n\nAdd `autoFit` state in `app/page.tsx` and pass it as a prop to `<BeadsGraph>`.\n\n## Where\n\nFile: `app/page.tsx`\n\n### 1. Add state declaration\n\nNear lines 306-311 (after the timeline state block, before `graphRef`), add:\n\n```typescript\n // Auto-fit: when true, graph auto-zooms to fit after data updates and layout changes\n const [autoFit, setAutoFit] = useState(true);\n```\n\n### 2. Pass prop to BeadsGraph\n\nAt lines 1302-1323 where `<BeadsGraph>` is rendered, add the `autoFit` prop:\n\n```tsx\n <BeadsGraph\n ref={graphRef}\n nodes={...}\n links={...}\n ...\n colorMode={colorMode}\n onColorModeChange={setColorMode}\n autoFit={autoFit} // <-- ADD THIS\n />\n```\n\n## Acceptance criteria\n- `autoFit` state exists in page.tsx, defaults to `true`\n- `setAutoFit` setter is available for the toggle button (task .4)\n- `<BeadsGraph>` receives `autoFit` prop\n- No TypeScript errors (prop already declared in task .1)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:08:40.195056+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:16:33.902562+13:00","closed_at":"2026-02-12T16:16:33.902562+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-3pg.3","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:08:40.196547+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.3","depends_on_id":"beads-map-3pg.1","type":"blocks","created_at":"2026-02-12T16:08:40.197777+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.4","title":"Reorganize top-right layout into stacked flex-col with auto-fit toggle button","description":"## What\n\nReplace the single-row top-right layout in `app/page.tsx` with a stacked vertical layout (flex-col) containing:\n1. **Row 1**: Auto-fit toggle button (always visible when no sidebar is open)\n2. **Row 2**: ActivityOverlay (conditionally visible, same conditions as before)\n\nAlso add the auto-fit toggle button itself, styled following Pattern C (cluster labels toggle).\n\n## Where\n\nFile: `app/page.tsx`\n\n### Current code (lines 1344-1363):\n\n```tsx\n {/* Activity overlay — top-right of canvas */}\n {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n <div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10\">\n <ActivityOverlay\n events={activityFeed}\n collapsed={activityOverlayCollapsed}\n onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}\n onExpandPanel={() => {\n setActivityPanelOpen(true);\n setSelectedNode(null);\n setAllCommentsPanelOpen(false);\n setHelpPanelOpen(false);\n }}\n onNodeClick={(nodeId) => {\n const node = data?.graphData.nodes.find((n) => n.id === nodeId);\n if (node) focusNode(node);\n }}\n />\n </div>\n )}\n```\n\n### Replace with:\n\n```tsx\n {/* Top-right controls — stacked vertically */}\n {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n <div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10 flex flex-col items-end gap-2\">\n {/* Auto-fit toggle */}\n <button\n onClick={() => setAutoFit((v) => !v)}\n 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 ${\n autoFit\n ? \"bg-emerald-500 text-white border-emerald-500\"\n : \"bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n title={autoFit ? \"Auto-fit enabled: camera adjusts after updates\" : \"Auto-fit disabled: camera stays fixed\"}\n >\n {/* Crosshairs/frame icon */}\n <svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n <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\" />\n <circle cx=\"12\" cy=\"12\" r=\"3\" />\n </svg>\n <span className=\"hidden sm:inline\">Auto-fit</span>\n </button>\n\n {/* Activity overlay */}\n <ActivityOverlay\n events={activityFeed}\n collapsed={activityOverlayCollapsed}\n onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}\n onExpandPanel={() => {\n setActivityPanelOpen(true);\n setSelectedNode(null);\n setAllCommentsPanelOpen(false);\n setHelpPanelOpen(false);\n }}\n onNodeClick={(nodeId) => {\n const node = data?.graphData.nodes.find((n) => n.id === nodeId);\n if (node) focusNode(node);\n }}\n />\n </div>\n )}\n```\n\n## Design details\n\n### Button styling (matches Pattern C — cluster labels toggle)\n- **Active (autoFit=true)**: `bg-emerald-500 text-white border-emerald-500` — solid emerald pill\n- **Inactive (autoFit=false)**: `bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50` — frosted glass pill\n- **Shared**: `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`\n- **Icon**: Crosshairs/frame SVG (`w-3.5 h-3.5`) — four corner brackets with a small circle in the center, suggests \"framing\" or \"fitting to view\"\n- **Label**: `<span className=\"hidden sm:inline\">Auto-fit</span>` — hidden on mobile, visible on sm+\n- **Title tooltip**: Explains current state on hover\n\n### Layout structure\n- Outer container: `flex flex-col items-end gap-2` — items right-aligned, 8px vertical gap\n- Auto-fit button: top row, always rendered within the container\n- ActivityOverlay: bottom row, no longer wrapped in its own `<div>` since the parent provides positioning\n- Both share the same visibility condition (hidden when any sidebar/timeline is active)\n\n### Icon SVG\nThe icon is a \"viewfinder\" / \"frame\" symbol:\n- Four corner brackets (top-left, top-right, bottom-left, bottom-right) drawn as L-shaped paths\n- A small circle in the center (crosshair target)\n- This communicates \"zoom to fit frame\" intuitively\n\n## Acceptance criteria\n- Top-right area shows auto-fit toggle above ActivityOverlay in a vertical stack\n- Toggle button is emerald when active, frosted glass when inactive\n- Clicking toggles `autoFit` state\n- ActivityOverlay retains all existing behavior (collapse, expand panel, node click)\n- On mobile (< sm), only the icon shows (label hidden)\n- When any sidebar or timeline is active, both controls are hidden (same as before)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:09:08.244968+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:14:02.363721+13:00","closed_at":"2026-02-12T16:14:02.363721+13:00","close_reason":"Superseded: moving auto-fit toggle to top-left instead of top-right","dependencies":[{"issue_id":"beads-map-3pg.4","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:09:08.246591+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.4","depends_on_id":"beads-map-3pg.3","type":"blocks","created_at":"2026-02-12T16:09:08.248185+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.5","title":"Build verify, bd sync, and push auto-fit toggle feature","description":"## What\n\nRun `pnpm build` to verify zero errors. If build fails:\n\n1. **Type errors**: Fix them in the relevant file\n2. **PageNotFoundError or Cannot find module**: Run `rm -rf .next node_modules/.cache && sleep 1 && pnpm build`\n3. Re-run build until clean\n\nThen commit and push:\n\n```bash\ngit add -A\ngit commit -m \"Add auto-fit toggle and reorganize top-right layout\"\nbd sync\ngit push\ngit status # MUST show \"up to date with origin\"\n```\n\n## Acceptance criteria\n- `pnpm build` passes with zero errors\n- All changes committed and pushed to origin/main\n- `git status` shows clean working tree, up to date with remote\n- `bd close beads-map-3pg` (epic auto-closes when all children closed)","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:09:17.522053+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:19:07.889301+13:00","closed_at":"2026-02-12T16:19:07.889301+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:09:17.523491+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.2","type":"blocks","created_at":"2026-02-12T16:09:17.525027+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.4","type":"blocks","created_at":"2026-02-12T16:09:17.527425+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.7","type":"blocks","created_at":"2026-02-12T16:15:02.933093+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.8","type":"blocks","created_at":"2026-02-12T16:15:03.067512+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.6","title":"Add tutorial step for auto-fit toggle and update static help content","description":"## What\n\nAdd a new tutorial step (step 2, \"Camera Controls\") that spotlights the top-right controls area and explains the auto-fit toggle. Also add a bullet to the static HelpContent \"More\" section.\n\n## Challenge: visibility during tutorial\n\nThe top-right controls container is conditionally hidden when `helpPanelOpen` is true (line 1345 of `page.tsx`):\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n```\n\nSince the HelpPanel is open during the tutorial, the top-right controls will be hidden, and the spotlight target will not be found. **Fix**: Add a special exception for when the tutorial is on the camera-controls step:\n\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && (!helpPanelOpen || (tutorialStep !== null && TUTORIAL_STEPS[tutorialStep]?.target === \"top-right-controls\")) && !timelineActive && (\n```\n\nThis requires importing `TUTORIAL_STEPS` in `page.tsx` (already imported at line 22) and having access to `tutorialStep` state (already exists).\n\n## Files to modify\n\n### 1. `components/TutorialOverlay.tsx` — Add new step at index 2\n\nInsert after the \"Layout Modes\" step (current index 1), before \"Color Modes & Legend\" (current index 2):\n\n```typescript\n {\n target: \"top-right-controls\",\n title: \"Camera Controls\",\n description:\n \"The Auto-fit toggle in the top-right controls whether the camera re-centers after each update. Turn it off to stay focused on a specific area while data streams in.\",\n },\n```\n\nThis bumps all subsequent steps by 1 (now 8 total: indices 0-7).\n\n### 2. `app/page.tsx` — Two changes\n\n#### 2a. Add `data-tutorial` attribute to top-right container\n\nIn the reorganized top-right container (from task .4), add `data-tutorial=\"top-right-controls\"`:\n```tsx\n<div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10 flex flex-col items-end gap-2\" data-tutorial=\"top-right-controls\">\n```\n\n#### 2b. Fix visibility condition to show controls during this tutorial step\n\nChange the visibility condition from:\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n```\nTo:\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && (!helpPanelOpen || (tutorialStep !== null && TUTORIAL_STEPS[tutorialStep]?.target === \"top-right-controls\")) && !timelineActive && (\n```\n\n`TUTORIAL_STEPS` is already imported at line 22: `import { TutorialOverlay, TUTORIAL_STEPS } from \"@/components/TutorialOverlay\";`\n\n### 3. `components/HelpPanel.tsx` — Add bullet to static help content\n\nIn the \"More\" section (CAT.mauve, around line 300-308), add a new bullet:\n```tsx\n<Bullet color={CAT.mauve}><strong>Auto-fit</strong> &mdash; top-right toggle to lock/unlock automatic camera reframing</Bullet>\n```\n\nInsert it after the \"Minimap\" bullet (line 307) and before the \"Copy\" bullet (line 308).\n\n## Edge cases\n\n- When tutorial is on step 2 (camera controls), the top-right area must be visible even though HelpPanel is open. The exception in the visibility condition handles this.\n- The auto-fit button should be interactive during this tutorial step — user can try clicking it. Since HelpPanel is z-[60] and the overlay is z-[55], the top-right controls at z-10 would be behind the overlay. But the overlay has `pointer-events: auto` on the dark area only — the spotlight cutout lets clicks through visually. However, SVG masks are visual-only (discovery from previous session). The button will NOT be clickable through the overlay. This is fine — user just reads the description and clicks to advance.\n- Step count changes from 7 to 8. The step indicator dots and \"X of Y\" text in TutorialContent are computed dynamically from `TUTORIAL_STEPS.length`, so no hardcoded counts to update.\n- The `isLast` check (`step === TUTORIAL_STEPS.length - 1`) will automatically adjust.\n\n## Acceptance criteria\n- Tutorial has 8 steps (was 7)\n- Step 2 spotlights the top-right controls area with emerald pulsing ring\n- Top-right controls (auto-fit button + ActivityOverlay) are visible during step 2 even though HelpPanel is open\n- Static help \"More\" section includes an \"Auto-fit\" bullet\n- Step indicator shows \"3 / 8\" when on this step\n- All other tutorial steps still work correctly (targets found, descriptions unchanged)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:13:04.41715+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:13:58.69935+13:00","closed_at":"2026-02-12T16:13:58.69935+13:00","close_reason":"Superseded: no visibility hack needed since button moved to top-left (always visible)","dependencies":[{"issue_id":"beads-map-3pg.6","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:13:04.418858+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.6","depends_on_id":"beads-map-3pg.4","type":"blocks","created_at":"2026-02-12T16:13:04.420528+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.7","title":"Reorganize top-left controls into two rows with auto-fit toggle in BeadsGraph","description":"## What\n\nReorganize the top-left controls in `components/BeadsGraph.tsx` from a single horizontal row into **two rows** (stacked vertically). Add the auto-fit toggle button to the second row.\n\n**Supersedes beads-map-3pg.4** (which put the toggle in the top-right; cancelled because the top-right controls are hidden when HelpPanel is open, making tutorial spotlighting impossible).\n\n## Current layout (single row, lines 1793-2009)\n\n```\n<div className=\"absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex items-start gap-1.5 sm:gap-2\">\n [Force|DAG|Radial|Cluster|Spread] [Collapse all] [Clusters]\n</div>\n```\n\nAll 3 control groups are siblings in one `flex` row with `items-start gap-1.5`.\n\n## New layout (two rows)\n\n```\n<div className=\"absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2\">\n {/* Row 1: Layout shape controls */}\n <div className=\"flex items-start gap-1.5 sm:gap-2\" data-tutorial=\"layouts\">\n [Force|DAG|Radial|Cluster|Spread]\n </div>\n {/* Row 2: View toggles */}\n <div className=\"flex items-start gap-1.5 sm:gap-2\" data-tutorial=\"view-controls\">\n [Collapse all] [Clusters] [Auto-fit]\n </div>\n</div>\n```\n\n### Exact changes to the JSX (lines 1793-2009):\n\n1. **Outer container** (line 1793): Change `flex items-start gap-1.5 sm:gap-2` to `flex flex-col gap-1.5 sm:gap-2`\n\n2. **Row 1 wrapper**: Wrap the layout segmented button group (`data-tutorial=\"layouts\"` div, line 1795) in a new `<div className=\"flex items-start gap-1.5 sm:gap-2\">`. Move `data-tutorial=\"layouts\"` to this new wrapper (or keep it on the segmented group — either works since the spotlight will cover the same area).\n\n3. **Row 2 wrapper**: Wrap the Collapse/Expand button (lines 1941-1983) and Clusters toggle (lines 1985-2008) in a new `<div className=\"flex items-start gap-1.5 sm:gap-2\" data-tutorial=\"view-controls\">`. Add the auto-fit toggle button after Clusters.\n\n4. **Auto-fit toggle button** (new, at end of row 2):\n\n```tsx\n {/* Auto-fit: lock/unlock automatic camera reframing */}\n <button\n onClick={() => onAutoFitToggle?.()}\n 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 ${\n autoFit\n ? \"bg-emerald-500 text-white border-emerald-500\"\n : \"bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n title={autoFit ? \"Auto-fit enabled: camera adjusts after updates\" : \"Auto-fit disabled: camera stays fixed\"}\n >\n <svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n <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\" />\n <circle cx=\"12\" cy=\"12\" r=\"3\" />\n </svg>\n <span className=\"hidden sm:inline\">Auto-fit</span>\n </button>\n```\n\n### Props changes\n\nThe auto-fit toggle needs to be controlled from `page.tsx` (where state lives). Two approaches:\n\n**Option A** (callback prop): Add `onAutoFitToggle?: () => void` to `BeadsGraphProps`. Page.tsx passes `onAutoFitToggle={() => setAutoFit(v => !v)}`. BeadsGraph reads `autoFit` for visual state and calls `onAutoFitToggle` on click.\n\n**Option B** (state+setter props): Add `onAutoFitChange?: (value: boolean) => void` to `BeadsGraphProps`. \n\nUse **Option A** (simpler). Add to `BeadsGraphProps` (line 29-55):\n```typescript\n /** Callback to toggle auto-fit */\n onAutoFitToggle?: () => void;\n```\n\nAnd in the destructuring (line 241, after `onColorModeChange`):\n```typescript\n onAutoFitToggle,\n```\n\n### Wiring in page.tsx (lines 1302-1323)\n\nAdd props to `<BeadsGraph>`:\n```tsx\n autoFit={autoFit}\n onAutoFitToggle={() => setAutoFit((v) => !v)}\n```\n\n### ActivityOverlay stays in top-right\n\nThe `page.tsx` top-right area (lines 1344-1363) is **unchanged**. ActivityOverlay stays where it is. No layout changes there.\n\n## Acceptance criteria\n- Top-left controls are two rows: layout buttons on top, view toggles below\n- Auto-fit toggle appears in row 2, after Clusters button\n- Button is emerald when active, frosted glass when inactive\n- Clicking calls `onAutoFitToggle` which flips `autoFit` state in page.tsx\n- `data-tutorial=\"layouts\"` covers row 1, `data-tutorial=\"view-controls\"` covers row 2\n- On mobile (<sm), only icons show (labels hidden)\n- ActivityOverlay remains in top-right, untouched","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:14:39.400843+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:17:55.781153+13:00","closed_at":"2026-02-12T16:17:55.781153+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-3pg.7","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:14:39.402553+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.7","depends_on_id":"beads-map-3pg.1","type":"blocks","created_at":"2026-02-12T16:14:39.40454+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.7","depends_on_id":"beads-map-3pg.3","type":"blocks","created_at":"2026-02-12T16:14:39.406102+13:00","created_by":"daviddao"}]},{"id":"beads-map-3pg.8","title":"Add tutorial step for view controls and update static help content","description":"## What\n\nAdd a new tutorial step that spotlights the second row of top-left controls (`data-tutorial=\"view-controls\"`) and explains the auto-fit toggle, collapse/expand, and cluster labels. Also add an auto-fit bullet to the static help \"More\" section.\n\nSince the controls are in `BeadsGraph.tsx` (always rendered), there is NO visibility issue — no hack needed. This is why we moved the button to top-left.\n\n## Files to modify\n\n### 1. `components/TutorialOverlay.tsx` — Add new step at index 2\n\nInsert after \"Layout Modes\" (current index 1), before \"Color Modes & Legend\" (current index 2):\n\n```typescript\n {\n target: \"view-controls\",\n title: \"View Controls\",\n description:\n \"Collapse or expand epic groups, toggle cluster label overlays, and control auto-fit. When auto-fit is on (green), the camera re-centers after every update. Turn it off to stay focused on a specific area while data streams in.\",\n },\n```\n\nThis bumps all subsequent steps by 1 (now 8 total: indices 0-7).\n\n**No other changes needed in this file.** The step indicator, navigation, and isLast logic are all computed from `TUTORIAL_STEPS.length` dynamically.\n\n### 2. `components/HelpPanel.tsx` — Add bullet to static help \"More\" section\n\nIn the \"More\" section (around line 300-308, `CAT.mauve` bullets), add after the \"Minimap\" bullet (line 307):\n\n```tsx\n <Bullet color={CAT.mauve}><strong>Auto-fit</strong> &mdash; top-left toggle to lock/unlock automatic camera reframing</Bullet>\n```\n\n## Why this is simple now\n\n- `data-tutorial=\"view-controls\"` is on row 2 of the top-left controls inside `BeadsGraph.tsx`\n- `BeadsGraph.tsx` is always rendered (no conditional visibility)\n- The tutorial overlay can always find the target element\n- No z-index hacks, no visibility exceptions, no conditional rendering changes\n\n## Acceptance criteria\n- Tutorial has 8 steps (was 7)\n- Step 2 (\"View Controls\") spotlights the second row of top-left controls with emerald pulsing ring\n- Step indicator shows \"3 / 8\" for this step\n- Static help \"More\" section includes \"Auto-fit\" bullet\n- All other tutorial steps unaffected (targets still found, descriptions unchanged)\n- No changes to visibility conditions in page.tsx","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T16:14:58.678602+13:00","created_by":"daviddao","updated_at":"2026-02-12T16:18:23.584479+13:00","closed_at":"2026-02-12T16:18:23.584479+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-3pg.8","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:14:58.679502+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.8","depends_on_id":"beads-map-3pg.7","type":"blocks","created_at":"2026-02-12T16:14:58.681794+13:00","created_by":"daviddao"}]},{"id":"beads-map-3qb","title":"Filter out tombstoned issues from graph","status":"closed","priority":1,"issue_type":"bug","owner":"david@gainforest.net","created_at":"2026-02-10T23:48:26.859412+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:48:54.69868+13:00","closed_at":"2026-02-10T23:48:54.69868+13:00","close_reason":"Closed"},{"id":"beads-map-48c","title":"Show full description with markdown rendering in NodeDetail sidebar","description":"## Show full description with markdown rendering in NodeDetail sidebar\n\n### Summary\nTwo changes to the description section in `components/NodeDetail.tsx`:\n\n1. **Remove truncation** — Stop calling `truncateDescription()` so the full description text is shown. The scrollable container (`max-h-40 overflow-y-auto`) already handles long content elegantly — keep that.\n\n2. **Render markdown** — Descriptions are written in markdown (headings, code blocks, lists, links, bold/italic). Currently rendered as plain `<pre>` text. Install `react-markdown` + `remark-gfm` and render the description as formatted markdown inside the scrollable box, with appropriate typography styles for the small text size (text-xs base).\n\n### Tasks\n- .1 Install react-markdown and remark-gfm\n- .2 Remove truncation, add markdown rendering with styled prose in the scrollable box\n- .3 Build verification\n\n### Files to modify\n- `package.json` — add react-markdown, remark-gfm\n- `components/NodeDetail.tsx` — replace `<pre>{truncateDescription(...)}</pre>` with `<ReactMarkdown>` component, remove `truncateDescription` function\n- `app/globals.css` — possibly add small prose styling overrides for the description box\n\n### Key constraint\nKeep the scrollable container (`max-h-40 overflow-y-auto custom-scrollbar`) — that's good UX. Just show the full content inside it and render it as markdown instead of plain text.","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:11:03.062054+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:12:31.404312+13:00","closed_at":"2026-02-11T01:12:31.404312+13:00","close_reason":"Closed"},{"id":"beads-map-7j2","title":"Create SSE endpoint /api/beads/stream","description":"Create a new file: app/api/beads/stream/route.ts\n\nPURPOSE: Server-Sent Events endpoint that streams beads data to the client. On initial connection, sends the full dataset. Then watches all JSONL files for changes and pushes updated data whenever files change. This replaces the one-shot GET /api/beads fetch for live use.\n\nIMPLEMENTATION:\n\n```typescript\nimport { discoverBeadsDir } from \"@/lib/discover\";\nimport { loadBeadsData } from \"@/lib/parse-beads\";\nimport { watchBeadsFiles } from \"@/lib/watch-beads\";\n\n// Prevent Next.js from statically optimizing this route\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(request: Request) {\n let cleanup: (() => void) | null = null;\n\n const stream = new ReadableStream({\n start(controller) {\n const encoder = new TextEncoder();\n\n function send(data: unknown) {\n try {\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`)\n );\n } catch {\n // Stream closed — cleanup will handle\n }\n }\n\n try {\n const { beadsDir } = discoverBeadsDir();\n\n // Send initial data\n const initialData = loadBeadsData(beadsDir);\n send(initialData);\n\n // Watch for changes and push updates\n cleanup = watchBeadsFiles(beadsDir, () => {\n try {\n const newData = loadBeadsData(beadsDir);\n send(newData);\n } catch (err) {\n console.error(\"Failed to reload beads data:\", err);\n }\n });\n\n // Heartbeat every 30s to keep connection alive through proxies/firewalls\n const heartbeat = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(\": heartbeat\\n\\n\"));\n } catch {\n clearInterval(heartbeat);\n }\n }, 30000);\n\n // Clean up when client disconnects\n request.signal.addEventListener(\"abort\", () => {\n clearInterval(heartbeat);\n if (cleanup) cleanup();\n try { controller.close(); } catch { /* already closed */ }\n });\n\n } catch (err: any) {\n // Discovery failed — send error and close\n send({ error: err.message });\n controller.close();\n }\n },\n\n cancel() {\n if (cleanup) cleanup();\n },\n });\n\n return new Response(stream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\", // Disable Nginx buffering\n },\n });\n}\n```\n\nKEY DESIGN DECISIONS:\n- export const dynamic = \"force-dynamic\": tells Next.js not to statically optimize this route\n- Full data push on each change: JSONL files are small (10-100 issues), so re-parsing is fast (<5ms). Sending full data avoids incremental diff sync complexity on the server.\n- Heartbeat every 30s: prevents proxies and load balancers from closing idle connections\n- request.signal.addEventListener(\"abort\"): proper cleanup when client disconnects (browser tab close, navigation away, EventSource reconnect)\n- TextEncoder for SSE format: controller.enqueue requires Uint8Array\n- X-Accel-Buffering: no: prevents Nginx from buffering SSE responses\n\nSSE MESSAGE FORMAT:\nEach message is a complete BeadsApiResponse JSON object:\n data: {\"issues\":[...],\"dependencies\":[...],\"graphData\":{\"nodes\":[...],\"links\":[...]},\"stats\":{...}}\n\nThe client (task .5) will parse this and diff against current state.\n\nERROR HANDLING:\n- Discovery failure: sends { error: \"...\" } then closes stream\n- Parse failure during watch: logs error, does NOT close stream (transient file write state)\n- Client disconnect: cleanup function closes all watchers\n\nTESTING:\n1. Start dev server: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n2. Open in browser: http://localhost:3000/api/beads/stream\n3. You should see SSE data flowing (initial payload, then updates when JSONL changes)\n4. In another terminal: cd ~/Projects/gainforest/gainforest-beads && bd create --title \"test live\" --priority 3\n5. Within ~300ms, the SSE stream should push a new message with the updated data\n6. Ctrl+C the stream — check no watcher leaks in the server process\n\nDEPENDS ON: task .1 (types), task .2 (watch-beads.ts)\n\nACCEPTANCE CRITERIA:\n- GET /api/beads/stream returns Content-Type: text/event-stream\n- Initial data sent immediately on connection\n- Updates pushed when any watched JSONL file changes\n- Heartbeat keeps connection alive\n- Proper cleanup on client disconnect\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:15:56.921088+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:26:39.409649+13:00","closed_at":"2026-02-10T23:26:39.409649+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.316735+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-m1o","type":"blocks","created_at":"2026-02-10T23:19:28.909987+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6","title":"Activity feed: real-time + historical event log with compact overlay and expandable panel","description":"Add a comprehensive activity feed to beads-map showing both historical events and real-time updates.\n\n**Features:**\n- Historical feed from existing data (node creation/closure, links, comments, claims, likes)\n- Real-time events from SSE diffs (status/priority/title/owner changes, new comments, etc.)\n- Compact overlay (top-right) showing latest 5 events\n- Full panel (slide-in sidebar) with search and category filters\n- 13 event types with color-coded icons\n- Mutual exclusivity with other sidebars\n\n**Components created:**\n- lib/activity.ts: Event types, builders, diff-to-events converter\n- ActivityItem.tsx: Reusable event row (compact + full variants)\n- ActivityOverlay.tsx: Always-visible card with collapsible state\n- ActivityPanel.tsx: Full sidebar with search/filters\n\n**Integration:**\n- Activity pill in header navbar\n- SSE handler pipes diffs into activity feed\n- Event deduplication and 200-event cap\n","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:13.943245+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:21.787516+13:00","closed_at":"2026-02-11T12:05:23.135577+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.410329+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.1","title":"Create lib/activity.ts: ActivityEvent type and historical feed builder","description":"Create the core activity feed infrastructure in lib/activity.ts.\n\n**Implemented:**\n- ActivityEventType enum: 13 event types (node-created, node-closed, node-status-changed, node-priority-changed, node-title-changed, node-owner-changed, link-added, link-removed, comment-added, reply-added, task-claimed, task-unclaimed, like-added)\n- ActivityEvent interface: { id, type, time, nodeId, nodeTitle?, actor?, detail?, meta? }\n- ActivityActor interface: { handle, avatar?, did? }\n- ActivityFilterCategory type: \"issues\" | \"deps\" | \"comments\" | \"claims\" | \"likes\"\n- getEventCategory(): maps event types to filter categories\n- buildHistoricalFeed(nodes, links, allComments): extracts events from existing data\n- diffToActivityEvents(diff, nodes): converts real-time BeadsDiff into events\n- mergeFeedEvents(existing, incoming): deduplicates by event ID, sorts newest-first, caps at 200\n- Event ID format: \"${type}:${nodeId}:${time}\" for deduplication\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:21.793982+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:25.528565+13:00","closed_at":"2026-02-11T12:05:22.084119+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.1","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.795118+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.2","title":"Wire activity feed state in page.tsx: accumulate historical + SSE events","description":"Wire activity feed state management in app/page.tsx to accumulate historical and real-time events.\n\n**Implemented:**\n- Added state: activityFeed (ActivityEvent[]), activityPanelOpen (boolean), activityOverlayCollapsed (boolean)\n- useEffect to rebuild historical feed when data or allComments change via buildHistoricalFeed()\n- SSE onmessage handler: after computing diff, calls diffToActivityEvents() and merges into feed via mergeFeedEvents()\n- Feed accumulation with deduplication by event ID\n- Max 200 events retained (newest first)\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:21.921951+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:29.04198+13:00","closed_at":"2026-02-11T12:05:22.216634+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.923002+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:24.073985+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.7","type":"blocks","created_at":"2026-02-11T12:12:27.830152+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.3","title":"Create components/ActivityItem.tsx: single event row (compact + full variants)","description":"Create ActivityItem.tsx component for rendering individual activity events.\n\n**Implemented:**\n- Two variants: \"compact\" (single-line for overlay) and \"full\" (rich with avatar for panel)\n- Per-type SVG icons with color coding:\n - Emerald: positive actions (created, claimed, liked)\n - Amber: changes (status, priority, title, owner)\n - Red: removals (closed, link removed, unclaimed)\n - Blue: comments and replies\n- describeEvent() function: maps event types to human-readable text\n- Clickable node ID pills calling onNodeClick prop\n- Displays actor handle and avatar in full variant\n- Timestamp formatting (relative time)\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.046526+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:32.556229+13:00","closed_at":"2026-02-11T12:05:22.350628+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.048183+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:12.799635+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.4","title":"Create components/ActivityOverlay.tsx: compact always-visible top-right card","description":"Create ActivityOverlay.tsx: compact always-visible card in the top-right of the graph area.\n\n**Implemented:**\n- Position: absolute top-3 right-3 z-10 (inside graph area div)\n- Frosted glass styling: bg-white/90 backdrop-blur-sm rounded-lg border shadow-sm\n- Width: w-64 (256px)\n- Shows latest 5 events in compact variant\n- Collapsible to small pill with recent event count badge (events in last 5 min)\n- \"See all activity\" link opens full ActivityPanel\n- Hidden when: NodeDetail sidebar open, ActivityPanel open, or timeline active\n- Smooth transitions between expanded/collapsed states\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.173537+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:35.718366+13:00","closed_at":"2026-02-11T12:05:22.484041+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.174711+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:16.524399+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.5","title":"Create components/ActivityPanel.tsx: full slide-in sidebar with search and filters","description":"Create ActivityPanel.tsx: full slide-in sidebar with search and category filters.\n\n**Implemented:**\n- Layout: desktop w-[360px] absolute top-0 right-0 z-30, mobile bottom drawer\n- Search bar: filters by nodeId, title, actor handle, detail (case-insensitive substring)\n- 5 filter chips: Issues, Deps, Comments, Claims, Likes\n - All active by default, toggleable (min 1 active required)\n - Active chip styling: bg-emerald-50 text-emerald-700 border-emerald-200\n- Scrollable event list with full-variant ActivityItems\n- Footer: shows filtered event count\n- Same slide-in pattern as AllCommentsPanel\n- Close button with X icon\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.301972+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:38.561844+13:00","closed_at":"2026-02-11T12:05:22.615127+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.303116+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:20.162124+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.6","title":"Add Activity pill to header and wire overlay + panel in page.tsx","description":"Wire Activity pill button in header navbar and render ActivityOverlay + ActivityPanel.\n\n**Implemented:**\n- Added \"Activity\" pill button in header (between Comments and Auth divider)\n- Active state styling when activityPanelOpen\n- Rendered ActivityOverlay inside graph area div\n- Rendered ActivityPanel after AllCommentsPanel\n- Mutual exclusivity: opening ActivityPanel closes NodeDetail and AllCommentsPanel\n- ActivityOverlay hides when any sidebar or timeline is active\n- Props wired: feed, onNodeClick, onOpenPanel, onToggleCollapse, visibility flags\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.426965+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:42.290477+13:00","closed_at":"2026-02-11T12:05:22.74963+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.428287+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.2","type":"blocks","created_at":"2026-02-11T12:12:31.588158+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.4","type":"blocks","created_at":"2026-02-11T12:12:35.542205+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.5","type":"blocks","created_at":"2026-02-11T12:12:39.650845+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.7","title":"Extend diff engine to track owner and assignee changes","description":"Extend the diff engine in lib/diff-beads.ts to track owner and assignee field changes.\n\n**Implemented:**\n- Added owner field comparison at line 84: `if ((old.owner || \"\") !== (node.owner || \"\"))`\n- Generates node-owner-changed diff when owner field changes\n- Enables activity feed to show \"owner changed\" events in real-time\n","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.551848+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:45.586883+13:00","closed_at":"2026-02-11T12:05:22.877032+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.552867+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:09.2907+13:00","created_by":"daviddao"}]},{"id":"beads-map-7r6.8","title":"Build, verify, and push activity feed feature","description":"Build, verify, and commit the complete activity feed feature.\n\n**Implemented:**\n- Ran pnpm build to verify no TypeScript errors\n- Tested activity overlay and panel in dev mode\n- Verified historical feed generation from existing data\n- Confirmed real-time event updates from SSE\n- Verified search and filter functionality in ActivityPanel\n- Committed changes with message: \"Activity feed: historical + real-time event log with compact overlay and expandable panel\"\n- Commit hash: ea51cb7\n","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T11:54:22.674715+13:00","created_by":"daviddao","updated_at":"2026-02-11T12:11:48.579538+13:00","closed_at":"2026-02-11T12:05:23.005815+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.675891+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6.6","type":"blocks","created_at":"2026-02-11T12:12:44.251892+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np","title":"Epic: Surface owner/assignee in tooltip and search","description":"Two enhancements: (1) Show owner and assignee in the node hover tooltip (BeadTooltip) when present. (2) Make the search bar match on owner and assignee names so typing 'daviddao' finds all nodes assigned to or owned by that person.","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:33:49.054947+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.891487+13:00","closed_at":"2026-02-12T10:35:36.891487+13:00","close_reason":"Completed: 0c7b4e1 — all tasks done","dependencies":[{"issue_id":"beads-map-8np","depends_on_id":"beads-map-9d3","type":"blocks","created_at":"2026-02-12T10:39:55.489578+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np.1","title":"Add assignee and createdBy to GraphNode and buildGraphData","description":"In lib/types.ts: add 'assignee?: string' and 'createdBy?: string' fields to GraphNode interface. In lib/parse-beads.ts buildGraphData() (line ~140): map 'assignee: issue.assignee' and 'createdBy: issue.created_by' into the GraphNode object. In lib/diff-beads.ts: if assignee/createdBy changes should trigger _changedAt, add them to the diff comparison (optional — they're display-only so probably not needed).","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:33:56.34265+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.639771+13:00","closed_at":"2026-02-12T10:35:36.639771+13:00","close_reason":"Completed: 0c7b4e1","dependencies":[{"issue_id":"beads-map-8np.1","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:33:56.34421+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np.2","title":"Show owner and assignee in BeadTooltip","description":"In components/BeadTooltip.tsx: add two new metadata rows between 'Created' and 'Blocked by'. (1) 'Owner' row showing node.owner if present. (2) 'Assignee' row showing node.assignee if present. Both should be conditionally rendered — only show when the value exists. Style: same labelStyle/valueStyle as existing rows.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:34:01.699002+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.724586+13:00","closed_at":"2026-02-12T10:35:36.724586+13:00","close_reason":"Completed: 0c7b4e1","dependencies":[{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:01.699953+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.820355+13:00","created_by":"daviddao"}]},{"id":"beads-map-8np.3","title":"Extend search bar to match on owner and assignee","description":"In app/page.tsx searchResults useMemo (line ~756): extend the searchable string from 'n.id n.title n.prefix' to include 'n.owner n.assignee n.createdBy' (with fallback to empty string for undefined values). This lets users type 'daviddao' and see all nodes owned by or assigned to that person. No UI changes to the result rendering needed — the existing display (id, title, prefix badge) is sufficient.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:34:07.488931+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:35:36.807758+13:00","closed_at":"2026-02-12T10:35:36.807758+13:00","close_reason":"Completed: 0c7b4e1","dependencies":[{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:07.490637+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.951842+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp","title":"v0.3.1: Catppuccin color modes, help sidebar, and UX polish","description":"## Overview\n\nEpic covering all features added between v0.3.0 and v0.3.1. This release introduced a legend color mode selector (5 modes), the Catppuccin Latte accent palette for all prefix-colored elements, a cluster visibility toggle, copy-to-clipboard for descriptions, tooltip enhancements, a Help sidebar, and comprehensive README updates.\n\n## Commits (13 total, chronological)\n\n1. c93622d — Add legend color mode selector with Catppuccin Mocha palette\n2. 31ae0c7 — Match cluster circle color to Catppuccin prefix palette when in prefix color mode\n3. 13e5bc8 — Use Catppuccin prefix colors for node rings, cluster circles, and tooltip accent bar\n4. c2e815a — Switch from Catppuccin Mocha to Latte palette for better contrast on white background\n5. 6cfc26c — Add toggle to show/hide hierarchical cluster labels when zoomed out\n6. bfe2714 — v0.3.1: version bump\n7. b499aac — Add copy-to-clipboard button for descriptions in modal and detail panel\n8. 3381968 — Show prefix label and issue ID in hover tooltip\n9. 7d5f774 — Include prefix, ID, and repo URL header when copying descriptions\n10. b630c89 — Add priority color mode to legend selector\n11. d6e6391 — Update README with all new features\n12. 63d1c38 — Add Help sidebar with casual plain-English guide\n\n## Files created\n- components/HelpPanel.tsx — Help sidebar with casual feature guide\n\n## Files modified\n- lib/types.ts — ColorMode type, Catppuccin Latte palette, getPersonColor(), getCatppuccinPrefixColor()\n- app/page.tsx — colorMode state, helpPanelOpen state, navbar Help pill, mutual exclusivity wiring\n- components/BeadsGraph.tsx — color-mode-aware getNodeColor(), legend selector UI, legendItems useMemo, cluster toggle, priority mode\n- components/BeadTooltip.tsx — prefix label + issue ID row\n- components/DescriptionModal.tsx — copy button with repo URL header\n- components/NodeDetail.tsx — copy button with repo URL header\n- lib/utils.ts — buildDescriptionCopyText() helper\n- package.json — version 0.3.0 → 0.3.1\n- README.md — documented all new features\n\n## Release\n- npm: beads-map@0.3.1 published\n- GitHub: pushed to GainForest/beads-map main","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-12T15:12:09.279906+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.662961+13:00","closed_at":"2026-02-12T15:15:22.662961+13:00","close_reason":"Closed"},{"id":"beads-map-8tp.1","title":"Legend color mode selector (Status, Owner, Assignee, Prefix)","description":"## What\n\nAdded a color mode selector to the bottom-right legend panel. Users can switch node body fill color between 4 modes (later extended to 5 with priority in task .8).\n\n## Commit\n- c93622d — Add legend color mode selector with Catppuccin Mocha palette\n\n## Files modified\n\n### lib/types.ts\n- Added \\`ColorMode\\` type: \\`\"status\" | \"owner\" | \"assignee\" | \"prefix\"\\`\n- Added \\`COLOR_MODE_LABELS\\` record with display names\n- Added \\`CATPPUCCIN_MOCHA_ACCENTS\\` array (14 hex colors, reordered for max contrast between adjacent indices — alternating warm/cool)\n- Added \\`CATPPUCCIN_ACCENT_NAMES\\` array for legend labels\n- Added \\`CATPPUCCIN_UNASSIGNED\\` constant (#585b70, Mocha Surface2)\n- Added \\`getPersonColor(person)\\` function: FNV-1a hash mod 14 into Catppuccin palette, returns CATPPUCCIN_UNASSIGNED for undefined/empty\n- Added \\`getCatppuccinPrefixColor(prefix)\\` function: delegates to getPersonColor\n\n### app/page.tsx\n- Added \\`colorMode\\` state (useState<ColorMode>(\"status\"))\n- Imported \\`ColorMode\\` type\n- Passed \\`colorMode\\` and \\`onColorModeChange={setColorMode}\\` props to \\`<BeadsGraph>\\`\n\n### components/BeadsGraph.tsx\n- Added \\`ColorMode\\` type import and new type imports (\\`COLOR_MODE_LABELS\\`, \\`getPersonColor\\`, \\`getCatppuccinPrefixColor\\`, \\`getPrefixLabel\\`)\n- Added \\`colorMode\\` and \\`onColorModeChange\\` to \\`BeadsGraphProps\\` interface\n- Added module-level \\`let _currentColorMode: ColorMode = \"status\"\\` variable\n- Converted \\`getNodeColor(node)\\` from simple status lookup to a switch on \\`_currentColorMode\\` (status/owner/assignee/prefix)\n- Added \\`colorModeRef\\` + sync useEffect that updates \\`_currentColorMode\\`, calls \\`refreshGraph()\\`, and redraws minimap\n- Added \\`legendItems\\` useMemo: computes dynamic legend entries from \\`viewNodes\\` based on color mode (Map<label, color>, sorted alphabetically with \"Unassigned\" last)\n- Replaced static legend panel with: segmented control (4 buttons, emerald-500 active), dynamic legend section (status dots or person/prefix dots), mode-aware hint text\n\n## Key design decisions\n- **Module-level \\`_currentColorMode\\`**: \\`paintNode\\` has \\`[]\\` deps so it can't read props. The module-level variable is synced from the useEffect and read synchronously by \\`getNodeColor\\` during canvas painting.\n- **legendItems depends on viewNodes**: Only shows people/prefixes present in currently visible nodes, not all possible values.\n- **Segmented control style**: Matches the existing layout mode buttons (zinc-100 bg, emerald-500 active, 10px font).","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:12:32.784315+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.355789+13:00","closed_at":"2026-02-12T15:15:22.355789+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.1","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:12:32.786117+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.10","title":"Add Help sidebar with casual plain-English feature guide","description":"## What\n\nAdded a \"Help\" button in the navbar that opens a right sidebar with a casual, friendly guide explaining all of Heartbeads' features in plain English. Follows the same sidebar pattern as Comments and Activity (mutually exclusive, 360px slide-in, mobile bottom drawer).\n\n## Commit\n- 63d1c38 — Add Help sidebar with casual plain-English guide to all features\n\n## Files created\n\n### components/HelpPanel.tsx (NEW)\nTwo-part component:\n\n1. **HelpPanel** (exported): Renders the sidebar wrapper:\n - Desktop: \\`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30\\` with \\`translate-x-0\\` / \\`translate-x-full\\` animation\n - Mobile: \\`md:hidden fixed inset-x-0 bottom-0 z-20\\` bottom drawer with \\`rounded-t-2xl max-h-[70vh]\\`\n - Header: \"Welcome to Heartbeads\" + close X button\n - Content: scrollable \\`<HelpContent />\\`\n\n2. **HelpContent** (internal): The actual help text, structured as:\n - **Intro**: \"Your command center for AI coding tasks\" + what the graph shows\n - **The graph**: circles, arrows, rings, solid vs dashed\n - **Getting around**: click, hover, right-click, scroll, drag, Cmd+F\n - **Layouts**: Force, DAG, Radial, Cluster, Spread\n - **Color modes**: Status, Priority, Owner, Assignee, Prefix\n - **More cool stuff**: Collapse/Expand, Clusters, Replay, Comments, Claim tasks, Minimap, Copy descriptions\n - **Footer**: \"Built with beads\" link to GitHub\n\n Uses a \\`SectionTitle\\` helper component (\\`text-xs font-semibold text-zinc-500 uppercase tracking-wider\\`) and bullet lists with \\`--\\` dashes for a casual look.\n\n## Files modified\n\n### app/page.tsx\n\n1. **Import**: Added \\`import { HelpPanel } from \"@/components/HelpPanel\"\\`\n\n2. **State** (line ~251): \\`const [helpPanelOpen, setHelpPanelOpen] = useState(false)\\`\n\n3. **Navbar Help pill** (after Activity, before divider):\n - Same pill styling as Comments/Activity: \\`px-4 py-2 text-sm font-medium rounded-full\\`\n - Active: \\`text-emerald-700 bg-emerald-50\\`\n - Icon: question-mark-circle SVG (Heroicons outline)\n - Label: \"Help\" (hidden on mobile)\n\n4. **Mutual exclusivity** (6 locations updated):\n - \\`handleNodeClick\\`: added \\`setHelpPanelOpen(false)\\`\n - Comments toggle: added \\`setHelpPanelOpen(false)\\` when opening\n - Activity toggle: added \\`setHelpPanelOpen(false)\\` when opening\n - Activity \"See all\": added \\`setHelpPanelOpen(false)\\`\n - Help toggle: closes \\`selectedNode\\`, \\`allCommentsPanelOpen\\`, \\`activityPanelOpen\\` when opening\n\n5. **sidebarOpen prop**: Extended to \\`!!selectedNode || allCommentsPanelOpen || activityPanelOpen || helpPanelOpen\\`\n\n6. **ActivityOverlay guard**: Extended to include \\`!helpPanelOpen\\`\n\n7. **Rendering** (after ActivityPanel, before closing \\`</div>\\`):\n \\`\\`\\`tsx\n <HelpPanel isOpen={helpPanelOpen} onClose={() => setHelpPanelOpen(false)} />\n \\`\\`\\`\n\n## Navbar order\n\\`\\`\\`\n[Replay] [Comments] [Activity] [Help] | [Sign in]\n\\`\\`\\`","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:15:12.205912+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.631825+13:00","closed_at":"2026-02-12T15:15:22.631825+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.10","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:15:12.207348+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.2","title":"Unify all prefix colors to Catppuccin palette (rings, clusters, tooltip)","description":"## What\n\nMade all prefix-colored elements use the Catppuccin accent palette consistently instead of the old FNV-hash HSL colors. Previously, the node ring, cluster circles, and tooltip accent bar each used PREFIX_COLORS (FNV-hash → HSL), while the new color mode used Catppuccin. This created a visual mismatch. Now everything is Catppuccin.\n\n## Commits\n- 31ae0c7 — Match cluster circle color to Catppuccin prefix palette when in prefix color mode\n- 13e5bc8 — Use Catppuccin prefix colors for node rings, cluster circles, and tooltip accent bar\n\n## Files modified\n\n### components/BeadsGraph.tsx\n- **getPrefixColor → getPrefixRingColor**: Renamed the module-level function (line ~91). Changed body from \\`PREFIX_COLORS[node.prefix] || \"#a1a1aa\"\\` to \\`getCatppuccinPrefixColor(node.prefix)\\`.\n- **Cluster circle color** (line ~1393 in paintClusterLabels): Changed from \\`PREFIX_COLORS[cluster.prefix] || \"hsl(0, 0%, 65%)\"\\` to \\`getCatppuccinPrefixColor(cluster.prefix)\\`. Always uses Catppuccin regardless of color mode since clusters always represent projects.\n- **Removed PREFIX_COLORS import**: No longer needed in BeadsGraph.tsx.\n- **paintNode call site** (line ~898): Updated from \\`getPrefixColor(graphNode)\\` to \\`getPrefixRingColor(graphNode)\\`.\n\n### app/page.tsx\n- **Tooltip accent bar** (line ~1388): Changed from \\`getPrefixColor(nodeTooltip.node.prefix)\\` to \\`getCatppuccinPrefixColor(nodeTooltip.node.prefix)\\`.\n- **Import**: Replaced \\`getPrefixColor\\` with \\`getCatppuccinPrefixColor\\` from \\`@/lib/types\\`.\n\n## Result\nAll prefix-colored elements now use \\`getCatppuccinPrefixColor()\\` via FNV-1a hash mod 14, ensuring a given project prefix always maps to the same Catppuccin color everywhere.","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:12:51.583264+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.387688+13:00","closed_at":"2026-02-12T15:15:22.387688+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.2","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:12:51.584816+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.2","depends_on_id":"beads-map-8tp.1","type":"blocks","created_at":"2026-02-12T15:12:51.586097+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.3","title":"Switch Catppuccin from Mocha to Latte for light backgrounds","description":"## What\n\nSwapped all 14 Catppuccin accent hex values from Mocha (pastel, designed for dark backgrounds) to Latte (saturated, designed for light backgrounds). The app has a white/zinc-50 background so Mocha colors were too washed out.\n\n## Commit\n- c2e815a — Switch from Catppuccin Mocha to Latte palette for better contrast on white background\n\n## File modified: lib/types.ts (single source of truth)\n\n- **Renamed** \\`CATPPUCCIN_MOCHA_ACCENTS\\` → \\`CATPPUCCIN_ACCENTS\\` (flavor-agnostic name)\n- **Swapped all 14 hex values** (same contrast-maximizing order):\n\n| Name | Mocha (old) | Latte (new) |\n|---|---|---|\n| Red | #f38ba8 | #d20f39 |\n| Teal | #94e2d5 | #179299 |\n| Peach | #fab387 | #fe640b |\n| Blue | #89b4fa | #1e66f5 |\n| Green | #a6e3a1 | #40a02b |\n| Mauve | #cba6f7 | #8839ef |\n| Yellow | #f9e2af | #df8e1d |\n| Sapphire | #74c7ec | #209fb5 |\n| Pink | #f5c2e7 | #ea76cb |\n| Sky | #89dceb | #04a5e5 |\n| Maroon | #eba0b3 | #e64553 |\n| Lavender | #b4befe | #7287fd |\n| Flamingo | #f2cdcd | #dd7878 |\n| Rosewater | #f5e0dc | #dc8a78 |\n\n- **Unassigned color**: Mocha Surface2 (#585b70) → Latte Surface2 (#acb0be)\n- **Updated** \\`getPersonColor()\\` to reference \\`CATPPUCCIN_ACCENTS\\` instead of \\`CATPPUCCIN_MOCHA_ACCENTS\\`\n- **Updated** all doc comments from \"Mocha\" to \"Latte\"\n\n## Why Latte\nCatppuccin Latte is the light-background flavor with saturated, high-contrast colors. Since the app renders on a white canvas with zinc-50 backgrounds, Latte colors are immediately distinguishable while Mocha pastels blended into the background.","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:13:08.949643+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.418953+13:00","closed_at":"2026-02-12T15:15:22.418953+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.3","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:08.950526+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.3","depends_on_id":"beads-map-8tp.2","type":"blocks","created_at":"2026-02-12T15:13:08.951859+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.4","title":"Add show/hide toggle for hierarchical cluster labels","description":"## What\n\nAdded a \"Clusters\" toggle button in the top-left toolbar to show/hide the hierarchical cluster circles and labels that appear when zoomed out. Previously clusters were always visible; now users can hide them for a cleaner view.\n\n## Commit\n- 6cfc26c — Add toggle to show/hide hierarchical cluster labels when zoomed out\n\n## File modified: components/BeadsGraph.tsx\n\n### 1. New state (line ~278)\n\\`\\`\\`typescript\nconst [showClusters, setShowClusters] = useState(true);\n\\`\\`\\`\nDefaults to true (preserves existing behavior).\n\n### 2. Guard in paintClusterLabels (line ~1333)\nAdded \\`if (!showClusters) return;\\` at the top of the \\`paintClusterLabels\\` callback, before any computation. Added \\`showClusters\\` to the dependency array: \\`[viewNodes, nodes, showClusters]\\`.\n\n### 3. Toggle button in top-left controls\nPlaced after the Collapse/Expand button, as the rightmost control in the top-left toolbar row:\n\\`\\`\\`\n[Force][DAG][Radial][Cluster][Spread] [Collapse all] [Clusters]\n\\`\\`\\`\n\nButton styling:\n- **Active (green)**: \\`bg-emerald-500 text-white border-emerald-500\\` — clusters visible\n- **Inactive (white)**: \\`bg-white/90 text-zinc-500 border-zinc-200\\` — clusters hidden\n- SVG icon: dashed circle with horizontal lines (representing the cluster overlay)\n- Label: \"Clusters\" (hidden on mobile via \\`hidden sm:inline\\`)\n- Title tooltip: \"Hide cluster labels\" / \"Show cluster labels\"\n\n### Behavior\n- **ON (default)**: Cluster circles fade in when zoomed out past globalScale 0.8, fully visible below 0.4. Shows dashed prefix-colored circle, epic title, epic ID, and member count at each cluster centroid.\n- **OFF**: No cluster rendering at any zoom level. The early return in paintClusterLabels skips all computation.","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:13:26.551878+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.448574+13:00","closed_at":"2026-02-12T15:15:22.448574+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.4","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:26.55342+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.5","title":"Add copy-to-clipboard button for descriptions (modal + sidebar)","description":"## What\n\nAdded a clipboard copy icon button to both the description modal (DescriptionModal) and the node detail sidebar (NodeDetail). Clicking copies the raw markdown text to clipboard with a brief checkmark feedback animation.\n\n## Commit\n- b499aac — Add copy-to-clipboard button for descriptions in modal and detail panel\n\n## Files modified\n\n### components/DescriptionModal.tsx\n- Added \\`useState\\` import and \\`copied\\` state for visual feedback\n- Added \\`handleCopy()\\` function: calls \\`navigator.clipboard.writeText(node.description)\\`, sets \\`copied=true\\` for 1.5 seconds via setTimeout\n- Added copy button in the modal header, between the title and close X button:\n - Default state: clipboard SVG icon (Heroicons clipboard-document outline, zinc-400, w-4 h-4)\n - Copied state: emerald-500 checkmark icon for 1.5 seconds\n - Both buttons wrapped in a \\`flex items-center gap-1\\` container\n\n### components/NodeDetail.tsx\n- Added \\`descCopied\\` state for feedback\n- Added copy button in the description section header, between the \"Description\" label and \"View in window\" link:\n - Same clipboard → checkmark pattern as the modal\n - Slightly smaller icons (w-3.5 h-3.5) to match the sidebar's compact design\n - Button and \"View in window\" wrapped in \\`flex items-center gap-2\\` container\n\n## UX details\n- Copies **raw markdown** (not rendered HTML) — useful for pasting into editors, chat, or issue trackers\n- 1.5-second checkmark feedback using \\`setTimeout(() => setCopied(false), 1500)\\`\n- \\`pointerEvents\\` not affected — the button is fully clickable\n- No toast/notification — the icon change itself is the feedback","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:13:41.073465+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.479169+13:00","closed_at":"2026-02-12T15:15:22.479169+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.5","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:41.075186+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.6","title":"Show prefix label and issue ID in hover tooltip","description":"## What\n\nAdded a row showing the project prefix label and issue ID to the hover tooltip (BeadTooltip), displayed between the accent bar and the title. This gives users immediate context about which project a node belongs to and its exact ID without clicking.\n\n## Commit\n- 3381968 — Show prefix label and issue ID in hover tooltip\n\n## File modified: components/BeadTooltip.tsx\n\n### Import\nAdded \\`getPrefixLabel\\` to the import from \\`@/lib/types\\`.\n\n### New row (between accent bar and title)\nInserted a \\`div\\` with \\`display: flex, alignItems: center, gap: 6, marginBottom: 6\\`:\n\n1. **Prefix label**: \\`<span>\\` with \\`fontSize: 10, fontWeight: 600, color: prefixColor, letterSpacing: 0.5, textTransform: uppercase\\`. Calls \\`getPrefixLabel(node.prefix)\\` which strips trailing hyphens and title-cases (e.g., \"beads-map\" → \"Beads Map\").\n\n2. **Issue ID**: \\`<span>\\` with \\`fontSize: 10, fontFamily: monospace, color: COLORS.textDim\\`. Shows \\`node.id\\` (e.g., \"beads-map-dwk.3\").\n\n### Layout adjustment\nReduced accent bar \\`marginBottom\\` from 10 to 8 to accommodate the new row without making the tooltip too tall.\n\n## Visual result\n```\n━━━━━━ (accent bar in prefix color)\nBEADS MAP beads-map-dwk.3\nUpdate BeadsGraph: color-mode-aware paintNode...\nCreated: 2h ago\n...\n```","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:13:55.420535+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.509818+13:00","closed_at":"2026-02-12T15:15:22.509818+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.6","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:55.422504+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.7","title":"Include prefix, ID, and repo URL header in copied description text","description":"## What\n\nEnhanced the copy-to-clipboard feature (task .5) so that copied text includes a metadata header above the description: project prefix label, issue ID, and GitHub repo URL (if available). Previously it copied only the raw description markdown.\n\n## Commit\n- 7d5f774 — Include prefix, ID, and repo URL header when copying descriptions\n\n## Files modified\n\n### lib/utils.ts\nAdded \\`buildDescriptionCopyText(node, repoUrl?)\\` helper function:\n\\`\\`\\`typescript\nexport function buildDescriptionCopyText(\n node: GraphNode,\n repoUrl?: string,\n): string {\n const lines: string[] = [];\n lines.push(\\`[\\${getPrefixLabel(node.prefix)}] \\${node.id}\\`);\n if (repoUrl) lines.push(repoUrl);\n lines.push(\"\");\n lines.push(node.description || \"\");\n return lines.join(\"\\\\n\");\n}\n\\`\\`\\`\nAdded imports: \\`getPrefixLabel\\` and \\`GraphNode\\` from \\`@/lib/types\\`.\n\nOutput format:\n\\`\\`\\`\n[Beads Map] beads-map-dwk.3\nhttps://github.com/GainForest/beads-map\n\n## What\nThis is the core task...\n\\`\\`\\`\n\n### components/DescriptionModal.tsx\n- Added \\`repoUrl?: string\\` prop to \\`DescriptionModalProps\\`\n- Imported \\`buildDescriptionCopyText\\` from \\`@/lib/utils\\`\n- Updated \\`handleCopy()\\` to call \\`buildDescriptionCopyText(node, repoUrl)\\` instead of \\`node.description\\`\n\n### components/NodeDetail.tsx\n- Imported \\`buildDescriptionCopyText\\` from \\`@/lib/utils\\`\n- Updated inline copy handler to call \\`buildDescriptionCopyText(node, repoUrls?.[node.prefix])\\`\n- Updated \\`<DescriptionModal>\\` rendering to pass \\`repoUrl={repoUrls?.[node.prefix]}\\`\n\n### app/page.tsx\n- Updated \\`<DescriptionModal>\\` (from context menu) to pass \\`repoUrl={repoUrls[descriptionModalNode.prefix]}\\`\n\n## Data flow for repoUrl\n\\`repoUrls\\` is a \\`Record<string, string>\\` mapping project prefix → GitHub URL. Fetched from \\`/api/config\\` which reads git remote URLs via \\`getRepoUrls()\\` in \\`lib/discover.ts\\`. Available in \\`page.tsx\\` state, passed to \\`NodeDetail\\` as prop, and now also passed to \\`DescriptionModal\\`.","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:14:12.522908+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.540463+13:00","closed_at":"2026-02-12T15:15:22.540463+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.7","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:14:12.523784+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.7","depends_on_id":"beads-map-8tp.5","type":"blocks","created_at":"2026-02-12T15:14:12.525112+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.8","title":"Add priority color mode to legend selector","description":"## What\n\nAdded \"Priority\" as a 5th color mode in the legend selector. Nodes are colored by their priority level (P0–P4) using the existing PRIORITY_COLORS palette.\n\n## Commit\n- b630c89 — Add priority color mode to legend selector (P0-P4 with red/orange/blue/zinc)\n\n## Files modified\n\n### lib/types.ts\n- Extended \\`ColorMode\\` type: \\`\"status\" | \"owner\" | \"assignee\" | \"prefix\"\\` → \\`\"status\" | \"priority\" | \"owner\" | \"assignee\" | \"prefix\"\\`\n- Added \\`priority: \"Priority\"\\` to \\`COLOR_MODE_LABELS\\` record\n\n### components/BeadsGraph.tsx\n\n1. **Imports** (line ~15): Added \\`PRIORITY_COLORS\\` and \\`PRIORITY_LABELS\\` to the import from \\`@/lib/types\\`.\n\n2. **getNodeColor** (line ~77): Added priority case to the switch:\n \\`\\`\\`typescript\n case \"priority\":\n return PRIORITY_COLORS[node.priority] || PRIORITY_COLORS[2];\n \\`\\`\\`\n Falls back to P2 (blue) for unknown priorities.\n\n3. **legendItems useMemo** (line ~466): Updated guard from \\`if (colorMode === \"status\") return []\\` to \\`if (colorMode === \"status\" || colorMode === \"priority\") return []\\` since priority uses static rendering (like status).\n\n4. **Segmented control** (line ~2028): Extended the modes array from 4 to 5:\n \\`\\`\\`typescript\n ([\"status\", \"priority\", \"owner\", \"assignee\", \"prefix\"] as ColorMode[])\n \\`\\`\\`\n\n5. **Legend dots** (line ~2043): Added a priority branch between status and the dynamic fallback:\n \\`\\`\\`typescript\n colorMode === \"priority\" ? (\n [0, 1, 2, 3, 4].map((p) => (\n <span ...>\n <span style={{ backgroundColor: PRIORITY_COLORS[p] }} />\n <span>{PRIORITY_LABELS[p]}</span>\n </span>\n ))\n )\n \\`\\`\\`\n\n## Priority colors (from PRIORITY_COLORS)\n- P0 Critical — red (#ef4444)\n- P1 High — orange (#f97316)\n- P2 Medium — blue (#3b82f6)\n- P3 Low — zinc (#a1a1aa)\n- P4 Backlog — zinc-300 (#d4d4d8)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:14:28.789276+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.570952+13:00","closed_at":"2026-02-12T15:15:22.570952+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.8","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:14:28.79067+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.8","depends_on_id":"beads-map-8tp.1","type":"blocks","created_at":"2026-02-12T15:14:28.792079+13:00","created_by":"daviddao"}]},{"id":"beads-map-8tp.9","title":"Update README with all v0.3.1 features","description":"## What\n\nUpdated README.md to document all new features from the v0.3.1 release.\n\n## Commit\n- d6e6391 — Update README with legend color modes, Catppuccin Latte palette, cluster toggle, copy button, priority mode\n\n## Changes to README.md sections\n\n### Graph Visualization section\n- **Visual encoding**: Updated from \"fill color = status\" to \"fill color = configurable via legend\" with link to Catppuccin Latte palette\n- **Legend color modes**: NEW subsection documenting all 5 modes (Status, Priority, Owner, Assignee, Prefix)\n- **Catppuccin Latte palette**: NEW subsection explaining the palette choice (14 saturated colors for light backgrounds, FNV-1a hash mapping)\n- **Semantic zoom**: Added mention of cluster visibility toggle (\"Clusters\" button)\n- **Hover tooltips**: Updated to include \"project prefix, issue ID\" in the description\n\n### Node Detail Sidebar section\n- Added copy-to-clipboard button documentation (\"copies raw markdown with header showing project prefix, issue ID, and GitHub repo URL\")\n- Added Description modal documentation as a separate bullet\n\n### Multi-Repo Support section\n- Updated \"Per-project colors\" from \"FNV-1a hash\" to \"Catppuccin Latte palette (14 accent colors)\"\n- Updated \"GitHub repo links\" to mention inclusion in copied description text\n\n### Info Panel section\n- Renamed to \"Info Panel & Legend\"\n- Updated to describe the color mode selector and dynamic legend behavior\n\n### Architecture tree\n- \\`BeadTooltip.tsx\\`: \"Hover tooltip: prefix, ID, title, date, blockers, priority, owner\"\n- \\`DescriptionModal.tsx\\`: \"Full-screen markdown description modal with copy button\"\n- \\`types.ts\\`: \"GraphNode, GraphLink, ColorMode, Catppuccin palette, color helpers\"\n- \\`utils.ts\\`: \"formatRelativeTime, buildDescriptionCopyText, shared utilities\"\n\n### Tech Stack section\n- Added Catppuccin Latte link: \\`[Catppuccin Latte](https://github.com/catppuccin/palette) accent palette for prefix/person coloring (14 colors)\\`","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:14:47.211918+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:15:22.601639+13:00","closed_at":"2026-02-12T15:15:22.601639+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:14:47.216998+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.1","type":"blocks","created_at":"2026-02-12T15:14:47.218878+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.2","type":"blocks","created_at":"2026-02-12T15:14:47.220248+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.3","type":"blocks","created_at":"2026-02-12T15:14:47.221773+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.4","type":"blocks","created_at":"2026-02-12T15:14:47.223045+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.5","type":"blocks","created_at":"2026-02-12T15:14:47.224557+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.6","type":"blocks","created_at":"2026-02-12T15:14:47.225986+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.7","type":"blocks","created_at":"2026-02-12T15:14:47.227396+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.8","type":"blocks","created_at":"2026-02-12T15:14:47.228629+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1","title":"Epic: Per-epic collapse/uncollapse via right-click context menu","description":"Add ability to collapse/uncollapse individual epics via right-click context menu while in Full view. A new collapsedEpicIds Set<string> state in page.tsx tracks which epics are individually collapsed. The viewNodes/viewLinks memo in BeadsGraph.tsx reads this set: in Full view, only children of collapsed epics are hidden; in Epics view, all children are hidden (existing behavior, unchanged). Context menu shows 'Collapse epic' on expanded epic nodes and 'Uncollapse epic' on collapsed ones. Collapse/uncollapse only available in Full view mode (Epics view forces all collapsed). collapsedEpicIds is independent of the Full/Epics toggle — switching modes preserves per-epic state.","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:08.902101+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:38.122465+13:00","closed_at":"2026-02-12T10:56:38.122465+13:00","close_reason":"All 4 subtasks completed in 74d70b0: per-epic collapse/uncollapse via right-click context menu"},{"id":"beads-map-8z1.1","title":"Add collapsedEpicIds state and toggle handler in page.tsx","description":"In app/page.tsx: (1) Add state: const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set()). (2) Add handler: const handleToggleEpicCollapse = useCallback((epicId: string) => { setCollapsedEpicIds(prev => { const next = new Set(prev); if (next.has(epicId)) next.delete(epicId); else next.add(epicId); return next; }); }, []). (3) Pass collapsedEpicIds as prop to <BeadsGraph>. Place state near other graph-related state (around line 188 near hoveredNode). Acceptance: prop is passed, handler exists, pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:17.434106+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:30.552955+13:00","closed_at":"2026-02-12T10:56:30.552955+13:00","close_reason":"Completed in 74d70b0: collapsedEpicIds state + handleToggleEpicCollapse in page.tsx","dependencies":[{"issue_id":"beads-map-8z1.1","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:17.43527+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1.2","title":"Integrate collapsedEpicIds into viewNodes/viewLinks memo in BeadsGraph","description":"In components/BeadsGraph.tsx: (1) Add collapsedEpicIds?: Set<string> to BeadsGraphProps (line ~36). (2) Destructure from props (line ~204). (3) Modify viewNodes/viewLinks useMemo (line ~296): Change the early return at line 297 from 'if (viewMode === \"full\") return ...' to 'if (viewMode === \"full\" && (!collapsedEpicIds || collapsedEpicIds.size === 0)) return ...'. (4) In the collapse logic, add a shouldCollapse helper: when viewMode === \"epics\" collapse all children (existing); when viewMode === \"full\" only collapse children whose parent is in collapsedEpicIds. Replace the line 'const childIds = new Set(childToParent.keys())' with a filtered set using shouldCollapse. (5) Add collapsedEpicIds to the useMemo dependency array. The rest of the memo (aggregate stats, filter nodes, remap links) stays identical — it already operates on the childIds set. Acceptance: individually collapsed epics fold their children in Full view; Epics view unchanged.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:27.393645+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:31.606551+13:00","closed_at":"2026-02-12T10:56:31.606551+13:00","close_reason":"Completed in 74d70b0: viewNodes/viewLinks memo supports per-epic collapse","dependencies":[{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:27.399923+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.363125+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1.3","title":"Add Collapse/Uncollapse epic menu items to ContextMenu","description":"In components/ContextMenu.tsx: (1) Add two new optional props to ContextMenuProps: onCollapseEpic?: () => void and onUncollapseEpic?: () => void. (2) Destructure them in the component. (3) Add 'Collapse epic' button after 'Add comment' (before Claim/Unclaim): conditionally rendered when onCollapseEpic is defined. Icon: inward-pointing chevrons/arrows (collapse visual). Style: same as other menu items (w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50). (4) Add 'Uncollapse epic' button: conditionally rendered when onUncollapseEpic is defined. Icon: outward-pointing chevrons/arrows (expand visual). (5) Adjust border-b logic on 'Add comment' button: it should show border-b when any of onCollapseEpic, onUncollapseEpic, onClaimTask, onUnclaimTask follow. Acceptance: menu items render correctly when props are provided, pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:36.641477+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:32.590331+13:00","closed_at":"2026-02-12T10:56:32.590331+13:00","close_reason":"Completed in 74d70b0: Collapse/Uncollapse epic menu items in ContextMenu","dependencies":[{"issue_id":"beads-map-8z1.3","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:36.643125+13:00","created_by":"daviddao"}]},{"id":"beads-map-8z1.4","title":"Wire collapse/uncollapse props in ContextMenu JSX in page.tsx","description":"In app/page.tsx, in the <ContextMenu> JSX (line ~1249): (1) Pass onCollapseEpic: set when ALL of: viewMode is 'full' (need viewMode from BeadsGraph — see note), node.issueType === 'epic', and !collapsedEpicIds.has(node.id). Calls handleToggleEpicCollapse(contextMenu.node.id) then setContextMenu(null). (2) Pass onUncollapseEpic: set when ALL of: viewMode is 'full', node.issueType === 'epic', and collapsedEpicIds.has(node.id). Same handler. NOTE on viewMode: viewMode currently lives inside BeadsGraph as internal state. Options: (a) Lift viewMode to page.tsx (cleanest but larger refactor), (b) Expose viewMode from BeadsGraph via the imperative handle (BeadsGraphHandle), (c) Add an onViewModeChange callback + viewMode prop to sync it up. Recommended: option (b) — add viewMode to the existing BeadsGraphHandle ref. Then page.tsx reads graphRef.current?.viewMode to decide. Alternatively, simpler approach: always show collapse/uncollapse on epic nodes in Full view — since the user explicitly chose the action, it's fine. We can track whether we're in epics view by checking the viewMode ref. Acceptance: right-clicking an epic in Full view shows Collapse/Uncollapse; in Epics view these items don't appear.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:50:48.968686+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:56:33.727649+13:00","closed_at":"2026-02-12T10:56:33.727649+13:00","close_reason":"Completed in 74d70b0: viewMode exposed via BeadsGraphHandle, props wired in page.tsx","dependencies":[{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:48.970023+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.478692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.2","type":"blocks","created_at":"2026-02-12T10:50:56.600112+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.3","type":"blocks","created_at":"2026-02-12T10:50:56.718812+13:00","created_by":"daviddao"}]},{"id":"beads-map-9d3","title":"Epic: Add hover tooltip to graph nodes showing title, creation date, blockers, and priority","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:25.175725+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.956317+13:00","closed_at":"2026-02-12T10:30:56.956317+13:00","close_reason":"Completed: 6d96fa3 — all tasks done"},{"id":"beads-map-9d3.2","title":"Create BeadTooltip component","description":"New file: components/BeadTooltip.tsx. React component inspired by plresearch.org DependencyGraph Tooltip design. White card, fade-in animation (0.2s translateY), colored accent bar (node prefix color), pointerEvents:none, position:fixed. Shows: (1) Title 14px semibold, (2) Created date via formatRelativeTime from lib/utils.ts, (3) Blocked by section listing dependentIds as short IDs or 'None', (4) Priority with PRIORITY_COLORS dot + PRIORITY_LABELS from lib/types.ts. Smart viewport clamping: prefer above cursor, flip below if no room. Width ~280px, border-radius 8px, shadow 0 8px 32px rgba(0,0,0,0.08). Props: node:GraphNode, x:number, y:number, prefixColor:string, allNodes:GraphNode[] (resolve blocker IDs to titles).","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:40.268918+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.779274+13:00","closed_at":"2026-02-12T10:30:56.779274+13:00","close_reason":"Completed: 6d96fa3","dependencies":[{"issue_id":"beads-map-9d3.2","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:40.269874+13:00","created_by":"daviddao"}]},{"id":"beads-map-9d3.3","title":"Wire hover tooltip state in page.tsx","description":"In app/page.tsx: (1) Add nodeTooltip state: { node: GraphNode; x: number; y: number } | null. (2) Modify handleNodeHover to accept (node, x, y) from BeadsGraph. (3) Render <BeadTooltip> in the graph area (alongside existing avatar tooltip) when nodeTooltip is set. Pass allNodes from data.graphData.nodes so tooltip can resolve blocker IDs to titles. Use getPrefixColor(node.prefix) for the accent color.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:46.061095+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.86785+13:00","closed_at":"2026-02-12T10:30:56.86785+13:00","close_reason":"Completed: 6d96fa3","dependencies":[{"issue_id":"beads-map-9d3.3","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:46.062066+13:00","created_by":"daviddao"}]},{"id":"beads-map-9d3.4","title":"Pass mouse position from BeadsGraph on hover","description":"In components/BeadsGraph.tsx: (1) Track last mouse position via mousemove listener on the container div (same pattern as avatar hover hit-testing). Store in a ref: lastMouseRef = useRef({x:0,y:0}). (2) Update onNodeHover prop type from (node: GraphNode | null) => void to (node: GraphNode | null, x: number, y: number) => void. (3) In the ForceGraph2D onNodeHover handler, read lastMouseRef.current and pass clientX/clientY along with the node. (4) Update BeadsGraphProps interface accordingly.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:26:51.724328+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:30:56.688848+13:00","closed_at":"2026-02-12T10:30:56.688848+13:00","close_reason":"Completed: 6d96fa3","dependencies":[{"issue_id":"beads-map-9d3.4","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:51.725102+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm","title":"Epic: Add radial, cluster-by-prefix, and spread graph layouts inspired by beads_viewer reference project","description":"Add three new graph layout modes to the beads-map force graph, inspired by the beads_viewer_for_agentic_coding_flywheel_setup reference project. Currently we have Force (physics-based) and DAG (topological top-down). This epic adds: (1) Radial — arranges nodes in concentric rings by dependency depth using d3.forceRadial, centered on origin. Root nodes (no incoming blockers) sit at center, deeper nodes in outer rings. (2) Cluster — groups nodes spatially by their project prefix (e.g., beads-map, beads, etc.) using d3.forceX/forceY pulling nodes toward prefix-specific center points arranged in a circle. Useful for multi-repo graphs to see project boundaries. (3) Spread — same physics as Force but with much stronger repulsion (charge -300), larger link distances (180), and weaker center pull. Maximizes spacing for readability and screenshot exports. All three are implemented purely via d3-force configuration in the existing layout useEffect in BeadsGraph.tsx — no new components or files needed. The dagMode prop on ForceGraph2D is only 'td' for DAG; all other modes use undefined (physics-only). Reference: beads_viewer_for_agentic_coding_flywheel_setup/graph.js lines 2420-2465 (applyRadialLayout, applyClusterLayout, applyForceLayout functions).","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:23:43.98936+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:30:38.998185+13:00","closed_at":"2026-02-12T11:30:38.998185+13:00","close_reason":"All 5 subtasks completed: imports (0137bf2), radial+cluster+spread forces (cee4c87), UI buttons (8d08e1c)"},{"id":"beads-map-9lm.1","title":"Add d3-force imports and extend LayoutMode type","description":"In components/BeadsGraph.tsx, make two changes: (1) Line 12 — extend the d3-force import from 'import { forceCollide } from \"d3-force\"' to 'import { forceCollide, forceRadial, forceX, forceY } from \"d3-force\"'. All four are exported from d3-force (verified: node_modules/d3-force/src/ contains radial.js, x.js, y.js alongside collide.js). (2) Line 21 — extend the LayoutMode type from 'type LayoutMode = \"force\" | \"dag\"' to 'type LayoutMode = \"force\" | \"dag\" | \"radial\" | \"cluster\" | \"spread\"'. This is a prerequisite for all three layout tasks — the new imports are used by radial (forceRadial) and cluster (forceX, forceY) layouts, and the type extension lets all five layouts be assigned to layoutMode state. Note: the existing dagMode prop logic (layoutMode === \"dag\" ? \"td\" : undefined at ~line 1876) automatically handles the new modes correctly since none equal \"dag\". Acceptance: pnpm build passes with zero errors. The new LayoutMode values are usable in useState and the switch branches.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:23:50.369669+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:28:17.095657+13:00","closed_at":"2026-02-12T11:28:17.095657+13:00","close_reason":"Completed: 0137bf2","dependencies":[{"issue_id":"beads-map-9lm.1","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:23:50.371471+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.3","title":"Implement radial layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"radial\")' branch after the existing 'dag' and 'force' branches. Implementation steps: (1) COMPUTE BFS DEPTH: Build an incoming-edges map from viewLinks — only count 'blocks' edges (skip 'parent-child'). Find root nodes (those with no incoming blocks edges). BFS outward from roots: for each node reached, set depth = parent_depth + 1. Store depth transiently as node._depth on each viewNode object (underscore prefix = transient animation metadata convention per AGENTS.md). Nodes unreachable from any root get _depth = 0. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as the force branch ~line 642) — these may be left over from DAG mode which sets fixed positions. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-100).distanceMax(300); fg.d3Force('link')?.distance(80).strength(0.5); fg.d3Force('center')?.strength(0.01); fg.d3Force('radial', forceRadial((node: any) => ((node as any)._depth || 0) * 80, 0, 0).strength(0.5)); fg.d3Force('x', forceX(0).strength(0.05)); fg.d3Force('y', forceY(0).strength(0.05)); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 5).strength(0.6)). (4) CROSS-TASK CLEANUP: The existing 'dag' and 'force' branches must be updated to clear the new custom forces — add fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); at the start of each non-radial branch. This prevents stale radial/x/y forces from persisting when switching away from radial mode. (5) ADD viewLinks TO USEEFFECT DEPS: Currently the deps are [layoutMode, viewNodes.length]. The radial BFS reads viewLinks, so add viewLinks to the dependency array. Edge cases: (a) If graph has cycles, BFS may not reach all nodes — default _depth=0 is fine, they cluster at center. (b) If all nodes are roots (no blocks edges), all get _depth=0 and cluster at center ring — this is correct behavior for a flat graph. Acceptance: selecting Radial layout arranges nodes in concentric rings by dependency depth. Root nodes at center, leaf nodes on outer rings. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:08.116392+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:29:23.576116+13:00","closed_at":"2026-02-12T11:29:23.576116+13:00","close_reason":"Completed: cee4c87","dependencies":[{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:08.11827+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.58504+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.4","title":"Implement cluster-by-prefix layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"cluster\")' branch. Implementation steps: (1) COMPUTE PREFIX CENTERS: Get unique prefixes from viewNodes via new Set(viewNodes.map(n => n.prefix)). Arrange center positions in a circle: radius = Math.max(200, prefixes.length * 50). For each prefix at index i, center = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius } where angle = (2 * Math.PI * i / count) - Math.PI / 2 (start from top). Store in a local Map<string, {x: number, y: number}>. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same as force/radial branches). (3) CLEAR STALE FORCES: fg.d3Force('radial', null) to remove any leftover radial force from a previous layout. (4) CONFIGURE FORCES: fg.d3Force('x', forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.3)); fg.d3Force('y', forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.3)); fg.d3Force('charge')?.strength(-40).distanceMax(250); fg.d3Force('link')?.distance(60).strength(0.3); fg.d3Force('center')?.strength(0.01); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 6).strength(0.7)). Design notes: Uses forceX/forceY per-node accessors to pull each node toward its prefix cluster center. Weaker charge (-40) keeps nodes within their cluster rather than repelling to distant positions. Cross-prefix links will stretch across clusters, making inter-project dependencies visually obvious. Edge cases: (a) Single-prefix graph — all nodes cluster at one position, which is fine (effectively same as force). (b) node.prefix is always set per lib/types.ts:51 so the Map lookup always succeeds. Acceptance: selecting Cluster layout spatially groups nodes by project prefix. Multi-repo graphs show distinct clusters. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:14.896035+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:29:23.709649+13:00","closed_at":"2026-02-12T11:29:23.709649+13:00","close_reason":"Completed: cee4c87","dependencies":[{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:14.898283+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.764197+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.5","title":"Implement spread layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"spread\")' branch. This is the simplest layout — identical to the existing force branch but with tuned parameters for maximum spacing and readability. Implementation steps: (1) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as force branch ~line 642). (2) CLEAR STALE FORCES: fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); — remove any custom forces from radial/cluster modes. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-300).distanceMax(500); fg.d3Force('link')?.distance(180).strength(0.4); fg.d3Force('center')?.strength(0.02); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 8).strength(0.8)). Key differences from force mode: charge is -300 vs -180 (much stronger repulsion), link distance is 180 vs 90-120 (wider gaps), center is 0.02 vs 0.03 (weaker pull so graph can spread), collision radius is +8 vs +6 (more buffer). Inspired by beads_viewer reference: LAYOUT_PRESETS.spread uses linkDistance 180, chargeStrength -300, centerStrength 0.02. No link distance per-connection intelligence needed (unlike force mode which varies by connection count) — uniform spacing is the point. Acceptance: selecting Spread layout produces a well-spaced graph suitable for screenshots and exports. Nodes should not overlap. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:20.51105+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:29:23.830149+13:00","closed_at":"2026-02-12T11:29:23.830149+13:00","close_reason":"Completed: cee4c87","dependencies":[{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:20.513717+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.949509+13:00","created_by":"daviddao"}]},{"id":"beads-map-9lm.6","title":"Add layout toggle buttons for Radial, Cluster, Spread","description":"In components/BeadsGraph.tsx, expand the existing 2-button layout segmented control (Force/DAG) to 5 buttons: Force, DAG, Radial, Cluster, Spread. The existing buttons are at ~line 1609 inside a div with className 'flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden'. Each button follows the exact same pattern: (a) onClick={() => setLayoutMode('radial')} etc., (b) active state: bg-emerald-500 text-white, inactive: text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50, (c) inner span with SVG icon (16x16 viewBox) + hidden sm:inline text label, (d) w-px bg-zinc-200 divider between each button. Existing buttons to keep: Force (scattered dots with connections icon, ~line 1610-1638) and DAG (top-down tree icon, ~line 1641-1672). New buttons to add after DAG: (1) RADIAL: icon = concentric circles (e.g., circle cx=8 cy=8 r=2 filled + circle r=5 stroke-only + circle r=7 stroke-only opacity=0.4), label 'Radial'. (2) CLUSTER: icon = grouped dots (e.g., 3 dots upper-left clustered + 3 dots lower-right clustered, suggesting two groups), label 'Cluster'. (3) SPREAD: icon = scattered dots with ample spacing (e.g., 5 small dots spread across the 16x16 viewBox with no connections, suggesting maximum spacing), label 'Spread'. Each new button needs a divider (w-px bg-zinc-200) before it. The layoutMode state variable is at ~line 255: useState<LayoutMode>('dag'). The bootstrap trick (~line 666) auto-switches from DAG to force on initial load — this should remain unchanged (new layouts are only activated by user click). Acceptance: all 5 buttons render correctly, clicking each switches the layout. Active button is visually highlighted. pnpm build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T11:24:29.190312+13:00","created_by":"daviddao","updated_at":"2026-02-12T11:30:38.877928+13:00","closed_at":"2026-02-12T11:30:38.877928+13:00","close_reason":"Completed: 8d08e1c","dependencies":[{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:29.192269+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.3","type":"blocks","created_at":"2026-02-12T11:24:36.145483+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.4","type":"blocks","created_at":"2026-02-12T11:24:36.312505+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.5","type":"blocks","created_at":"2026-02-12T11:24:36.484971+13:00","created_by":"daviddao"}]},{"id":"beads-map-a2e","title":"v0.3.3: Auto-fit toggle, pulse animation, and UX polish","description":"## Overview\n\nThis release adds several features and fixes:\n\n1. **Auto-fit toggle** — Button in top-left row 2 to enable/disable automatic camera zoom-to-fit after data updates. Both zoomToFit calls in BeadsGraph gated by autoFit prop. Top-left controls reorganized into two rows.\n2. **Pulse animation** — Continuous emerald ripple rings highlight the node with the most recent activity event. Toggle button next to auto-fit. Uses autoPauseRedraw for smooth animation without fighting zoom.\n3. **Remove semantic zoom fade** — Nodes and links stay visible at all zoom levels instead of fading out when zoomed out.\n4. **Heartbeads attribution** — Link to github.com/daviddao/heartbeads in help panel footer.\n5. **Tutorial update** — New step 2 \"View Controls\" spotlighting the second row of controls.\n\n## Commits\n\n- `0975960` — Add auto-fit toggle and reorganize top-left controls into two rows\n- `5eaabaa` — beads: close epic beads-map-3pg\n- `e4ada72` — Remove semantic zoom fade: nodes and links stay visible at all zoom levels\n- `91e338d` — Add Heartbeads attribution to help panel footer\n- `157fb02` — Fix broken zoom + add pulse ripple on most-recent-activity node\n\n## Files modified\n\n- `components/BeadsGraph.tsx` — autoFit/pulse props, gated zoomToFit, two-row layout, toggle buttons, ripple animation in paintNode, autoPauseRedraw, removed semantic zoom fade\n- `app/page.tsx` — autoFit/showPulse state, pulseNodeId from activityFeed, props wiring\n- `components/TutorialOverlay.tsx` — New step 2 \"View Controls\"\n- `components/HelpPanel.tsx` — Auto-fit bullet, Heartbeads attribution link","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-12T17:15:36.731173+13:00","created_by":"daviddao","updated_at":"2026-02-12T17:16:16.604875+13:00","closed_at":"2026-02-12T17:16:16.604875+13:00","close_reason":"Closed"},{"id":"beads-map-a2e.1","title":"Add auto-fit toggle and reorganize top-left controls into two rows","description":"Retroactive task for commit 0975960.\n\n- Added autoFit state (default: on) that gates both zoomToFit calls in BeadsGraph.tsx (lines 838 and 865)\n- Reorganized top-left controls from single row to two rows: row 1 = layout modes, row 2 = view toggles (Collapse/Clusters/Auto-fit)\n- Auto-fit button uses Pattern C styling (emerald active, frosted glass inactive)\n- Added tutorial step 2 \"View Controls\" spotlighting the new second row (data-tutorial=\"view-controls\")\n- Added Auto-fit bullet to static help content in HelpPanel.tsx\n\nFiles: components/BeadsGraph.tsx, app/page.tsx, components/TutorialOverlay.tsx, components/HelpPanel.tsx","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T17:15:45.736117+13:00","created_by":"daviddao","updated_at":"2026-02-12T17:16:16.443982+13:00","closed_at":"2026-02-12T17:16:16.443982+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-a2e.1","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:15:45.738001+13:00","created_by":"daviddao"}]},{"id":"beads-map-a2e.2","title":"Remove semantic zoom fade from nodes and links","description":"Retroactive task for commit e4ada72.\n\nRemoved the semantic zoom fade logic from paintNode and paintLink in BeadsGraph.tsx. Previously, nodes and links faded to invisible when zoomed out past globalScale 0.1-0.2. Now they stay fully visible at all zoom levels.\n\nRemoved code:\n- paintNode: FADE_OUT_MIN/FADE_OUT_MAX constants and zoomFade multiplier (was lines 953-961)\n- paintLink: LINK_FADE_OUT_MIN/LINK_FADE_OUT_MAX and linkZoomFade multiplier (was lines 1238-1245)\n\nCluster labels (controlled by Clusters toggle) still fade in when zoomed out — that is a separate feature.\n\nFile: components/BeadsGraph.tsx","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T17:15:54.275929+13:00","created_by":"daviddao","updated_at":"2026-02-12T17:16:16.48495+13:00","closed_at":"2026-02-12T17:16:16.48495+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-a2e.2","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:15:54.279327+13:00","created_by":"daviddao"}]},{"id":"beads-map-a2e.3","title":"Add Heartbeads attribution to help panel footer","description":"Retroactive task for commit 91e338d.\n\nAdded a sentence to the help panel footer in HelpPanel.tsx mentioning that Heartbeads is built for the GainForest agentic workflow, with a link to github.com/daviddao/heartbeads.\n\nFile: components/HelpPanel.tsx","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T17:16:00.090854+13:00","created_by":"daviddao","updated_at":"2026-02-12T17:16:16.525792+13:00","closed_at":"2026-02-12T17:16:16.525792+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-a2e.3","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:16:00.098891+13:00","created_by":"daviddao"}]},{"id":"beads-map-a2e.4","title":"Add pulse ripple animation on most-recently-active node","description":"Retroactive task for commit 157fb02.\n\nInspired by the OccurrenceMap blinking animation in hyperscan, added a continuous emerald ripple animation on the node with the most recent activity event.\n\nImplementation:\n- page.tsx: Added showPulse state (default true), computed pulseNodeId from activityFeed[0].nodeId\n- BeadsGraph.tsx: Added pulseNodeId/showPulse/onShowPulseToggle props with refs synced via useEffect\n- paintNode: 3 staggered expanding/fading emerald rings (2s period, 500ms stagger) drawn after the node body\n- Ripple radius and line width scale with 1/globalScale for visibility at any zoom level (~30 screen pixels)\n- Uses autoPauseRedraw={false} on ForceGraph2D when pulse is active (instead of refreshGraph zoom nudge which broke user zoom)\n- Pulse toggle button added to row 2 of top-left controls, next to Auto-fit\n\nKey discovery: refreshGraph (zoom nudge trick) called at 60fps fights with user scroll-to-zoom input. The fix is to use autoPauseRedraw={false} which is the officially recommended approach for custom dynamic animations in react-force-graph-2d.\n\nFiles: components/BeadsGraph.tsx, app/page.tsx","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T17:16:11.442635+13:00","created_by":"daviddao","updated_at":"2026-02-12T17:16:16.565473+13:00","closed_at":"2026-02-12T17:16:16.565473+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-a2e.4","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:16:11.444833+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh","title":"ATProto login with identity display and annotation support","description":"Add ATProto (Bluesky) OAuth login to beads-map, porting the auth infrastructure from Hyperscan. Users can sign in with their Bluesky handle, see their avatar/name in the header, and (in future work) leave annotations on issues in the graph.\n\nARCHITECTURE:\n- OAuth 2.0 Authorization Code flow with PKCE via @atproto/oauth-client-node\n- Encrypted cookie sessions via iron-session (no client-side token storage)\n- React Context (AuthProvider + useAuth hook) for client-side auth state\n- 6 API routes (login, callback, client-metadata, jwks, status, logout)\n- Sign-in modal + avatar dropdown in header top-right (next to stats)\n- Support both confidential (production) and public (dev) OAuth client modes\n\nWHY HYPERSCAN'S APPROACH:\nHyperscan already solved this for ATProto in a Next.js App Router context. Their implementation is production-grade, handles all edge cases (reconnection, network errors, session restoration), and follows OAuth best practices. We'll port the core auth infrastructure verbatim, then adapt the UI to match beads-map's design.\n\nDEPENDENCY ON PAST WORK:\nThis modifies app/page.tsx (header) and app/layout.tsx (AuthProvider wrapper), which were last touched by the live-update epic (beads-map-3jy). The two features are independent but touch the same files.\n\nSCOPE:\nThis epic covers ONLY the auth infrastructure and identity display (avatar in header). Annotation features (writing comments to ATProto) will be a separate follow-up epic that builds on the authenticated agent helper (task .7).\n\nTASK BREAKDOWN:\n.1 - Install deps + env setup\n.2 - Session management (iron-session)\n.3 - OAuth client factory (confidential + public modes)\n.4 - Auth API routes (login, callback, status, logout, metadata, jwks)\n.5 - AuthProvider + useAuth hook\n.6 - AuthButton component (sign-in modal + avatar dropdown)\n.7 - Authenticated agent helper (for future annotation writes)\n.8 - Build + integration test\n\nFILES TO CREATE (13 files):\n- .env.example\n- scripts/generate-jwk.js\n- lib/env.ts\n- lib/session.ts\n- lib/auth/client.ts\n- lib/auth.tsx\n- lib/agent.ts\n- app/api/login/route.ts\n- app/api/oauth/callback/route.ts\n- app/api/oauth/client-metadata.json/route.ts\n- app/api/oauth/jwks.json/route.ts\n- app/api/status/route.ts\n- app/api/logout/route.ts\n- components/AuthButton.tsx\n\nFILES TO MODIFY (2 files):\n- package.json (add 5 dependencies)\n- app/layout.tsx (wrap in AuthProvider)\n- app/page.tsx (add <AuthButton /> to header)","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-10T23:56:19.74299+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:12.831198+13:00","closed_at":"2026-02-11T00:06:12.831198+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.001081+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.1","title":"Install ATProto auth dependencies and environment setup","description":"Foundation task: install npm packages, create environment variable template, add JWK generation script, and create env validation utility.\n\nPART 1: Install dependencies\n\nAdd to package.json:\n pnpm add @atproto/oauth-client-node@^0.3.15 @atproto/api@^0.18.16 @atproto/jwk-jose@^0.1.11 @atproto/syntax@^0.4.2 iron-session@^8.0.4\n\nPART 2: Create .env.example\n\nFile: /Users/david/Projects/gainforest/beads-map/.env.example\nContent:\n# ATProto OAuth Authentication\n# Copy this file to .env.local and fill in the values\n\n# Required for all modes: Session encryption key (32+ chars)\nCOOKIE_SECRET=development-secret-at-least-32-chars!!\n\n# Required for production: Your app's public URL\n# Leave empty for localhost dev mode (uses public OAuth client)\nPUBLIC_URL=\n\n# Optional: Dev server port (default 3000)\nPORT=3000\n\n# Required for production confidential client: ES256 JWK private key\n# Generate with: node scripts/generate-jwk.js\n# Leave empty for localhost dev mode\nATPROTO_JWK_PRIVATE=\n\nPART 3: Create scripts/generate-jwk.js\n\nCopy verbatim from Hyperscan: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\nMake executable: chmod +x scripts/generate-jwk.js\n\nPART 4: Create lib/env.ts\n\nFile: /Users/david/Projects/gainforest/beads-map/lib/env.ts\nPort from Hyperscan's /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n- Validates COOKIE_SECRET, PUBLIC_URL, PORT, ATPROTO_JWK_PRIVATE\n- Provides defaults for dev mode\n- Exports typed env object\n\nREFERENCE FILES:\n- Hyperscan package.json: /Users/david/Projects/gainforest/hyperscan/package.json\n- Hyperscan generate-jwk.js: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\n- Hyperscan env.ts: /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n\nACCEPTANCE CRITERIA:\n- All 5 packages installed in package.json dependencies\n- .env.example exists with all 4 env vars documented\n- scripts/generate-jwk.js exists and is executable\n- lib/env.ts exists and validates env vars\n- pnpm build passes (env.ts may not be used yet, but must compile)\n- .gitignore already has .env* (from earlier work)\n\nNOTES:\n- Do NOT create .env.local -- user will do that manually\n- Do NOT commit any actual secrets\n- The env.ts validation will allow missing values for dev mode (defaults kick in)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:56:38.692755+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.670005+13:00","closed_at":"2026-02-11T00:06:08.670005+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.1","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:56:38.694406+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.2","title":"Create session management with iron-session","description":"Port Hyperscan's iron-session setup for encrypted cookie-based authentication sessions.\n\nPURPOSE: iron-session encrypts session data into cookies (no database needed). The session stores user identity (did, handle, displayName, avatar) and OAuth session tokens for authenticated API calls.\n\nCREATE FILE: lib/session.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nKey changes when porting:\n1. Cookie name: 'impact_indexer_sid' -> 'beads_map_sid'\n2. Import env from our lib/env.ts (not Hyperscan's path)\n3. Keep the same session shape:\n interface Session {\n did?: string\n handle?: string\n displayName?: string\n avatar?: string\n returnTo?: string\n oauthSession?: string\n }\n4. Keep the same exports:\n - getSession(request/cookies)\n - getRawSession(request/cookies)\n - clearSession(request/cookies)\n\nIMPLEMENTATION NOTES:\n- Use env.COOKIE_SECRET for encryption\n- secure: true only when env.PUBLIC_URL is set (production)\n- maxAge: 30 days (same as Hyperscan)\n- Support both Next.js Request and cookies() from next/headers (for Server Components vs API routes)\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nACCEPTANCE CRITERIA:\n- lib/session.ts exists\n- Exports getSession, getRawSession, clearSession\n- Session type matches Hyperscan's shape\n- Cookie is secure in production (when PUBLIC_URL set), insecure in dev\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:01.110787+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.757647+13:00","closed_at":"2026-02-11T00:06:08.757647+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:01.112211+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh.1","type":"blocks","created_at":"2026-02-10T23:57:01.113311+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.3","title":"Create OAuth client factory with dual mode support","description":"Port Hyperscan's OAuth client setup with support for both confidential (production) and public (localhost dev) client modes.\n\nPURPOSE: The OAuth client handles the full Authorization Code flow with PKCE. It needs two modes:\n- Public client (dev): loopback client_id, no secrets, works on localhost\n- Confidential client (prod): uses ES256 JWK for private_key_jwt auth, requires PUBLIC_URL\n\nCREATE FILE: lib/auth/client.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nKey adaptations:\n1. Import Session type from our lib/session.ts\n2. Import env from our lib/env.ts\n3. clientName: 'Beads Map' (not 'Impact Indexer')\n4. The sessionStore must sync OAuth session data between in-memory Map and iron-session cookies (critical for serverless)\n\nIMPLEMENTATION NOTES:\n- Export getGlobalOAuthClient() as the main API\n- If PUBLIC_URL is set: confidential mode (load JWK from env.ATPROTO_JWK_PRIVATE, publish client-metadata.json and jwks.json)\n- If PUBLIC_URL is empty: public mode (loopback client_id, use 127.0.0.1 not localhost, no JWK)\n- The client is cached globally per process (singleton pattern)\n- Session store serializes OAuth session (tokens, DPoP keys) to/from cookie.oauthSession field\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nACCEPTANCE CRITERIA:\n- lib/auth/client.ts exists (create lib/auth/ dir)\n- Exports getGlobalOAuthClient()\n- Supports both confidential and public modes based on env.PUBLIC_URL\n- Session store syncs with iron-session cookie\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:16.261043+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.840672+13:00","closed_at":"2026-02-11T00:06:08.840672+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:16.26232+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh.2","type":"blocks","created_at":"2026-02-10T23:57:16.263416+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.4","title":"Create 6 authentication API routes","description":"Port all 6 auth-related API routes from Hyperscan.\n\nCREATE 6 ROUTE FILES:\n\n1. app/api/login/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n - POST handler\n - Validates handle with @atproto/syntax isValidHandle\n - Calls client.authorize(handle)\n - Stores returnTo in session cookie\n - Returns { redirectUrl }\n\n2. app/api/oauth/callback/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n - GET handler\n - Calls client.callback(params) to exchange code for tokens\n - Fetches profile via @atproto/api Agent\n - Saves { did, handle, displayName, avatar, oauthSession } to session cookie\n - Redirects to returnTo (303 redirect)\n - Has retry logic for network errors\n\n3. app/api/oauth/client-metadata.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n - GET handler (only in confidential mode)\n - Returns OAuth client metadata JSON per ATProto spec\n\n4. app/api/oauth/jwks.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n - GET handler (only in confidential mode)\n - Returns JWKS public keys\n\n5. app/api/status/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n - GET handler\n - Reads session cookie\n - Returns { authenticated, did, handle, displayName, avatar } or { authenticated: false }\n\n6. app/api/logout/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n - POST handler\n - Calls clearSession()\n - Returns { success: true }\n\nKEY NOTES:\n- All routes use export const dynamic = 'force-dynamic'\n- Import from our lib/ paths (not Hyperscan's src/lib/)\n- The callback route should handle both OAuth success and error states\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n\nACCEPTANCE CRITERIA:\n- All 6 route files exist in correct paths\n- Each exports the correct HTTP method handler (GET or POST)\n- All routes compile and pnpm build passes\n- All routes use dynamic = 'force-dynamic'\n- Imports use beads-map paths (not Hyperscan's)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:32.923191+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:08.921439+13:00","closed_at":"2026-02-11T00:06:08.921439+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:32.924539+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:57:32.926286+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.5","title":"Create AuthProvider and useAuth hook","description":"Port Hyperscan's client-side auth state management: React Context provider and useAuth hook. Wrap the app in AuthProvider.\n\nCREATE FILE: lib/auth.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n\nKey pieces:\n1. AuthContext with shape: state, login, logout\n2. AuthProvider component:\n - Manages auth status: idle, authorizing, authenticated, error\n - On mount: checks /api/status to restore session\n - Exposes login(handle) and logout() functions\n3. useAuth() hook:\n - Returns status, session, isLoading, isAuthenticated, login, logout\n - session shape: did, handle, displayName, avatar or null\n\nLOGIN FLOW client-side:\n1. User calls login(handle)\n2. POST to /api/login with handle and returnTo\n3. Server returns redirectUrl\n4. window.location.href = redirectUrl (redirect to PDS)\n5. After OAuth callback completes, browser redirects back to returnTo\n6. AuthProvider re-checks /api/status and updates context\n\nLOGOUT FLOW:\n1. User calls logout()\n2. POST to /api/logout\n3. Clear local state\n4. Optionally reload or redirect\n\nMODIFY FILE: app/layout.tsx\n\nWrap children in AuthProvider from lib/auth.tsx\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n/Users/david/Projects/gainforest/hyperscan/src/app/layout.tsx (for wrapper example)\n\nACCEPTANCE CRITERIA:\n- lib/auth.tsx exists\n- Exports AuthProvider, useAuth\n- useAuth returns correct shape\n- app/layout.tsx wraps children in AuthProvider\n- pnpm build passes\n- No client-side token storage, all session state comes from /api/status reading cookies","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:57:56.261859+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.003714+13:00","closed_at":"2026-02-11T00:06:09.003714+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:56.263692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh.4","type":"blocks","created_at":"2026-02-10T23:57:56.264726+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.6","title":"Create AuthButton component and integrate into header","description":"Port Hyperscan's AuthButton component (sign-in modal + avatar dropdown) and add it to the page.tsx header top-right, next to the stats.\n\nCREATE FILE: components/AuthButton.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n\nKey UI elements:\n1. When logged out: Sign in text link\n2. Click opens modal with:\n - Title: Sign in with ATProto\n - Handle input field with placeholder alice.bsky.social\n - Helper text: Just a username? We will add .bsky.social for you\n - Cancel + Connect buttons (emerald-600 green for Connect)\n - Error display area\n - Backdrop blur overlay\n3. When logged in: Avatar (24x24 rounded) + display name/handle (truncated)\n4. Click avatar opens dropdown with:\n - Profile link (to /profile if we add that page, or just show did for now)\n - Divider\n - Sign out button\n\nThe component uses useAuth() hook from lib/auth.tsx for status, session, login, logout.\n\nADAPT STYLING:\n- Use beads-map's design tokens: emerald-500/600, zinc colors, same border-radius, same shadows\n- Match the existing header style (text-xs, simple, clean)\n- Modal should be centered with 20vh from top (same as Hyperscan)\n\nMODIFY FILE: app/page.tsx\n\nAdd AuthButton to header right section (line 644-662). Current structure:\n Left: Logo + title\n Center: Search\n Right: Stats (total issues, deps, projects)\n\nNew structure:\n Right: Stats + vertical divider + <AuthButton />\n\nThe stats div stays, just add:\n <span className=\"w-px h-4 bg-zinc-200\" />\n <AuthButton />\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n/Users/david/Projects/gainforest/hyperscan/src/components/Header.tsx (for placement example)\n\nACCEPTANCE CRITERIA:\n- components/AuthButton.tsx exists\n- Shows sign-in text link when logged out\n- Shows avatar + name/handle when logged in\n- Modal opens on click when logged out, dropdown on click when logged in\n- Integrated into page.tsx header right section\n- Matches beads-map visual style (emerald, zinc, text-xs)\n- pnpm build passes\n- Dev server shows the button (no visual regression on existing header)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:58:15.698478+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.086644+13:00","closed_at":"2026-02-11T00:06:09.086644+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:15.699689+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh.5","type":"blocks","created_at":"2026-02-10T23:58:15.700911+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.7","title":"Create authenticated agent helper for ATProto API calls","description":"Create a server-side utility that restores an authenticated ATProto Agent from the session cookie. This is the foundation for future annotation writes.\n\nCREATE FILE: lib/agent.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nPurpose:\n- Server-side only (API routes, Server Components, Server Actions)\n- Reads session cookie to get oauthSession data\n- Calls client.restore(did, oauthSession) to rebuild OAuth session\n- Returns @atproto/api Agent instance for making authenticated ATProto API calls\n\nKey function:\nexport async function getAuthenticatedAgent(request: Request): Promise<Agent>\n - Reads session via getSession(request)\n - If no session.did or session.oauthSession: throw Error(Unauthorized)\n - Deserialize oauthSession\n - Call client.restore(did, sessionData)\n - Return new Agent(restoredOAuthSession)\n\nUSAGE EXAMPLE (for future annotation API):\n// In app/api/annotations/route.ts\nimport { getAuthenticatedAgent } from '@/lib/agent'\n\nexport async function POST(request: Request) {\n const agent = await getAuthenticatedAgent(request)\n // agent.com.atproto.repo.createRecord(...)\n}\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nACCEPTANCE CRITERIA:\n- lib/agent.ts exists\n- Exports getAuthenticatedAgent(request)\n- Throws clear error if not authenticated\n- Returns Agent instance ready for ATProto API calls\n- pnpm build passes\n- NOT YET USED (will be used in future annotation epic), but must compile","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:58:28.649345+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.166246+13:00","closed_at":"2026-02-11T00:06:09.166246+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:28.65065+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:58:28.65195+13:00","created_by":"daviddao"}]},{"id":"beads-map-cvh.8","title":"Build verification and integration test","description":"Final verification that the ATProto login system works end-to-end in dev mode (public OAuth client).\n\nPART 1: Build check\n pnpm build -- must pass with zero errors\n\nPART 2: Dev server smoke test\n\nStart dev server with existing beads project:\n BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n\nBrowser tests:\n1. Open http://localhost:3000\n2. Header should show: Logo | Search | Stats | Sign in (new!)\n3. Click Sign in -> modal opens\n4. Enter a Bluesky handle (e.g. alice.bsky.social)\n5. Click Connect -> redirects to bsky.social OAuth consent screen\n6. Approve -> redirects back to beads-map\n7. Header should now show: Logo | Search | Stats | Avatar + name (alice.bsky.social)\n8. Click avatar -> dropdown opens with Sign out\n9. Click Sign out -> back to unauthenticated state\n10. Refresh page -> session persists (avatar still shows)\n11. Open devtools Network tab -> no client-side token storage, only encrypted cookies\n\nServer logs should show:\n- Watching N files for changes (from file watcher, unrelated)\n- No OAuth client errors\n- If using localhost: should see public client mode log\n\nPART 3: Session persistence check\n- Login\n- Close browser tab\n- Reopen http://localhost:3000\n- Should still be logged in (session cookie persists)\n\nPART 4: Error handling check\n- Click Sign in\n- Enter invalid handle (e.g. test.invalid)\n- Should show error message in modal (not crash)\n\nFUNCTIONAL CHECKS:\n- Graph still works (nodes, links, search, layout toggle)\n- Stats still update in real-time\n- No console errors during login/logout flow\n- No memory leaks (EventSource from earlier work still cleans up)\n\nPERFORMANCE CHECKS:\n- Page load time not significantly affected by auth check\n- No jank during modal open/close animations\n\nKNOWN LIMITATIONS (OK for this epic):\n- /profile route does not exist yet (clicking Profile in dropdown would 404)\n- No annotation features yet (will be follow-up epic)\n- Confidential client mode not tested (requires PUBLIC_URL + JWK in production)\n\nACCEPTANCE CRITERIA:\n- pnpm build passes\n- Can log in via localhost OAuth in dev mode\n- Avatar + name display correctly when logged in\n- Session persists across page refresh\n- Logout works\n- No console errors\n- All existing features (graph, search, live updates) still work","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:58:49.014769+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:06:09.245646+13:00","closed_at":"2026-02-11T00:06:09.245646+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:49.015822+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.6","type":"blocks","created_at":"2026-02-10T23:58:49.016931+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.7","type":"blocks","created_at":"2026-02-10T23:58:49.017826+13:00","created_by":"daviddao"}]},{"id":"beads-map-dwk","title":"Legend color mode selector with Catppuccin Mocha palette","description":"## Goal\n\nAdd a **color mode selector** to the bottom-right legend panel in BeadsGraph. Users can switch between 4 modes that change the **node body fill color**:\n\n1. **Status** (default) — nodes colored by open/in_progress/blocked/deferred/closed using existing `STATUS_COLORS`\n2. **Owner** — nodes colored by `createdBy` field, using Catppuccin Mocha accent palette (14 colors)\n3. **Assignee** — nodes colored by `assignee` field, using Catppuccin Mocha accent palette\n4. **Prefix** — nodes colored by `prefix` field, using Catppuccin Mocha accent palette\n\nThe outer ring color (prefix-based via FNV hash) stays unchanged in all modes.\n\n## Catppuccin Mocha Accent Colors (14)\n\n| Name | Hex |\n|---|---|\n| Rosewater | #f5e0dc |\n| Flamingo | #f2cdcd |\n| Pink | #f5c2e7 |\n| Mauve | #cba6f7 |\n| Red | #f38ba8 |\n| Maroon | #eba0b3 |\n| Peach | #fab387 |\n| Yellow | #f9e2af |\n| Green | #a6e3a1 |\n| Teal | #94e2d5 |\n| Sky | #89dceb |\n| Sapphire | #74c7ec |\n| Blue | #89b4fa |\n| Lavender | #b4befe |\n\nUnassigned/unknown uses Mocha Surface2 (#585b70) as a neutral muted color.\n\n## Architecture\n\n- **State ownership:** `colorMode` state lives in `app/page.tsx`, passed as prop to BeadsGraph\n- **Ref pattern:** BeadsGraph stores `colorMode` in a ref (like selectedNodeRef, hoveredNodeRef) so paintNode (which has [] deps) can read it without re-creating the callback\n- **getNodeColor replacement:** The module-level `getNodeColor(node)` function at BeadsGraph.tsx:66-68 becomes color-mode-aware, reading from a ref\n- **Legend panel:** The existing inlined legend at BeadsGraph.tsx:1911-1943 gets a segmented control (4 buttons, same style as layout mode buttons) and a dynamic legend section that shows relevant color dots based on the active mode\n- **Minimap:** Uses the same getNodeColor function at BeadsGraph.tsx:1508, so it automatically reflects the color mode\n\n## Files to modify\n\n1. `lib/types.ts` — Add ColorMode type, CATPPUCCIN_MOCHA_ACCENTS array, getPersonColor() function\n2. `app/page.tsx` — Add colorMode state, pass as prop\n3. `components/BeadsGraph.tsx` — Accept colorMode prop, ref pattern, update getNodeColor, update legend panel\n\n## Acceptance criteria\n\n- [ ] 4 color modes: Status, Owner, Assignee, Prefix\n- [ ] Segmented control in legend panel to switch modes\n- [ ] Node body fill changes per mode; outer ring stays prefix-based\n- [ ] Dynamic legend shows relevant items (statuses, people, or prefixes) with colored dots\n- [ ] Only items present in visible nodes appear in legend\n- [ ] Catppuccin Mocha accent palette used for person/prefix coloring\n- [ ] Unassigned nodes shown with muted gray + \"Unassigned\" label\n- [ ] Minimap reflects current color mode\n- [ ] `pnpm build` passes with zero errors","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-12T13:58:33.624445+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:03:52.455754+13:00","closed_at":"2026-02-12T14:03:52.455754+13:00","close_reason":"Closed"},{"id":"beads-map-dwk.1","title":"Add ColorMode type, Catppuccin palette, and getPersonColor to lib/types.ts","description":"## What\n\nAdd the color mode infrastructure to `lib/types.ts`. This is the foundation that all other tasks depend on.\n\n## File: `lib/types.ts` (currently 205 lines)\n\n### 1. Add ColorMode type (after line 70, after GraphNode interface)\n\n```typescript\nexport type ColorMode = \"status\" | \"owner\" | \"assignee\" | \"prefix\";\n```\n\n### 2. Add COLOR_MODE_LABELS (after STATUS_LABELS around line 122)\n\n```typescript\nexport const COLOR_MODE_LABELS: Record<ColorMode, string> = {\n status: \"Status\",\n owner: \"Owner\",\n assignee: \"Assignee\",\n prefix: \"Prefix\",\n};\n```\n\n### 3. Add CATPPUCCIN_MOCHA_ACCENTS (after PRIORITY_COLORS around line 138)\n\n```typescript\n/** Catppuccin Mocha accent colors — 14 visually distinct, ordered for max contrast between adjacent indices */\nexport const CATPPUCCIN_MOCHA_ACCENTS: string[] = [\n \"#f38ba8\", // Red\n \"#94e2d5\", // Teal\n \"#fab387\", // Peach\n \"#89b4fa\", // Blue\n \"#a6e3a1\", // Green\n \"#cba6f7\", // Mauve\n \"#f9e2af\", // Yellow\n \"#74c7ec\", // Sapphire\n \"#f5c2e7\", // Pink\n \"#89dceb\", // Sky\n \"#eba0b3\", // Maroon\n \"#b4befe\", // Lavender\n \"#f2cdcd\", // Flamingo\n \"#f5e0dc\", // Rosewater\n];\n\n/** Catppuccin Mocha Surface2 — used for \"unassigned\" / unknown values */\nexport const CATPPUCCIN_UNASSIGNED = \"#585b70\";\n```\n\nNOTE: The array is NOT in Catppuccin's native order. It's reordered so that adjacent indices have maximal visual contrast (alternating warm/cool). This matters because if two people hash to adjacent indices, they should still be visually distinguishable.\n\n### 4. Add CATPPUCCIN_ACCENT_NAMES (for legend labels)\n\n```typescript\nexport const CATPPUCCIN_ACCENT_NAMES: string[] = [\n \"Red\", \"Teal\", \"Peach\", \"Blue\", \"Green\", \"Mauve\", \"Yellow\",\n \"Sapphire\", \"Pink\", \"Sky\", \"Maroon\", \"Lavender\", \"Flamingo\", \"Rosewater\",\n];\n```\n\n### 5. Add getPersonColor function (after getPrefixColor around line 191)\n\n```typescript\n/**\n * Deterministically map a person handle/name to a Catppuccin Mocha accent color.\n * Uses FNV-1a hash (same as hashColor) modulo 14 to index into CATPPUCCIN_MOCHA_ACCENTS.\n * Returns CATPPUCCIN_UNASSIGNED for undefined/null/empty strings.\n */\nexport function getPersonColor(person: string | undefined): string {\n if (!person) return CATPPUCCIN_UNASSIGNED;\n let hash = 2166136261; // FNV offset basis\n for (let i = 0; i < person.length; i++) {\n hash ^= person.charCodeAt(i);\n hash = (hash * 16777619) >>> 0;\n }\n return CATPPUCCIN_MOCHA_ACCENTS[hash % CATPPUCCIN_MOCHA_ACCENTS.length];\n}\n\n/**\n * Deterministically map a prefix string to a Catppuccin Mocha accent color.\n * Same approach as getPersonColor but for project prefixes.\n */\nexport function getCatppuccinPrefixColor(prefix: string): string {\n return getPersonColor(prefix);\n}\n```\n\n### 6. Export the new ColorMode type from the file\n\nMake sure `ColorMode` is exported (it's a type export so it will be used in both page.tsx and BeadsGraph.tsx).\n\n## Acceptance criteria\n\n- [ ] `ColorMode` type exported\n- [ ] `COLOR_MODE_LABELS` record exported with 4 entries\n- [ ] `CATPPUCCIN_MOCHA_ACCENTS` array has exactly 14 hex strings, reordered for contrast\n- [ ] `CATPPUCCIN_UNASSIGNED` constant exported (#585b70)\n- [ ] `getPersonColor(person)` returns deterministic Catppuccin color, or CATPPUCCIN_UNASSIGNED for undefined/empty\n- [ ] `getCatppuccinPrefixColor(prefix)` works the same way for prefixes\n- [ ] Existing exports (STATUS_COLORS, PREFIX_COLORS, getPrefixColor, etc.) are NOT changed\n- [ ] `pnpm build` passes\n\n## Why this is task 1\n\nAll other tasks import these types/functions. This must be done first.","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T13:58:57.258003+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:01:20.100573+13:00","closed_at":"2026-02-12T14:01:20.100573+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dwk.1","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T13:58:57.259359+13:00","created_by":"daviddao"}]},{"id":"beads-map-dwk.2","title":"Add colorMode state to page.tsx and pass as prop","description":"## What\n\nAdd the `colorMode` state to `app/page.tsx` and wire it through to `<BeadsGraph>`.\n\n## File: `app/page.tsx`\n\n### 1. Import the new type\n\nAt the top of the file, add `ColorMode` to the existing import from `@/lib/types`:\n\n```typescript\nimport type { ..., ColorMode } from \"@/lib/types\";\n```\n\nFind the existing import line for types and extend it.\n\n### 2. Add state (near other state declarations)\n\nFind the area where other state like `collapsedEpicIds`, `selectedNode`, etc. are declared. Add:\n\n```typescript\nconst [colorMode, setColorMode] = useState<ColorMode>(\"status\");\n```\n\n### 3. Pass props to BeadsGraph\n\nIn the `<BeadsGraph>` JSX (around line 1232-1251), add two new props:\n\n```tsx\n<BeadsGraph\n // ... existing props ...\n colorMode={colorMode}\n onColorModeChange={setColorMode}\n/>\n```\n\n### 4. No changes to BeadTooltip\n\nThe `BeadTooltip` component uses `prefixColor` which is independent of color mode. No changes needed there.\n\n## Acceptance criteria\n\n- [ ] `colorMode` state initialized to `\"status\"`\n- [ ] `colorMode` and `onColorModeChange` props passed to BeadsGraph\n- [ ] No other behavior changes in page.tsx\n- [ ] `pnpm build` passes (it will warn about unused props in BeadsGraph until task 3 is done, but should still compile)\n\n## Why this is task 2\n\nThis wires the state from the parent. Task 3 (BeadsGraph) consumes it.","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T13:59:08.216098+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:01:53.897087+13:00","closed_at":"2026-02-12T14:01:53.897087+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dwk.2","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T13:59:08.218477+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.2","depends_on_id":"beads-map-dwk.1","type":"blocks","created_at":"2026-02-12T13:59:08.220794+13:00","created_by":"daviddao"}]},{"id":"beads-map-dwk.3","title":"Update BeadsGraph: color-mode-aware paintNode, legend selector, and dynamic legend","description":"## What\n\nThis is the core task. Update `components/BeadsGraph.tsx` to:\n1. Accept `colorMode` + `onColorModeChange` props\n2. Make `getNodeColor()` color-mode-aware via a ref\n3. Replace the static legend panel with a mode selector + dynamic legend\n4. Minimap automatically picks up the new colors (it already calls `getNodeColor`)\n\n## File: `components/BeadsGraph.tsx`\n\n### Step 1: Update imports (line 13-14)\n\nAdd new imports from `@/lib/types`:\n\n```typescript\nimport type { GraphNode, GraphLink, ColorMode } from \"@/lib/types\";\nimport {\n STATUS_COLORS, STATUS_LABELS, PREFIX_COLORS,\n COLOR_MODE_LABELS, CATPPUCCIN_MOCHA_ACCENTS, CATPPUCCIN_UNASSIGNED,\n getPersonColor, getCatppuccinPrefixColor, getPrefixLabel,\n} from \"@/lib/types\";\n```\n\n### Step 2: Update BeadsGraphProps interface (line 26-48)\n\nAdd two new props:\n\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n /** Current color mode for node body fill */\n colorMode?: ColorMode;\n /** Callback to change color mode (from legend selector) */\n onColorModeChange?: (mode: ColorMode) => void;\n}\n```\n\n### Step 3: Destructure new props (line 200-218)\n\nAdd `colorMode = \"status\"` and `onColorModeChange` to the destructured props:\n\n```typescript\nconst BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsGraph({\n // ... existing ...\n collapsedEpicIds,\n onCollapseAll,\n onExpandAll,\n colorMode = \"status\",\n onColorModeChange,\n}, ref) {\n```\n\n### Step 4: Add colorMode ref (around line 262-268, near other refs)\n\n```typescript\nconst colorModeRef = useRef<ColorMode>(colorMode);\n```\n\nAnd add a sync effect (near the other ref sync effects around line 440-480):\n\n```typescript\nuseEffect(() => {\n colorModeRef.current = colorMode;\n refreshGraph(graphRef);\n // Also redraw minimap\n minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());\n}, [colorMode]);\n```\n\n### Step 5: Convert getNodeColor to color-mode-aware (lines 65-68)\n\nThe current code is a module-level function:\n```typescript\nfunction getNodeColor(node: GraphNode): string {\n return STATUS_COLORS[node.status] || STATUS_COLORS.open;\n}\n```\n\nThis CANNOT read from a ref because it's module-level. Two approaches:\n\n**Approach A (recommended):** Keep the module-level function but add a module-level variable that the ref syncs to:\n\n```typescript\n// Module-level color mode tracker (synced from ref in useEffect)\nlet _currentColorMode: ColorMode = \"status\";\n\nfunction getNodeColor(node: GraphNode): string {\n switch (_currentColorMode) {\n case \"owner\":\n return getPersonColor(node.createdBy);\n case \"assignee\":\n return getPersonColor(node.assignee);\n case \"prefix\":\n return getCatppuccinPrefixColor(node.prefix);\n case \"status\":\n default:\n return STATUS_COLORS[node.status] || STATUS_COLORS.open;\n }\n}\n```\n\nThen in the colorMode sync useEffect:\n```typescript\nuseEffect(() => {\n colorModeRef.current = colorMode;\n _currentColorMode = colorMode; // sync module-level variable\n refreshGraph(graphRef);\n minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());\n}, [colorMode]);\n```\n\nThis works because paintNode calls getNodeColor synchronously during rendering, and the module-level variable is always up to date when paintNode runs.\n\n### Step 6: Update the legend panel (lines 1911-1943)\n\nReplace the current legend content with:\n\n#### 6a. Stats row (keep as-is)\nThe stats row showing \"42 issues · 38 deps · 3 projects\" stays unchanged.\n\n#### 6b. Add color mode segmented control\nAfter the stats row, add a row of 4 small buttons (matching the layout mode button style from lines 1720-1908):\n\n```tsx\n{/* Color mode selector */}\n<div className=\"flex bg-zinc-100 rounded-md overflow-hidden mb-1.5\">\n {([\"status\", \"owner\", \"assignee\", \"prefix\"] as ColorMode[]).map((mode) => (\n <button\n key={mode}\n onClick={() => onColorModeChange?.(mode)}\n className={`flex-1 px-2 py-1 text-[10px] font-medium transition-colors ${\n colorMode === mode\n ? \"bg-emerald-500 text-white\"\n : \"text-zinc-500 hover:text-zinc-700 hover:bg-zinc-200/60\"\n }`}\n >\n {COLOR_MODE_LABELS[mode]}\n </button>\n ))}\n</div>\n```\n\n#### 6c. Dynamic legend section\nReplace the static status dots section (lines 1927-1936) with a dynamic section:\n\n```tsx\n<div className=\"hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5\">\n {colorMode === \"status\" && (\n <>\n {[\"open\", \"in_progress\", \"blocked\", \"deferred\", \"closed\"].map((status) => (\n <span key={status} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: STATUS_COLORS[status] }} />\n <span className=\"text-zinc-500\">{STATUS_LABELS[status]}</span>\n </span>\n ))}\n </>\n )}\n {colorMode === \"owner\" && (\n <>\n {legendItems.map(({ label, color }) => (\n <span key={label} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: color }} />\n <span className=\"text-zinc-500 truncate max-w-[80px]\">{label}</span>\n </span>\n ))}\n </>\n )}\n {colorMode === \"assignee\" && (\n <>\n {legendItems.map(({ label, color }) => (\n <span key={label} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: color }} />\n <span className=\"text-zinc-500 truncate max-w-[80px]\">{label}</span>\n </span>\n ))}\n </>\n )}\n {colorMode === \"prefix\" && (\n <>\n {legendItems.map(({ label, color }) => (\n <span key={label} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: color }} />\n <span className=\"text-zinc-500 truncate max-w-[80px]\">{label}</span>\n </span>\n ))}\n </>\n )}\n</div>\n```\n\nWhere `legendItems` is a useMemo computed from `viewNodes` + `colorMode` (see step 7).\n\n#### 6d. Update the hint text\nChange \"Size = importance · Ring = project\" to be mode-aware:\n\n```tsx\n<div className=\"hidden sm:flex flex-col gap-0.5 text-zinc-400\">\n <span>\n Size = importance · Ring = project\n {colorMode !== \"status\" && \" · Fill = \" + COLOR_MODE_LABELS[colorMode].toLowerCase()}\n </span>\n</div>\n```\n\n### Step 7: Add legendItems useMemo\n\nAdd a useMemo (before the return statement, near the other useMemos) that computes the legend items based on color mode and visible nodes:\n\n```typescript\nconst legendItems = useMemo(() => {\n if (colorMode === \"status\") return []; // handled by static STATUS_COLORS rendering\n\n const items = new Map<string, string>(); // label -> color\n\n for (const node of viewNodes) {\n let key: string | undefined;\n let color: string;\n\n switch (colorMode) {\n case \"owner\":\n key = node.createdBy || undefined;\n color = getPersonColor(key);\n items.set(key || \"Unassigned\", color);\n break;\n case \"assignee\":\n key = node.assignee || undefined;\n color = getPersonColor(key);\n items.set(key || \"Unassigned\", color);\n break;\n case \"prefix\":\n color = getCatppuccinPrefixColor(node.prefix);\n items.set(getPrefixLabel(node.prefix), color);\n break;\n }\n }\n\n // Sort: \"Unassigned\" last, others alphabetically\n return Array.from(items.entries())\n .sort(([a], [b]) => {\n if (a === \"Unassigned\") return 1;\n if (b === \"Unassigned\") return -1;\n return a.localeCompare(b);\n })\n .map(([label, color]) => ({ label, color }));\n}, [colorMode, viewNodes]);\n```\n\nIMPORTANT: `viewNodes` is declared earlier in the file as a useMemo around line 300-400. The `legendItems` memo must be declared AFTER `viewNodes` but BEFORE the JSX return. Put it near the other useMemos (there's a `clusters` useMemo around line 420).\n\n### Step 8: Cluster labels (optional consistency)\n\nThe cluster background circle at line 1324-1325 uses `PREFIX_COLORS[cluster.prefix]` which is the FNV hash HSL color. This is used for the layout cluster grouping visual, NOT for node fill. Leave it unchanged — it should stay the prefix ring color regardless of color mode.\n\n### Step 9: Verify minimap\n\nThe minimap at line 1508 already calls `getNodeColor(node)`. Since we updated the module-level `getNodeColor` to be mode-aware, the minimap will automatically reflect the correct colors. The `redrawMinimap` is triggered by the colorMode useEffect. No additional changes needed.\n\n## Key constraints\n\n- **paintNode has [] deps** — it reads from refs/module-level vars, NEVER from props directly\n- **getNodeColor is module-level** — it's called by both paintNode AND minimap. The module-level `_currentColorMode` variable is the bridge\n- **viewNodes ordering** — legendItems useMemo depends on viewNodes, so it must be declared after viewNodes\n- **refreshGraph + minimap redraw** — changing colorMode must trigger both (the useEffect handles this)\n\n## Acceptance criteria\n\n- [ ] Color mode segmented control renders in legend panel (4 buttons)\n- [ ] Clicking a button changes node body fill colors across the entire graph\n- [ ] Status mode shows the 5 status color dots (existing behavior)\n- [ ] Owner mode shows unique createdBy values with Catppuccin colors\n- [ ] Assignee mode shows unique assignee values with Catppuccin colors\n- [ ] Prefix mode shows unique prefixes with Catppuccin colors\n- [ ] \"Unassigned\" appears last in legend with muted gray (#585b70)\n- [ ] Only people/prefixes present in visible (viewNodes) nodes appear\n- [ ] Minimap reflects current color mode\n- [ ] Outer ring color is UNCHANGED (still prefix-based FNV hash)\n- [ ] No jitter or graph reset when switching modes\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T13:59:56.524903+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:03:29.438392+13:00","closed_at":"2026-02-12T14:03:29.438392+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dwk.3","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T13:59:56.526648+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.3","depends_on_id":"beads-map-dwk.1","type":"blocks","created_at":"2026-02-12T13:59:56.528522+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.3","depends_on_id":"beads-map-dwk.2","type":"blocks","created_at":"2026-02-12T13:59:56.530415+13:00","created_by":"daviddao"}]},{"id":"beads-map-dwk.4","title":"Build verification and polish","description":"## What\n\nFinal verification pass after all code changes are in place.\n\n## Steps\n\n### 1. Run `pnpm build`\n\nMust pass with zero errors. Common issues to watch for:\n- Unused imports (if any old imports from types.ts were removed)\n- Type mismatches on the new props\n- Missing exports from types.ts\n\n### 2. Fix any build errors\n\nIf the build fails, fix the issues. Common pitfalls:\n- `_currentColorMode` module-level variable — make sure it's declared with `let` not `const`\n- `ColorMode` type must be imported in both `page.tsx` and `BeadsGraph.tsx`\n- `getPersonColor` and `getCatppuccinPrefixColor` must be exported from types.ts\n- `getPrefixLabel` must be imported in BeadsGraph.tsx (it's already exported from types.ts)\n- The `legendItems` useMemo must reference `viewNodes` which is declared earlier\n\n### 3. Visual verification checklist\n\nIf dev mode is available (optional):\n- [ ] Default view shows Status mode with familiar colors\n- [ ] Switching to Owner mode recolors nodes, legend shows people\n- [ ] Switching to Assignee mode recolors nodes, legend shows assignees\n- [ ] Switching to Prefix mode recolors nodes, legend shows project names\n- [ ] Switching back to Status restores original colors\n- [ ] Minimap dots match main graph colors\n- [ ] Outer rings unchanged in all modes\n- [ ] No graph jitter/reset when switching modes\n- [ ] Legend panel doesn't overflow with many unique owners\n- [ ] \"Unassigned\" label appears when nodes lack owner/assignee\n\n### 4. Stale cache\n\nIf `PageNotFoundError` or `Cannot find module` errors occur during build:\n```bash\nrm -rf .next node_modules/.cache && pnpm build\n```\n\n## Acceptance criteria\n\n- [ ] `pnpm build` exits with code 0\n- [ ] No TypeScript errors\n- [ ] No unused imports warnings that cause build failure","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T14:00:09.752736+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:03:52.280657+13:00","closed_at":"2026-02-12T14:03:52.280657+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dwk.4","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T14:00:09.763561+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.4","depends_on_id":"beads-map-dwk.3","type":"blocks","created_at":"2026-02-12T14:00:09.76645+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi","title":"Right-click comment tooltip on graph nodes with ATProto annotations","description":"## Right-click comment tooltip on graph nodes with ATProto annotations\n\n### Summary\nAdd right-click context menu on graph nodes that opens a beautiful floating tooltip (inspired by plresearch.org dependency graph) with a text input to post ATProto comments using the `org.impactindexer.review.comment` lexicon. Fetch existing comments from the Hypergoat indexer, show comment icon badge on nodes with comments, and display a full comment section in the NodeDetail sidebar panel.\n\n### Subject URI Convention\nComments target beads issues using: `{ uri: 'beads:<issue-id>', type: 'record' }`\nExample: `{ uri: 'beads:beads-map-cvh', type: 'record' }`\n\n### Architecture\n```\n[User right-clicks node] → CommentTooltip appears (positioned near cursor)\n → [User types + clicks Send]\n → POST /api/records → getAuthenticatedAgent() → agent.com.atproto.repo.createRecord()\n → Record written to user's PDS as org.impactindexer.review.comment\n → refetch() → Hypergoat GraphQL indexer → updated commentsByNode Map\n → [Comment badge appears on node] + [Comments shown in NodeDetail sidebar]\n```\n\n### Task dependency chain\n- .1 (API route) and .2 (comments hook) are independent — can be done in parallel\n- .3 (right-click + tooltip) is independent but uses auth awareness\n- .4 (comment badge) depends on .2 (needs commentedNodeIds)\n- .5 (NodeDetail comments) depends on .2 (needs comments data)\n- .6 (wiring) depends on ALL of .1-.5\n\n### Key reference files\n- Hyperscan API route: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`\n- Hypergoat indexer: `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts`\n- Tooltip design: `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Subject ref defs: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/defs.json`\n\n### New files to create\n1. `app/api/records/route.ts` — generic ATProto record CRUD route\n2. `hooks/useBeadsComments.ts` — fetch + parse comments from indexer\n3. `components/CommentTooltip.tsx` — floating right-click comment tooltip\n\n### Files to modify\n1. `components/BeadsGraph.tsx` — add onNodeRightClick prop + comment badge in paintNode\n2. `components/NodeDetail.tsx` — add Comments section at bottom\n3. `app/page.tsx` — wire everything together\n\n### Build & test\n```bash\npnpm build # Must pass with zero errors\nBEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev # Manual test\n```\n\n### Design specification (from plresearch.org)\n- White bg, border `1px solid #E5E7EB`, border-radius 8px\n- Shadow: `0 8px 32px rgba(0,0,0,0.08)`\n- Padding: 18px 20px\n- Colored accent bar (24px x 2px) using node prefix color\n- Fade-in animation: 0.2s ease from opacity:0 translateY(4px)\n- Comment badge on nodes: blue (#3b82f6) speech bubble at top-right","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:11.044718+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:47:32.504475+13:00","closed_at":"2026-02-11T00:47:32.504475+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi","depends_on_id":"beads-map-cvh","type":"blocks","created_at":"2026-02-12T10:39:55.083326+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.1","title":"Create /api/records route for ATProto record CRUD","description":"## Create /api/records route for ATProto record CRUD\n\n### Goal\nCreate `app/api/records/route.ts` — a generic server-side API route that allows authenticated users to create, update, and delete ATProto records on their PDS (Personal Data Server). This is the foundational route that the comment feature will use to write `org.impactindexer.review.comment` records.\n\n### What to create\n**New file:** `app/api/records/route.ts`\n\n### Source to port from\nCopy almost verbatim from Hyperscan: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts` (136 lines). The only changes needed are import paths (`@/lib/agent` → `@/lib/agent`, `@/lib/session` → `@/lib/session` — these are actually identical since beads-map uses the same structure).\n\n### Implementation details\n\nThe file exports three HTTP handlers:\n\n**POST /api/records** — Create a new record:\n```typescript\nimport { NextRequest, NextResponse } from 'next/server'\nimport { getAuthenticatedAgent } from '@/lib/agent'\nimport { getSession } from '@/lib/session'\n\nexport const dynamic = 'force-dynamic'\n\nexport async function POST(request: NextRequest) {\n // 1. Check session.did from iron-session cookie → 401 if missing\n // 2. Call getAuthenticatedAgent() → 401 if null\n // 3. Parse body: { collection: string, rkey?: string, record: object }\n // 4. Validate collection (required, string) and record (required, object)\n // 5. Call agent.com.atproto.repo.createRecord({ repo: session.did, collection, rkey: rkey || undefined, record })\n // 6. Return { success: true, uri: res.data.uri, cid: res.data.cid }\n}\n```\n\n**PUT /api/records** — Update an existing record:\n- Same auth checks\n- Body: { collection, rkey (required), record }\n- Calls agent.com.atproto.repo.putRecord(...)\n- Returns { success: true }\n\n**DELETE /api/records?collection=...&rkey=...** — Delete a record:\n- Same auth checks\n- Params from URL searchParams\n- Calls agent.com.atproto.repo.deleteRecord(...)\n- Returns { success: true }\n\nAll methods wrap in try/catch, returning { error: message } with status 500 on failure.\n\n### Dependencies already in place\n- `lib/agent.ts` — exports `getAuthenticatedAgent()` which returns an `Agent` from `@atproto/api` (already created in previous session)\n- `lib/session.ts` — exports `getSession()` returning `Session` with optional `did` field (already created)\n- `@atproto/api` — already installed in package.json\n\n### Testing\nAfter creating the file, run `pnpm build` to verify it compiles. The route should appear in the build output as `ƒ /api/records` (dynamic route).\n\n### Acceptance criteria\n- [ ] File exists at `app/api/records/route.ts`\n- [ ] Exports POST, PUT, DELETE handlers\n- [ ] Uses `export const dynamic = 'force-dynamic'`\n- [ ] All three methods check `session.did` and `getAuthenticatedAgent()`\n- [ ] `pnpm build` passes with no type errors","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:20.159813+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:02.816953+13:00","closed_at":"2026-02-11T00:44:02.816953+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.1","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:20.161533+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.2","title":"Create useBeadsComments hook to fetch comments from Hypergoat indexer","description":"## Create useBeadsComments hook to fetch comments from Hypergoat indexer\n\n### Goal\nCreate `hooks/useBeadsComments.ts` — a React hook that fetches all `org.impactindexer.review.comment` records from the Hypergoat GraphQL indexer, filters them to only those whose subject URI starts with `beads:`, resolves commenter profiles, and returns structured data for the UI.\n\n### What to create\n**New file:** `hooks/useBeadsComments.ts`\n\n### Hypergoat GraphQL API\n- **Endpoint:** `https://hypergoat-app-production.up.railway.app/graphql`\n- **Query pattern** (from `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts` line 80-100):\n```graphql\nquery FetchRecords($collection: String!, $first: Int, $after: String) {\n records(collection: $collection, first: $first, after: $after) {\n edges {\n node {\n cid\n collection\n did\n rkey\n uri\n value\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n```\n- Call with `collection: 'org.impactindexer.review.comment'`, `first: 100`\n- The `value` field is a JSON object containing the record data including `subject`, `text`, `createdAt`\n\n### Comment record shape (from `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`):\n```typescript\n{\n subject: { uri: string, type: string }, // e.g. { uri: 'beads:beads-map-cvh', type: 'record' }\n text: string,\n createdAt: string, // ISO 8601\n replyTo?: string, // AT-URI of parent comment (not used yet but good to preserve)\n}\n```\n\n### Profile resolution\nFor each unique `did` in comments, resolve to display info via the Bluesky public API:\n```typescript\n// Resolve DID to profile (handle, displayName, avatar)\nconst res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`)\nconst profile = await res.json()\n// Returns: { did, handle, displayName?, avatar? }\n```\nCache results in a module-level Map<string, ResolvedProfile> to avoid redundant fetches. Deduplicate in-flight requests.\n\n### Hook interface\n```typescript\ninterface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string; // AT-URI of the comment record itself\n rkey: string;\n}\n\ninterface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // key = beads issue ID (e.g. 'beads-map-cvh')\n commentedNodeIds: Set<string>; // for quick badge lookup in paintNode\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n\nexport function useBeadsComments(): UseBeadsCommentsResult\n```\n\n### Implementation steps\n1. On mount, call the GraphQL endpoint to fetch comments\n2. Parse each record's `value.subject.uri` — only keep those starting with `beads:`\n3. Extract the beads issue ID by stripping the `beads:` prefix (e.g. `beads:beads-map-cvh` → `beads-map-cvh`)\n4. Group comments by issue ID into a Map\n5. Build `commentedNodeIds` Set from the Map keys\n6. Resolve all unique DIDs to profiles in parallel (with caching)\n7. Merge profile data into each BeadsComment object\n8. Return the result with a `refetch()` function that re-runs the whole pipeline\n9. Comments within each node should be sorted newest-first by `createdAt`\n\n### Error handling\n- Silent failure on profile resolution (show DID prefix as fallback)\n- Set error state on GraphQL fetch failure\n- Use `cancelled` flag pattern for cleanup (matches existing codebase convention)\n\n### No dependencies to add\nThis hook only uses `fetch()` and React hooks — no new npm packages needed.\n\n### Acceptance criteria\n- [ ] File exists at `hooks/useBeadsComments.ts`\n- [ ] Fetches from Hypergoat GraphQL endpoint\n- [ ] Filters comments to only `beads:*` subject URIs\n- [ ] Groups by issue ID, provides `commentedNodeIds` Set\n- [ ] Resolves commenter profiles (handle, avatar)\n- [ ] Provides `refetch()` method\n- [ ] `pnpm build` passes (even if hook isn't wired up yet — it should have no import errors)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:28.751791+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:02.935547+13:00","closed_at":"2026-02-11T00:44:02.935547+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.2","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:28.754207+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.3","title":"Add right-click handler to BeadsGraph and context menu tooltip component","description":"## Add right-click handler to BeadsGraph and context menu tooltip component\n\n### Goal\nEnable right-clicking on graph nodes to open a beautiful floating comment tooltip. Create the `CommentTooltip` component and wire the right-click event through BeadsGraph to the parent page.\n\n### Part A: Modify `components/BeadsGraph.tsx`\n\n**1. Add `onNodeRightClick` to the props interface** (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n nodes: GraphNode[];\n links: GraphLink[];\n selectedNode: GraphNode | null;\n hoveredNode: GraphNode | null;\n onNodeClick: (node: GraphNode) => void;\n onNodeHover: (node: GraphNode | null) => void;\n onBackgroundClick: () => void;\n onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void; // NEW\n}\n```\n\n**2. Destructure the new prop** in the component function (around line 145):\n```typescript\nconst { nodes, links, selectedNode, hoveredNode, onNodeClick, onNodeHover, onBackgroundClick, onNodeRightClick } = props;\n```\nNote: BeadsGraph uses `forwardRef` — the props are the first argument.\n\n**3. Add `onNodeRightClick` to ForceGraph2D** (around line 1231-1235, after `onNodeClick`):\n```typescript\nonNodeRightClick={(node: any, event: MouseEvent) => {\n event.preventDefault();\n onNodeRightClick?.(node as GraphNode, event);\n}}\n```\nThe `react-force-graph-2d` library supports `onNodeRightClick` as a built-in prop. The `event.preventDefault()` prevents the browser's default context menu.\n\n### Part B: Create `components/CommentTooltip.tsx`\n\n**New file:** `components/CommentTooltip.tsx`\n\nThis is a `'use client'` component that renders an absolutely-positioned floating tooltip near the right-click location.\n\n**Design inspiration:** The tooltip from `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx` (lines 237-274). Key design elements:\n- White background (`#FFFFFF`)\n- Subtle border: `1px solid #E5E7EB`\n- Border radius: `8px`\n- Padding: `18px 20px`\n- Box shadow: `0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08)`\n- Fade-in animation: `0.2s ease` from `opacity: 0; translateY(4px)` to `opacity: 1; translateY(0)`\n- Colored accent bar at top: `width: 24px, height: 2px` using the node's prefix color\n\n**Props:**\n```typescript\ninterface CommentTooltipProps {\n node: GraphNode;\n x: number; // screen X from MouseEvent.clientX\n y: number; // screen Y from MouseEvent.clientY\n onClose: () => void;\n onSubmit: (text: string) => Promise<void>;\n isAuthenticated: boolean;\n existingComments?: BeadsComment[]; // show recent comments in tooltip too\n}\n```\n\n**Layout (top to bottom):**\n1. **Colored accent bar** — 24px wide, 2px tall, using `PREFIX_COLORS[node.prefix]` from `@/lib/types`\n2. **Node info** — ID in mono font (`text-emerald-600`), title in `font-semibold text-sm text-zinc-800`\n3. **Existing comments preview** — if any, show count like '3 comments' as a subtle label, with the most recent 1-2 comments abbreviated\n4. **Textarea** — if authenticated: `<textarea>` with placeholder 'Leave a comment...', 3 rows, matching zinc style. If not authenticated: show `<p>Sign in to comment</p>` with a muted style.\n5. **Action row** — Send button (emerald bg, white text, rounded, small) + Cancel button (text-only, zinc). Send button disabled when textarea empty. Shows spinner during submission.\n\n**Positioning logic:**\n- Position at `(x + 14, y - tooltipHeight - 14)` relative to viewport\n- If overflows right: clamp to `window.innerWidth - tooltipWidth - 16`\n- If overflows left: clamp to `16`\n- If overflows top: flip below cursor at `(x + 14, y + 28)`\n- Use a `useRef` + `useEffect` to measure tooltip dimensions after first render and adjust position (same pattern as plresearch.org Tooltip component, lines 244-256)\n- Fixed positioning (`position: fixed`) since it's relative to the viewport, not a container\n\n**Interaction:**\n- Closes on Escape key (add keydown listener in useEffect)\n- Closes on click outside (add mousedown listener, check if event.target is outside tooltip ref)\n- Auto-focuses the textarea on mount\n- After successful submit: calls `onSubmit(text)`, clears textarea, calls `onClose()`\n\n**Tailwind animation:** Add a CSS class or inline style for the fade-in:\n```css\n@keyframes tooltipFadeIn {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: translateY(0); }\n}\n```\nUse inline style `animation: 'tooltipFadeIn 0.2s ease'` or a Tailwind animate class.\n\n### Acceptance criteria\n- [ ] `BeadsGraphProps` includes `onNodeRightClick`\n- [ ] ForceGraph2D has `onNodeRightClick` handler with `preventDefault()`\n- [ ] `components/CommentTooltip.tsx` exists with the design described above\n- [ ] Tooltip positions near cursor, clamped to viewport\n- [ ] Closes on Escape, click outside\n- [ ] Shows auth-gated textarea vs 'Sign in to comment' message\n- [ ] Send button calls `onSubmit` with text, shows loading state\n- [ ] `pnpm build` passes (component may not be rendered yet — that's task .6)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:39.225841+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.051623+13:00","closed_at":"2026-02-11T00:44:03.051623+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.3","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:39.227376+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.4","title":"Add comment icon badge to nodes with comments in paintNode","description":"## Add comment icon badge to nodes with comments in paintNode\n\n### Goal\nDraw a small speech-bubble comment icon on graph nodes that have ATProto comments. This provides at-a-glance visual feedback about which issues have been discussed.\n\n### What to modify\n**File:** `components/BeadsGraph.tsx`\n\n### Step 1: Add `commentedNodeIds` prop\n\nAdd to `BeadsGraphProps` interface (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n commentedNodeIds?: Set<string>; // NEW — node IDs that have comments\n}\n```\n\nDestructure in the component (around line 145 where other props are destructured):\n```typescript\nconst { ..., commentedNodeIds } = props;\n```\n\n### Step 2: Create a ref for commentedNodeIds\n\nFollowing the established ref pattern in BeadsGraph (lines 181-185 where `selectedNodeRef`, `hoveredNodeRef`, `connectedNodesRef` are declared):\n\n```typescript\nconst commentedNodeIdsRef = useRef<Set<string>>(commentedNodeIds || new Set());\n```\n\nAdd a sync effect (near lines 263-293 where selectedNode/hoveredNode ref syncs happen):\n```typescript\nuseEffect(() => {\n commentedNodeIdsRef.current = commentedNodeIds || new Set();\n // Trigger a canvas redraw so the badge appears/disappears\n refreshGraph(graphRef);\n}, [commentedNodeIds]);\n```\n\n**Why a ref?** The `paintNode` callback has an empty dependency array (`[]`) — it reads all visual state from refs, not props. This avoids recreating the callback and re-rendering ForceGraph2D. This is the same pattern used for `selectedNodeRef`, `hoveredNodeRef`, and `connectedNodesRef` (see lines 181-185, 263-293).\n\n### Step 3: Draw the comment badge in paintNode\n\nIn the `paintNode` callback (lines 456-631), add the badge drawing AFTER the label section (around line 625, before `ctx.restore()` on line 628):\n\n```typescript\n// Comment badge — small speech bubble at top-right of node\nif (commentedNodeIdsRef.current.has(graphNode.id) && globalScale > 0.5) {\n const badgeSize = Math.min(6, Math.max(3, 8 / globalScale));\n // Position at ~45 degrees from center, just outside the node circle\n const badgeX = node.x + animatedSize * 0.7;\n const badgeY = node.y - animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = opacity * 0.85;\n\n // Speech bubble body (rounded rect)\n const bw = badgeSize * 1.6; // bubble width\n const bh = badgeSize * 1.2; // bubble height\n const br = badgeSize * 0.3; // border radius\n ctx.beginPath();\n ctx.moveTo(badgeX - bw/2 + br, badgeY - bh/2);\n ctx.lineTo(badgeX + bw/2 - br, badgeY - bh/2);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY - bh/2, badgeX + bw/2, badgeY - bh/2 + br);\n ctx.lineTo(badgeX + bw/2, badgeY + bh/2 - br);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY + bh/2, badgeX + bw/2 - br, badgeY + bh/2);\n // Small triangle pointer at bottom-left\n ctx.lineTo(badgeX - bw/4, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/3, badgeY + bh/2 + badgeSize * 0.4);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY + bh/2, badgeX - bw/2, badgeY + bh/2 - br);\n ctx.lineTo(badgeX - bw/2, badgeY - bh/2 + br);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY - bh/2, badgeX - bw/2 + br, badgeY - bh/2);\n ctx.closePath();\n\n ctx.fillStyle = '#3b82f6'; // blue-500\n ctx.fill();\n\n ctx.restore();\n}\n```\n\nThe exact canvas drawing can be simplified/refined — the key requirements are:\n- Small speech-bubble shape (recognizable as a comment icon)\n- Positioned at top-right of the node circle\n- Blue fill (`#3b82f6`) at ~0.85 opacity\n- Only drawn when `globalScale > 0.5` (same threshold as labels on line 598)\n- Scales with zoom level like other indicators\n\n### Acceptance criteria\n- [ ] `commentedNodeIds` prop added to `BeadsGraphProps`\n- [ ] Ref created and synced with effect + `refreshGraph()` call\n- [ ] Speech bubble badge drawn in `paintNode` for nodes in the set\n- [ ] Badge scales with zoom, only visible at reasonable zoom levels\n- [ ] No ForceGraph re-render triggered (ref pattern maintained)\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:47.743964+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.169692+13:00","closed_at":"2026-02-11T00:44:03.169692+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:47.745514+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.253835+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.5","title":"Add comment section to NodeDetail panel","description":"## Add comment section to NodeDetail panel\n\n### Goal\nAdd a 'Comments' section at the bottom of the NodeDetail sidebar panel that shows existing ATProto comments for the selected node and provides an inline compose area for authenticated users.\n\n### What to modify\n**File:** `components/NodeDetail.tsx`\n\n### Current file structure (304 lines)\n- Line 1-18: imports and props interface\n- Line 20-247: main `NodeDetail` component\n - Line 25-46: null state (no node selected)\n - Line 48-245: node detail rendering\n - Lines 229-245: 'Blocked by' section (LAST section before closing div)\n - Line 246: closing `</div>`\n- Lines 250-298: helper components (`MetricCard`, `DependencyLink`)\n- Lines 300-303: `truncateDescription`\n\n### Changes needed\n\n**1. Expand the props interface** (line 14-18):\n```typescript\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // NEW import\n\ninterface NodeDetailProps {\n node: GraphNode | null;\n allNodes: GraphNode[];\n onNodeNavigate: (nodeId: string) => void;\n comments?: BeadsComment[]; // NEW — comments for selected node\n onPostComment?: (text: string) => Promise<void>; // NEW — submit callback\n isAuthenticated?: boolean; // NEW — auth state for compose area\n}\n```\n\n**2. Destructure new props** (line 20-24):\n```typescript\nexport default function NodeDetail({\n node, allNodes, onNodeNavigate, comments, onPostComment, isAuthenticated,\n}: NodeDetailProps) {\n```\n\n**3. Add Comments section** (after the 'Blocked by' section, around line 245, before the closing `</div>`):\n\n```tsx\n{/* Comments */}\n<div className=\"mb-4\">\n <h4 className=\"text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2\">\n Comments {comments && comments.length > 0 && (\n <span className=\"ml-1 px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full text-[10px] font-medium\">\n {comments.length}\n </span>\n )}\n </h4>\n\n {/* Comment list */}\n {comments && comments.length > 0 ? (\n <div className=\"space-y-3\">\n {comments.map((comment) => (\n <CommentItem key={comment.uri} comment={comment} />\n ))}\n </div>\n ) : (\n <p className=\"text-xs text-zinc-400 italic\">No comments yet</p>\n )}\n\n {/* Compose area */}\n {isAuthenticated && onPostComment ? (\n <CommentCompose onSubmit={onPostComment} />\n ) : !isAuthenticated ? (\n <p className=\"text-xs text-zinc-400 mt-2\">Sign in to leave a comment</p>\n ) : null}\n</div>\n```\n\n**4. Create helper sub-components** (after `DependencyLink`, before `truncateDescription`):\n\n**CommentItem** — displays a single comment:\n```tsx\nfunction CommentItem({ comment }: { comment: BeadsComment }) {\n return (\n <div className=\"flex gap-2\">\n {/* Avatar */}\n <div className=\"shrink-0 w-6 h-6 rounded-full bg-zinc-100 overflow-hidden\">\n {comment.avatar ? (\n <img src={comment.avatar} alt=\"\" className=\"w-full h-full object-cover\" />\n ) : (\n <div className=\"w-full h-full flex items-center justify-center text-[10px] font-medium text-zinc-400\">\n {(comment.handle || comment.did).charAt(0).toUpperCase()}\n </div>\n )}\n </div>\n {/* Content */}\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-xs font-medium text-zinc-600 truncate\">\n {comment.displayName || comment.handle || comment.did.slice(0, 16) + '...'}\n </span>\n <span className=\"text-[10px] text-zinc-300 shrink-0\">\n {formatRelativeTime(comment.createdAt)}\n </span>\n </div>\n <p className=\"text-xs text-zinc-500 mt-0.5 whitespace-pre-wrap break-words\">{comment.text}</p>\n </div>\n </div>\n );\n}\n```\n\n**CommentCompose** — inline textarea + send button:\n```tsx\nfunction CommentCompose({ onSubmit }: { onSubmit: (text: string) => Promise<void> }) {\n const [text, setText] = useState('');\n const [sending, setSending] = useState(false);\n\n const handleSubmit = async () => {\n if (!text.trim() || sending) return;\n setSending(true);\n try {\n await onSubmit(text.trim());\n setText('');\n } catch (err) {\n console.error('Failed to post comment:', err);\n } finally {\n setSending(false);\n }\n };\n\n return (\n <div className=\"mt-3 space-y-2\">\n <textarea\n value={text}\n onChange={(e) => setText(e.target.value)}\n placeholder=\"Leave a comment...\"\n rows={2}\n className=\"w-full px-2.5 py-1.5 text-xs border border-zinc-200 rounded-md bg-zinc-50 text-zinc-700 placeholder-zinc-400 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500\"\n />\n <button\n onClick={handleSubmit}\n disabled={!text.trim() || sending}\n className=\"px-3 py-1 text-xs font-medium text-white bg-emerald-500 rounded-md hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {sending ? 'Sending...' : 'Comment'}\n </button>\n </div>\n );\n}\n```\n\n**formatRelativeTime** helper:\n```typescript\nfunction formatRelativeTime(isoString: string): string {\n const date = new Date(isoString);\n const now = new Date();\n const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);\n if (seconds < 60) return 'just now';\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return minutes + 'm ago';\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return hours + 'h ago';\n const days = Math.floor(hours / 24);\n if (days < 7) return days + 'd ago';\n return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\n}\n```\n\n**5. Add `useState` import** — needed for `CommentCompose`. Add to the existing React import at line 1 or import separately.\n\n### Styling notes\n- Matches existing NodeDetail style: `text-xs`, zinc palette, `mb-4` section spacing\n- Avatar uses plain `<img>` (not `next/image`) — consistent with AuthButton.tsx pattern\n- Count badge uses blue accent to match the comment badge on the graph nodes\n- Compose area uses emerald accent for the submit button (matches the app's primary color)\n\n### Acceptance criteria\n- [ ] 'Comments' section appears after 'Blocked by' in NodeDetail\n- [ ] Shows comment count badge when comments exist\n- [ ] Each comment shows avatar, handle/name, relative time, text\n- [ ] Empty state shows 'No comments yet' placeholder\n- [ ] Authenticated users see compose textarea + submit button\n- [ ] Unauthenticated users see 'Sign in to leave a comment'\n- [ ] Submit clears textarea and calls `onPostComment`\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:31:54.777115+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.284193+13:00","closed_at":"2026-02-11T00:44:03.284193+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:54.778714+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.395175+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.6","title":"Wire everything together in page.tsx and build verification","description":"## Wire everything together in page.tsx and build verification\n\n### Goal\nConnect all the pieces created in tasks .1-.5 in the main `app/page.tsx` orchestration file. This is the final integration task.\n\n### What to modify\n**File:** `app/page.tsx` (currently 811 lines)\n\n### Current file structure (key sections)\n- Line 1: `'use client'`\n- Lines 3-12: imports\n- Lines 14-67: helper functions (`findNeighborPosition`, etc.)\n- Lines 69-811: main `Home` component\n - Lines 73-90: state declarations\n - Lines 175-250: SSE/fetch data loading\n - Lines 280-320: event handlers (`handleNodeClick`, `handleNodeHover`, etc.)\n - Lines 680-695: BeadsGraph rendering\n - Lines 697-765: Desktop sidebar with NodeDetail\n - Lines 767-806: Mobile drawer with NodeDetail\n\n### Step 1: Add imports (near lines 3-12)\n\n```typescript\nimport { CommentTooltip } from '@/components/CommentTooltip'; // task .3\nimport { useBeadsComments } from '@/hooks/useBeadsComments'; // task .2\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // task .2\nimport { useAuth } from '@/lib/auth'; // already importable\n```\n\n### Step 2: Add state and hooks (near lines 73-90, after existing state declarations)\n\n```typescript\n// Auth state\nconst { isAuthenticated, session } = useAuth();\n\n// Comments from ATProto indexer\nconst { commentsByNode, commentedNodeIds, refetch: refetchComments } = useBeadsComments();\n\n// Context menu state for right-click tooltip\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n### Step 3: Create event handlers (near lines 280-320)\n\n**Right-click handler:**\n```typescript\nconst handleNodeRightClick = useCallback((node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n}, []);\n```\n\n**Post comment callback:**\n```typescript\nconst handlePostComment = useCallback(async (nodeId: string, text: string) => {\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n $type: 'org.impactindexer.review.comment',\n subject: {\n uri: `beads:${nodeId}`,\n type: 'record',\n },\n text,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n\n if (!response.ok) {\n const data = await response.json();\n throw new Error(data.error || 'Failed to post comment');\n }\n\n // Refetch comments to update the UI\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 4: Pass props to BeadsGraph (around lines 680-695)\n\nAdd the new props to the `<BeadsGraph>` component:\n```tsx\n<BeadsGraph\n ref={graphRef}\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n selectedNode={selectedNode}\n hoveredNode={hoveredNode}\n onNodeClick={handleNodeClick}\n onNodeHover={handleNodeHover}\n onBackgroundClick={handleBackgroundClick}\n onNodeRightClick={handleNodeRightClick} // NEW\n commentedNodeIds={commentedNodeIds} // NEW\n/>\n```\n\n### Step 5: Pass props to NodeDetail (desktop sidebar, around line 733-737)\n\n```tsx\n<NodeDetail\n node={selectedNode}\n allNodes={data.graphData.nodes}\n onNodeNavigate={handleNodeNavigate}\n comments={selectedNode ? commentsByNode.get(selectedNode.id) : undefined} // NEW\n onPostComment={selectedNode ? (text: string) => handlePostComment(selectedNode.id, text) : undefined} // NEW\n isAuthenticated={isAuthenticated} // NEW\n/>\n```\n\nDo the same for the mobile drawer NodeDetail (around line 799-803).\n\n### Step 6: Render CommentTooltip (after BeadsGraph, before the sidebar, around line 695)\n\n```tsx\n{/* Right-click comment tooltip */}\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n### Step 7: Close tooltip on background click\n\nModify `handleBackgroundClick` to also close the context menu:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null); // NEW — close tooltip too\n}, []);\n```\n\n### Step 8: Build and fix errors\n\nRun `pnpm build` and fix any type errors. Common issues to watch for:\n- Import path typos\n- Missing exports (e.g., `BeadsComment` type not exported from hook)\n- `useAuth` must be called inside `AuthProvider` (it already is — layout.tsx wraps children)\n- The `CommentTooltip` component must be exported as named export (check consistency with import)\n\n### Acceptance criteria\n- [ ] `useBeadsComments` hook called at top level of Home component\n- [ ] `useAuth` provides `isAuthenticated` state\n- [ ] `contextMenu` state manages right-click tooltip position + node\n- [ ] `handleNodeRightClick` creates context menu state\n- [ ] `handlePostComment` POSTs to `/api/records` with correct record shape\n- [ ] BeadsGraph receives `onNodeRightClick` and `commentedNodeIds`\n- [ ] Both desktop and mobile NodeDetail receive comments + postComment + isAuthenticated\n- [ ] CommentTooltip renders when `contextMenu` is set\n- [ ] Background click closes both selection and context menu\n- [ ] `pnpm build` passes with zero errors\n- [ ] All auth API routes visible in build output (`ƒ /api/records`, etc.)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:32:01.724819+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:44:03.398953+13:00","closed_at":"2026-02-11T00:44:03.398953+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:32:01.725925+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.1","type":"blocks","created_at":"2026-02-11T00:38:43.522633+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.647344+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.3","type":"blocks","created_at":"2026-02-11T00:38:43.773371+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.4","type":"blocks","created_at":"2026-02-11T00:38:43.895718+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.5","type":"blocks","created_at":"2026-02-11T00:38:44.013093+13:00","created_by":"daviddao"}]},{"id":"beads-map-dyi.7","title":"Add delete button for own comments","description":"## Add delete button for own comments\n\n### Goal\nAllow authenticated users to delete comments they have authored. Show a small trash/X icon on comments where the logged-in user's DID matches the comment's DID. Clicking it calls DELETE /api/records to remove the record from their PDS, then refetches comments.\n\n### What to modify\n\n#### 1. `components/NodeDetail.tsx` — CommentItem sub-component\n\nAdd a delete button that appears only when the comment's `did` matches the current user's DID.\n\n**Props change for CommentItem:**\n```typescript\nfunction CommentItem({ comment, currentDid, onDelete }: {\n comment: BeadsComment;\n currentDid?: string;\n onDelete?: (comment: BeadsComment) => Promise<void>;\n})\n```\n\n**UI:** A small X or trash icon button, only visible when `currentDid === comment.did`. Positioned at the top-right of the comment row. On hover, it becomes visible (use `group` + `group-hover:opacity-100` pattern or always-visible is fine for simplicity). Shows a confirmation or just deletes immediately. While deleting, show a subtle spinner or disabled state.\n\n**Delete call pattern** (from Hyperscan `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`):\n```typescript\n// The rkey is extracted from the comment's AT-URI: at://did/collection/rkey\n// comment.rkey is already available in BeadsComment\nawait fetch(`/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`, {\n method: 'DELETE',\n});\n```\n\n#### 2. `components/NodeDetail.tsx` — NodeDetailProps\n\nAdd `currentDid` to props:\n```typescript\ninterface NodeDetailProps {\n // ... existing props ...\n currentDid?: string; // NEW — the authenticated user's DID for ownership checks\n}\n```\n\n#### 3. `components/CommentTooltip.tsx` — existing comments preview\n\nOptionally add delete to the tooltip preview too, or skip for simplicity (tooltip is compact). Recommended: skip delete in tooltip, only in NodeDetail.\n\n#### 4. `app/page.tsx` — pass currentDid and onDeleteComment\n\nPass `session?.did` as `currentDid` to both desktop and mobile `<NodeDetail>` instances.\n\nCreate a `handleDeleteComment` callback:\n```typescript\nconst handleDeleteComment = useCallback(async (comment: BeadsComment) => {\n const response = await fetch(\n `/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`,\n { method: 'DELETE' }\n );\n if (!response.ok) {\n const errData = await response.json();\n throw new Error(errData.error || 'Failed to delete comment');\n }\n await refetchComments();\n}, [refetchComments]);\n```\n\nPass it to NodeDetail:\n```tsx\n<NodeDetail\n ...existing props...\n currentDid={session?.did}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n#### 5. `components/NodeDetail.tsx` — wire onDeleteComment\n\nAdd to NodeDetailProps:\n```typescript\nonDeleteComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nPass to CommentItem:\n```tsx\n<CommentItem\n key={comment.uri}\n comment={comment}\n currentDid={currentDid}\n onDelete={onDeleteComment}\n/>\n```\n\n### Existing infrastructure\n- `DELETE /api/records?collection=...&rkey=...` already exists (created in beads-map-dyi.1)\n- `BeadsComment` type has `rkey` and `did` fields (from useBeadsComments hook)\n- `useAuth()` provides `session.did` (from lib/auth.tsx)\n- `refetchComments()` from `useBeadsComments` hook refreshes the comment list\n\n### Acceptance criteria\n- [ ] Delete icon/button appears only on comments authored by the current user\n- [ ] Clicking delete calls `DELETE /api/records` with correct collection and rkey\n- [ ] After successful delete, comments list refreshes automatically\n- [ ] Shows loading/disabled state during deletion\n- [ ] `pnpm build` passes with zero errors","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T00:45:37.231167+13:00","created_by":"daviddao","updated_at":"2026-02-11T00:47:32.38037+13:00","closed_at":"2026-02-11T00:47:32.38037+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-dyi.7","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:45:37.232842+13:00","created_by":"daviddao"}]},{"id":"beads-map-ecl","title":"Wire EventSource in page.tsx with merge logic","description":"Modify: app/page.tsx\n\nPURPOSE: Replace the one-shot fetch(\"/api/beads\") with an EventSource connected to /api/beads/stream. On each SSE message, diff the new data against current state, stamp animation metadata, and update React state. This is the central coordination point where server data meets client state.\n\nCHANGES TO page.tsx:\n\n1. ADD IMPORTS at top:\n import { diffBeadsData, linkKey } from \"@/lib/diff-beads\";\n import type { BeadsDiff } from \"@/lib/diff-beads\";\n\n2. ADD a ref to track the previous data for diffing:\n const prevDataRef = useRef<BeadsApiResponse | null>(null);\n\n3. REPLACE the existing fetch useEffect (lines 38-53) with EventSource logic:\n\n```typescript\n // Live-streaming beads data via SSE\n useEffect(() => {\n let eventSource: EventSource | null = null;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n function connect() {\n eventSource = new EventSource(\"/api/beads/stream\");\n\n eventSource.onmessage = (event) => {\n try {\n const newData = JSON.parse(event.data) as BeadsApiResponse;\n if ((newData as any).error) {\n setError((newData as any).error);\n setLoading(false);\n return;\n }\n\n const oldData = prevDataRef.current;\n const diff = diffBeadsData(oldData, newData);\n\n if (!oldData) {\n // Initial load — no animations, just set data\n prevDataRef.current = newData;\n setData(newData);\n setLoading(false);\n return;\n }\n\n if (!diff.hasChanges) return; // No-op if nothing changed\n\n // Merge: stamp animation metadata and preserve positions\n const mergedData = mergeBeadsData(oldData, newData, diff);\n prevDataRef.current = mergedData;\n setData(mergedData);\n } catch (err) {\n console.error(\"Failed to parse SSE message:\", err);\n }\n };\n\n eventSource.onerror = () => {\n // EventSource auto-reconnects, but we handle the gap\n if (eventSource?.readyState === EventSource.CLOSED) {\n // Permanent failure — try manual reconnect after delay\n reconnectTimer = setTimeout(connect, 5000);\n }\n };\n\n // If still loading after 5s, fall back to one-shot fetch\n setTimeout(() => {\n if (loading) {\n fetch(\"/api/beads\")\n .then(res => res.json())\n .then(data => {\n if (!prevDataRef.current) {\n prevDataRef.current = data;\n setData(data);\n setLoading(false);\n }\n })\n .catch(() => {});\n }\n }, 5000);\n }\n\n connect();\n\n return () => {\n eventSource?.close();\n if (reconnectTimer) clearTimeout(reconnectTimer);\n };\n }, []);\n```\n\n4. ADD the mergeBeadsData function (above the component or as a module-level function):\n\n```typescript\nfunction mergeBeadsData(\n oldData: BeadsApiResponse,\n newData: BeadsApiResponse,\n diff: BeadsDiff\n): BeadsApiResponse {\n const now = Date.now();\n\n // Build position map from old nodes (preserves x/y/fx/fy from simulation)\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));\n\n // Merge nodes: carry over positions, stamp animation metadata\n const mergedNodes = newData.graphData.nodes.map(node => {\n const oldNode = oldNodeMap.get(node.id);\n\n if (!oldNode) {\n // NEW NODE — stamp spawn time, place near a connected neighbor\n const neighbor = findNeighborPosition(node.id, newData.graphData.links, oldNodeMap);\n return {\n ...node,\n _spawnTime: now,\n x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,\n y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,\n };\n }\n\n // EXISTING NODE — preserve position, check for changes\n const merged = {\n ...node,\n x: oldNode.x,\n y: oldNode.y,\n fx: oldNode.fx,\n fy: oldNode.fy,\n };\n\n // Stamp change metadata if status changed\n if (diff.changedNodes.has(node.id)) {\n const changes = diff.changedNodes.get(node.id)!;\n const statusChange = changes.find(c => c.field === \"status\");\n if (statusChange) {\n merged._changedAt = now;\n merged._prevStatus = statusChange.from;\n }\n }\n\n return merged;\n });\n\n // Handle removed nodes: keep them briefly for exit animation\n for (const removedId of diff.removedNodeIds) {\n const oldNode = oldNodeMap.get(removedId);\n if (oldNode) {\n mergedNodes.push({\n ...oldNode,\n _removeTime: now,\n });\n }\n }\n\n // Merge links: stamp spawn time on new links\n const mergedLinks = newData.graphData.links.map(link => {\n const key = linkKey(link);\n if (!oldLinkKeySet.has(key)) {\n return { ...link, _spawnTime: now };\n }\n return link;\n });\n\n // Handle removed links: keep briefly for exit animation\n for (const removedKey of diff.removedLinkKeys) {\n const oldLink = oldData.graphData.links.find(l => linkKey(l) === removedKey);\n if (oldLink) {\n mergedLinks.push({\n source: typeof oldLink.source === \"object\" ? (oldLink.source as any).id : oldLink.source,\n target: typeof oldLink.target === \"object\" ? (oldLink.target as any).id : oldLink.target,\n type: oldLink.type,\n _removeTime: now,\n });\n }\n }\n\n return {\n ...newData,\n graphData: {\n nodes: mergedNodes as any,\n links: mergedLinks as any,\n },\n };\n}\n\n// Find position of a neighbor node (for placing new nodes near connections)\nfunction findNeighborPosition(\n nodeId: string,\n links: GraphLink[],\n nodeMap: Map<string, GraphNode>\n): { x: number; y: number } | null {\n for (const link of links) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n if (src === nodeId && nodeMap.has(tgt)) {\n const n = nodeMap.get(tgt)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n if (tgt === nodeId && nodeMap.has(src)) {\n const n = nodeMap.get(src)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n }\n return null;\n}\n```\n\n5. ADD cleanup of expired animation items.\n After a timeout, remove nodes/links that have _removeTime older than 600ms:\n\n```typescript\n // Clean up expired exit animations\n useEffect(() => {\n if (!data) return;\n const timer = setTimeout(() => {\n const now = Date.now();\n const EXPIRE_MS = 600;\n const nodes = data.graphData.nodes.filter(\n n => !n._removeTime || now - n._removeTime < EXPIRE_MS\n );\n const links = data.graphData.links.filter(\n l => !(l as any)._removeTime || now - (l as any)._removeTime < EXPIRE_MS\n );\n if (nodes.length !== data.graphData.nodes.length || links.length !== data.graphData.links.length) {\n setData(prev => prev ? {\n ...prev,\n graphData: { nodes, links },\n } : prev);\n }\n }, 700); // slightly after animation duration\n return () => clearTimeout(timer);\n }, [data]);\n```\n\n6. KEEP the existing /api/config fetch useEffect unchanged.\n\n7. UPDATE the stats display in the header to exclude nodes/links with _removeTime (so counts reflect real data, not animated ghosts).\n\nWHY FULL MERGE IN page.tsx:\nThe merge logic lives here because it's where we have access to both the old React state (with simulation positions) and the new server data. BeadsGraph.tsx just receives nodes/links props and renders — it doesn't need to know about the merge.\n\nPOSITION PRESERVATION IS CRITICAL:\nreact-force-graph-2d mutates node objects in-place, setting x/y/vx/vy during simulation. If we replace nodes with fresh objects from the server (which have no x/y), the entire graph layout resets. The mergeBeadsData function copies x/y/fx/fy from old nodes to preserve positions.\n\nDEPENDS ON: task .3 (SSE endpoint), task .4 (diff-beads.ts)\n\nACCEPTANCE CRITERIA:\n- EventSource connects to /api/beads/stream on mount\n- Initial data loads correctly (same as before)\n- When JSONL changes, new data streams in and state updates\n- New nodes get _spawnTime stamped\n- Changed nodes get _changedAt + _prevStatus stamped\n- Removed nodes/links kept briefly with _removeTime for exit animation\n- Existing node positions preserved across updates\n- New nodes placed near their connected neighbors\n- Expired animation items cleaned up after 600ms\n- Fallback to one-shot fetch if SSE fails after 5s\n- EventSource cleaned up on unmount\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:17:01.615466+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:36:14.609896+13:00","closed_at":"2026-02-10T23:36:14.609896+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.476318+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-7j2","type":"blocks","created_at":"2026-02-10T23:19:29.07598+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-2fk","type":"blocks","created_at":"2026-02-10T23:19:29.155362+13:00","created_by":"daviddao"}]},{"id":"beads-map-f8f","title":"Switch Catppuccin palette from Mocha to Latte for light background","description":"## What (retroactive — already done)\n\nSwapped all 14 Catppuccin accent hex values from Mocha (pastel, designed for dark backgrounds) to Latte (saturated, designed for light backgrounds) for better contrast on the app's white/zinc-50 background.\n\n## Commit\n- c2e815a — Switch from Catppuccin Mocha to Latte palette for better contrast on white background\n\n## Changes\n\n### lib/types.ts (single file, single source of truth)\n- **Renamed** \\`CATPPUCCIN_MOCHA_ACCENTS\\` → \\`CATPPUCCIN_ACCENTS\\` (flavor-agnostic name)\n- **Swapped all 14 hex values** from Mocha → Latte, same contrast-maximizing order:\n | Name | Mocha (old) | Latte (new) |\n |---|---|---|\n | Red | #f38ba8 | #d20f39 |\n | Teal | #94e2d5 | #179299 |\n | Peach | #fab387 | #fe640b |\n | Blue | #89b4fa | #1e66f5 |\n | Green | #a6e3a1 | #40a02b |\n | Mauve | #cba6f7 | #8839ef |\n | Yellow | #f9e2af | #df8e1d |\n | Sapphire | #74c7ec | #209fb5 |\n | Pink | #f5c2e7 | #ea76cb |\n | Sky | #89dceb | #04a5e5 |\n | Maroon | #eba0b3 | #e64553 |\n | Lavender | #b4befe | #7287fd |\n | Flamingo | #f2cdcd | #dd7878 |\n | Rosewater | #f5e0dc | #dc8a78 |\n- **Unassigned color**: Changed from Mocha Surface2 (#585b70) to Latte Surface2 (#acb0be)\n- Updated \\`getPersonColor()\\` to reference \\`CATPPUCCIN_ACCENTS\\` instead of \\`CATPPUCCIN_MOCHA_ACCENTS\\`\n- Updated doc comments from \"Mocha\" to \"Latte\"\n\n## Result\nAll Catppuccin-colored elements (node fill in owner/assignee/prefix modes, outer rings, cluster circles, tooltip accent bars) now use the Latte flavor with much better contrast against the white background.","status":"closed","priority":2,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T14:22:17.911078+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:22:50.456364+13:00","closed_at":"2026-02-12T14:22:50.456364+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-f8f","depends_on_id":"beads-map-2u2","type":"blocks","created_at":"2026-02-12T14:22:17.912819+13:00","created_by":"daviddao"}]},{"id":"beads-map-gjo","title":"Add animation timestamp fields to types + export getAdditionalRepoPaths","description":"Foundation task: add animation metadata fields to GraphNode/GraphLink types and export a currently-private function from parse-beads.ts.\n\nFILE 1: lib/types.ts\n\nAdd optional animation timestamp fields to GraphNode interface (after the fx/fy fields, around line 61):\n\n // Animation metadata (set by live-update merge logic, consumed by paintNode)\n _spawnTime?: number; // Date.now() when this node first appeared (for pop-in animation)\n _removeTime?: number; // Date.now() when this node was marked for removal (for shrink-out)\n _changedAt?: number; // Date.now() when status/priority changed (for ripple animation)\n _prevStatus?: string; // Previous status value before the change (for color transition)\n\nAdd optional animation timestamp fields to GraphLink interface (after the type field, around line 67):\n\n // Animation metadata (set by live-update merge logic, consumed by paintLink)\n _spawnTime?: number; // Date.now() when this link first appeared (for fade-in animation)\n _removeTime?: number; // Date.now() when this link was marked for removal (for fade-out)\n\nIMPORTANT: These fields use the underscore prefix convention to signal they are transient metadata not persisted to JSONL. They are set by the merge logic in page.tsx and consumed by paintNode/paintLink in BeadsGraph.tsx.\n\nIMPORTANT: GraphNode has an index signature [key: string]: unknown at line 37. The new fields must be declared as optional properties within the interface body (not via the index signature) so TypeScript knows their types.\n\nFILE 2: lib/parse-beads.ts\n\nThe function getAdditionalRepoPaths(beadsDir: string): string[] at line 26 is currently private (no export keyword). Change it to:\n\n export function getAdditionalRepoPaths(beadsDir: string): string[]\n\nThis is needed by lib/watch-beads.ts (task .2) to discover which JSONL files to watch.\n\nNo other changes to parse-beads.ts.\n\nACCEPTANCE CRITERIA:\n- GraphNode has _spawnTime, _removeTime, _changedAt, _prevStatus optional fields\n- GraphLink has _spawnTime, _removeTime optional fields\n- getAdditionalRepoPaths is exported from parse-beads.ts\n- pnpm build passes with zero errors\n- No runtime behavior changes (animation fields are just type declarations, unused until task .5/.6/.7)","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:15:11.332936+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:24:57.300177+13:00","closed_at":"2026-02-10T23:24:57.300177+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-gjo","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.148777+13:00","created_by":"daviddao"}]},{"id":"beads-map-hrt","title":"Copy-to-clipboard button for node descriptions","description":"## What (retroactive — already done)\n\nAdded a clipboard copy icon button to both the description modal and the node detail sidebar panel, allowing users to copy the raw markdown text of a task description with one click.\n\n## Commit\n- b499aac — Add copy-to-clipboard button for descriptions in modal and detail panel\n\n## Changes\n\n### components/DescriptionModal.tsx\n- Added \\`useState\\` import and \\`copied\\` state for feedback\n- Added \\`handleCopy()\\` function: calls \\`navigator.clipboard.writeText(node.description)\\`, sets \\`copied=true\\` for 1.5s\n- Added copy button in the modal header (between title and close X button):\n - Default: clipboard SVG icon (Heroicons clipboard-document, zinc-400)\n - After click: emerald-500 checkmark icon for 1.5 seconds\n - Wrapped both buttons in a flex container with gap-1\n\n### components/NodeDetail.tsx\n- Added \\`descCopied\\` state for feedback\n- Added copy button in the description section header (between \"Description\" label and \"View in window\" link):\n - Same clipboard → checkmark icon pattern as the modal\n - Slightly smaller (w-3.5 h-3.5) to match the sidebar's compact design\n - Wrapped \"View in window\" and copy button in a flex container with gap-2\n\n## UX\n- Copies raw markdown (not rendered HTML) — useful for pasting into editors, chat, or other tools\n- Brief visual feedback (checkmark) confirms the copy succeeded\n- Non-intrusive: small icon that doesn't compete with other controls","status":"closed","priority":2,"issue_type":"feature","owner":"david@gainforest.net","created_at":"2026-02-12T14:22:43.908091+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:22:50.515043+13:00","closed_at":"2026-02-12T14:22:50.515043+13:00","close_reason":"Closed"},{"id":"beads-map-iyn","title":"Add spawn/exit/change animations to paintNode","description":"Modify: components/BeadsGraph.tsx — paintNode() callback\n\nPURPOSE: Animate nodes based on the _spawnTime, _removeTime, and _changedAt timestamps set by the merge logic (task .5). New nodes pop in with a bouncy scale-up, removed nodes shrink out, and status-changed nodes flash a ripple effect.\n\nCHANGES TO paintNode (currently at line ~435, inside the useCallback):\n\n1. ADD EASING FUNCTIONS (above the component, near the helper functions around line 50):\n\n```typescript\n// Animation duration constants\nconst SPAWN_DURATION = 500; // ms for pop-in animation\nconst REMOVE_DURATION = 400; // ms for shrink-out animation\nconst CHANGE_DURATION = 800; // ms for status change ripple\n\n/**\n * easeOutBack: overshoots slightly then settles — gives \"pop\" feel.\n * t is 0..1, returns 0..~1.05 (overshoots before settling at 1)\n */\nfunction easeOutBack(t: number): number {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n}\n\n/**\n * easeOutQuad: smooth deceleration\n */\nfunction easeOutQuad(t: number): number {\n return 1 - (1 - t) * (1 - t);\n}\n```\n\n2. MODIFY paintNode() to add animation effects:\n\nAt the BEGINNING of paintNode, before any drawing, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (pop-in) ---\n let spawnScale = 1;\n const spawnTime = (graphNode as any)._spawnTime as number | undefined;\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n spawnScale = easeOutBack(elapsed / SPAWN_DURATION);\n }\n // After animation completes, _spawnTime is ignored (scale stays 1)\n }\n\n // --- Remove animation (shrink-out) ---\n let removeScale = 1;\n let removeOpacity = 1;\n const removeTime = (graphNode as any)._removeTime as number | undefined;\n if (removeTime) {\n const elapsed = now - removeTime;\n if (elapsed < REMOVE_DURATION) {\n const progress = elapsed / REMOVE_DURATION;\n removeScale = 1 - easeOutQuad(progress);\n removeOpacity = 1 - progress;\n } else {\n removeScale = 0; // fully gone\n removeOpacity = 0;\n }\n }\n\n const animScale = spawnScale * removeScale;\n if (animScale <= 0.01) return; // skip drawing invisible nodes\n\n const animatedSize = size * animScale;\n```\n\nREPLACE all references to `size` in the drawing code with `animatedSize`:\n- ctx.arc(node.x, node.y, size + 2, ...) → ctx.arc(node.x, node.y, animatedSize + 2, ...)\n- ctx.arc(node.x, node.y, size, ...) → ctx.arc(node.x, node.y, animatedSize, ...)\n- node.y + size + 3 → node.y + animatedSize + 3\n- node.y - size - 2 → node.y - animatedSize - 2\n\nAlso multiply the base opacity by removeOpacity:\n- ctx.globalAlpha = opacity → ctx.globalAlpha = opacity * removeOpacity\n\n3. ADD STATUS CHANGE RIPPLE after drawing the node body but before the label:\n\n```typescript\n // --- Status change ripple animation ---\n const changedAt = (graphNode as any)._changedAt as number | undefined;\n if (changedAt) {\n const elapsed = now - changedAt;\n if (elapsed < CHANGE_DURATION) {\n const progress = elapsed / CHANGE_DURATION;\n const rippleRadius = animatedSize + 4 + progress * 20;\n const rippleOpacity = (1 - progress) * 0.6;\n const newStatusColor = STATUS_COLORS[graphNode.status] || \"#a1a1aa\";\n\n ctx.beginPath();\n ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);\n ctx.strokeStyle = newStatusColor;\n ctx.lineWidth = 2 * (1 - progress);\n ctx.globalAlpha = rippleOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n4. ADD SPAWN GLOW: during the spawn animation, add a brief emerald glow ring:\n\n```typescript\n // --- Spawn glow ---\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n const glowProgress = elapsed / SPAWN_DURATION;\n const glowOpacity = (1 - glowProgress) * 0.4;\n const glowRadius = animatedSize + 6 + glowProgress * 8;\n ctx.beginPath();\n ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);\n ctx.strokeStyle = \"#10b981\";\n ctx.lineWidth = 3 * (1 - glowProgress);\n ctx.globalAlpha = glowOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n5. ADD CONTINUOUS REDRAW during active animations.\n\nThe canvas only redraws when the force simulation is active or when React state changes. During animations, we need continuous redraws. Add a useEffect that requests animation frames while animations are active:\n\n```typescript\n // Drive continuous canvas redraws during active animations\n useEffect(() => {\n let rafId: number;\n let active = true;\n\n function tick() {\n if (!active) return;\n const now = Date.now();\n const hasActiveAnimations = viewNodes.some((n: any) => {\n if (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) return true;\n if (n._removeTime && now - n._removeTime < REMOVE_DURATION) return true;\n if (n._changedAt && now - n._changedAt < CHANGE_DURATION) return true;\n return false;\n }) || viewLinks.some((l: any) => {\n if (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) return true;\n if (l._removeTime && now - l._removeTime < REMOVE_DURATION) return true;\n return false;\n });\n\n if (hasActiveAnimations) {\n refreshGraph(graphRef);\n }\n rafId = requestAnimationFrame(tick);\n }\n\n tick();\n return () => { active = false; cancelAnimationFrame(rafId); };\n }, [viewNodes, viewLinks]);\n```\n\nIMPORTANT: refreshGraph() already exists at line ~105 — it does an imperceptible zoom jitter to force canvas redraw. This is the exact right mechanism for animation frames.\n\nIMPORTANT: The paintNode callback has [] (empty) dependency array. This is correct and must NOT change — it reads from refs, not props. The animation timestamps are on the node objects themselves (passed as the first argument to paintNode by react-force-graph), so they're always current.\n\nDEPENDS ON: task .5 (page.tsx must stamp _spawnTime/_removeTime/_changedAt on nodes)\n\nACCEPTANCE CRITERIA:\n- New nodes pop in with easeOutBack scale animation (500ms)\n- New nodes show brief emerald glow ring during spawn\n- Removed nodes shrink to zero with fade-out (400ms)\n- Status-changed nodes show expanding ripple ring in new status color (800ms)\n- Animations are smooth (requestAnimationFrame drives redraws)\n- No visual glitches when multiple animations overlap\n- Non-animated nodes render identically to before (no regression)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:17:37.790522+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:39:22.776735+13:00","closed_at":"2026-02-10T23:39:22.776735+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.553429+13:00","created_by":"daviddao"},{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-ecl","type":"blocks","created_at":"2026-02-10T23:19:29.234083+13:00","created_by":"daviddao"}]},{"id":"beads-map-m1o","title":"Create lib/watch-beads.ts — file watcher with debounce","description":"Create a new file: lib/watch-beads.ts\n\nPURPOSE: Watch all issues.jsonl files (primary + additional repos from config.yaml) for changes using Node.js fs.watch(). When any file changes, fire a debounced callback. This is the server-side foundation for the SSE endpoint (task .3).\n\nINTERFACE:\n```typescript\n/**\n * Watch all issues.jsonl files for a beads project.\n * Discovers files from the primary .beads dir and config.yaml repos.additional.\n * Debounces rapid changes (bd often writes multiple times per command).\n *\n * @param beadsDir - Absolute path to the primary .beads/ directory\n * @param onChange - Callback fired when any watched file changes (after debounce)\n * @param debounceMs - Debounce interval in milliseconds (default: 300)\n * @returns Cleanup function that closes all watchers\n */\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs?: number\n): () => void;\n\n/**\n * Get all issues.jsonl file paths that should be watched.\n * Returns the primary path plus any additional repo paths from config.yaml.\n */\nexport function getWatchPaths(beadsDir: string): string[];\n```\n\nIMPLEMENTATION:\n\n```typescript\nimport { watch, existsSync } from \"fs\";\nimport { join } from \"path\";\nimport { getAdditionalRepoPaths } from \"./parse-beads\";\n\nexport function getWatchPaths(beadsDir: string): string[] {\n const paths: string[] = [];\n\n // Primary JSONL\n const primary = join(beadsDir, \"issues.jsonl\");\n if (existsSync(primary)) paths.push(primary);\n\n // Additional repo JSONLs\n const additionalRepos = getAdditionalRepoPaths(beadsDir);\n for (const repoPath of additionalRepos) {\n const jsonlPath = join(repoPath, \".beads\", \"issues.jsonl\");\n if (existsSync(jsonlPath)) paths.push(jsonlPath);\n }\n\n return paths;\n}\n\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs = 300\n): () => void {\n const paths = getWatchPaths(beadsDir);\n let timer: ReturnType<typeof setTimeout> | null = null;\n const watchers: ReturnType<typeof watch>[] = [];\n\n const debouncedOnChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(onChange, debounceMs);\n };\n\n for (const filePath of paths) {\n try {\n const watcher = watch(filePath, { persistent: false }, (eventType) => {\n if (eventType === \"change\") {\n debouncedOnChange();\n }\n });\n watchers.push(watcher);\n } catch (err) {\n console.warn(`Failed to watch ${filePath}:`, err);\n }\n }\n\n if (paths.length === 0) {\n console.warn(\"No issues.jsonl files found to watch\");\n }\n\n // Return cleanup function\n return () => {\n if (timer) clearTimeout(timer);\n for (const w of watchers) {\n w.close();\n }\n };\n}\n```\n\nKEY DESIGN DECISIONS:\n- persistent: false — so the watcher doesn't prevent Node.js from exiting\n- Only watches for \"change\" events (not \"rename\") since bd writes in-place\n- 300ms debounce: bd typically does flush→sync→write in rapid succession\n- If a watched file disappears (repo deleted), the watcher silently dies — acceptable\n\nEDGE CASES:\n- No additional repos: only watches primary issues.jsonl\n- Empty project (no issues.jsonl yet): returns empty paths array, logs warning\n- File deleted while watching: fs.watch fires an event, but next re-parse returns empty — handled gracefully by parse-beads.ts\n\nDEPENDS ON: task .1 (getAdditionalRepoPaths must be exported from parse-beads.ts)\n\nACCEPTANCE CRITERIA:\n- lib/watch-beads.ts exports watchBeadsFiles and getWatchPaths\n- Debounces rapid changes correctly (only one onChange call per burst)\n- Watches all JSONL files (primary + additional repos)\n- Cleanup function closes all watchers\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:15:32.448347+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:25:49.410672+13:00","closed_at":"2026-02-10T23:25:49.410672+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.23277+13:00","created_by":"daviddao"},{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.823723+13:00","created_by":"daviddao"}]},{"id":"beads-map-mfw","title":"Epic: Search comments by commenter username","description":"Allow searching for nodes by commenter username. Typing a Bluesky handle (e.g. 'daviddao') in the search bar should also surface nodes where that person has left comments. This extends the existing node-field search to include comment author handles.","status":"closed","priority":2,"issue_type":"epic","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:37:59.835891+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:39:16.820832+13:00","closed_at":"2026-02-12T10:39:16.820832+13:00","close_reason":"Completed: e2a49e1 — all tasks done","dependencies":[{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-8np","type":"blocks","created_at":"2026-02-12T10:39:55.570556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.652022+13:00","created_by":"daviddao"}]},{"id":"beads-map-mfw.1","title":"Include comment author handles in search matching","description":"In app/page.tsx: (1) Add a useMemo that builds a Map<string, string> from allComments — maps each nodeId to a space-joined string of unique commenter handles for that node. (2) In the searchResults useMemo, append the commenter handles string to the existing searchable string. This way typing 'daviddao.bsky.social' or just 'daviddao' surfaces nodes where that user commented. The searchResults useMemo needs allComments (or the derived map) in its dependency array.","status":"closed","priority":2,"issue_type":"task","assignee":"daviddao","owner":"david@gainforest.net","created_at":"2026-02-12T10:38:08.454443+13:00","created_by":"daviddao","updated_at":"2026-02-12T10:39:16.735264+13:00","closed_at":"2026-02-12T10:39:16.735264+13:00","close_reason":"Completed: e2a49e1","dependencies":[{"issue_id":"beads-map-mfw.1","depends_on_id":"beads-map-mfw","type":"parent-child","created_at":"2026-02-12T10:38:08.455822+13:00","created_by":"daviddao"}]},{"id":"beads-map-mq9","title":"Add spawn/exit animations to paintLink","description":"Modify: components/BeadsGraph.tsx — paintLink() callback\n\nPURPOSE: Animate links based on _spawnTime and _removeTime timestamps. New links fade in smoothly, removed links fade out. This complements the node animations (task .6).\n\nCHANGES TO paintLink (currently at line ~544, inside the useCallback):\n\n1. At the BEGINNING of paintLink, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (fade-in + thickness) ---\n let linkSpawnAlpha = 1;\n let linkSpawnWidth = 1;\n const linkSpawnTime = (link as any)._spawnTime as number | undefined;\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < SPAWN_DURATION) {\n const progress = elapsed / SPAWN_DURATION;\n linkSpawnAlpha = easeOutQuad(progress);\n linkSpawnWidth = 1 + (1 - progress) * 1.5; // starts 2.5x thick, settles to 1x\n }\n }\n\n // --- Remove animation (fade-out) ---\n let linkRemoveAlpha = 1;\n const linkRemoveTime = (link as any)._removeTime as number | undefined;\n if (linkRemoveTime) {\n const elapsed = now - linkRemoveTime;\n if (elapsed < REMOVE_DURATION) {\n linkRemoveAlpha = 1 - easeOutQuad(elapsed / REMOVE_DURATION);\n } else {\n return; // fully gone, skip drawing\n }\n }\n\n const linkAnimAlpha = linkSpawnAlpha * linkRemoveAlpha;\n if (linkAnimAlpha <= 0.01) return; // skip invisible links\n```\n\n2. MULTIPLY the existing opacity by linkAnimAlpha:\n\nCurrently (line ~574-580), the opacity is computed as:\n```typescript\n const opacity = isParentChild\n ? hasHighlight\n ? isConnectedLink ? 0.5 : 0.05\n : 0.2\n : hasHighlight\n ? isConnectedLink ? 0.8 : 0.08\n : 0.35;\n```\n\nAfter this, multiply:\n```typescript\n ctx.globalAlpha = opacity * linkAnimAlpha;\n```\n\n3. MULTIPLY the line width by linkSpawnWidth:\n\nCurrently the line width is set separately for parent-child and blocks links. Multiply each by linkSpawnWidth:\n```typescript\n // For parent-child:\n ctx.lineWidth = Math.max(0.6, 1.5 / globalScale) * linkSpawnWidth;\n // For blocks:\n ctx.lineWidth = (isConnectedLink\n ? Math.max(2, 2.5 / globalScale)\n : Math.max(0.8, 1.2 / globalScale)) * linkSpawnWidth;\n```\n\n4. ADD SPAWN FLASH for new links (optional but nice):\n\nAfter drawing the link curve, if it's spawning, draw a brief bright flash along the path:\n\n```typescript\n // Brief bright flash for new links\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < 300) {\n const flashProgress = elapsed / 300;\n const flashAlpha = (1 - flashProgress) * 0.5;\n ctx.save();\n ctx.globalAlpha = flashAlpha;\n ctx.strokeStyle = \"#10b981\"; // emerald\n ctx.lineWidth = (isParentChild ? 3 : 4) / globalScale;\n ctx.beginPath();\n ctx.moveTo(start.x, start.y);\n ctx.quadraticCurveTo(cx, cy, end.x, end.y);\n ctx.stroke();\n ctx.restore();\n }\n }\n```\n\nThis creates a bright emerald line that fades out over 300ms, overlaid on the normal link.\n\nIMPORTANT: The paintLink callback has [] (empty) dependency array. Keep it that way. Animation timestamps are on the link objects themselves.\n\nIMPORTANT: The SPAWN_DURATION, REMOVE_DURATION, easeOutQuad constants are shared with paintNode (task .6). They should be declared at module level (above the component), not inside the callbacks. If task .6 is implemented first, they'll already exist.\n\nDEPENDS ON: task .5 (links must have _spawnTime/_removeTime), task .6 (shared animation constants + easing functions)\n\nACCEPTANCE CRITERIA:\n- New links fade in over 500ms with initial thickness burst\n- New links show brief emerald flash (300ms)\n- Removed links fade out over 400ms\n- Flow particles on new links are also affected by spawn alpha (not critical, nice-to-have)\n- No visual regression for non-animated links\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-10T23:18:20.715649+13:00","created_by":"daviddao","updated_at":"2026-02-10T23:39:22.858151+13:00","closed_at":"2026-02-10T23:39:22.858151+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.630363+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-iyn","type":"blocks","created_at":"2026-02-10T23:19:29.312556+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c","title":"v0.3.2: Fix help descriptions and add interactive tutorial","description":"## Overview\n\nThis epic covers two features:\n\n1. **Fix help page description** — Change \"arrows\" to \"flowing particles\" in the help text to accurately describe the link animation (particles flow along dependency lines, not static arrows).\n2. **Interactive tutorial** — A \"Start Tutorial\" button in the HelpPanel that launches a multi-step guided tour. Each step highlights a UI element with a spotlight cutout overlay (dark semi-transparent backdrop with a rounded hole) and shows a description in the HelpPanel sidebar with Next/Back/step-indicator navigation.\n\n## Tutorial Steps (7 total)\n\n| Step | Target `data-tutorial` | Highlight element | Description |\n|------|----------------------|-------------------|-------------|\n| 0 | `graph` | Graph canvas container | Welcome — circles are tasks, flowing particles show dependency direction |\n| 1 | `layouts` | Layout button group (top-left) | Switch between Force, DAG, Radial, Cluster, Spread |\n| 2 | `legend` | Legend panel (bottom-right) | Color nodes by Status, Priority, Owner, Assignee, or Prefix |\n| 3 | `minimap` | Minimap (bottom-left) | Click to navigate, drag edges to resize |\n| 4 | `search` | Search bar (header center) | Cmd/Ctrl+F to search by name, ID, owner, commenter |\n| 5 | `graph` | Graph canvas again | Click for details, hover for summary, right-click for actions |\n| 6 | `nav-pills` | Nav pill buttons (header right) | Replay, Comments, Activity, Help |\n\n## Design decisions\n\n- **Spotlight cutout style**: Semi-transparent dark overlay (`bg-black/50`) with an SVG or CSS clip-path rounded rectangle cutout around the target element. The cutout position is computed via `document.querySelector(\"[data-tutorial=X]\").getBoundingClientRect()`.\n- **Tutorial text in HelpPanel sidebar**: When `tutorialStep !== null`, HelpPanel switches from static help content to a step-by-step view with title, description, step indicator (dots or \"2 of 7\"), and Next/Back buttons.\n- **State ownership**: `page.tsx` owns `tutorialStep: number | null`. `null` = inactive. `0–6` = active step. Passed to HelpPanel and TutorialOverlay as props.\n- **Sidebar auto-open**: Starting the tutorial auto-opens HelpPanel and closes other sidebars. Ending the tutorial keeps HelpPanel open showing normal content.\n- **z-index**: TutorialOverlay uses z-40 (above z-30 sidebars, below z-50 header). The header elements (search, nav pills) need the spotlight to cover them, so the overlay portal may need z-[45] or z-[55] depending on which step.\n\n## Files\n\n### New files\n- `components/TutorialOverlay.tsx` — Spotlight overlay with step config, DOM rect computation, dark backdrop with cutout\n\n### Modified files\n- `components/HelpPanel.tsx` — Fix \"arrows\" text, add \"Start Tutorial\" button, add tutorial step content mode\n- `components/BeadsGraph.tsx` — Add `data-tutorial` attributes to: graph container, layout buttons, legend panel, minimap\n- `app/page.tsx` — Add `tutorialStep` state, add `data-tutorial` attrs to nav-pills and search, wire callbacks, render TutorialOverlay\n\n## Acceptance criteria\n\n- [ ] Help page says \"flowing particles\" not \"arrows\"\n- [ ] \"Start Tutorial\" button visible in HelpPanel\n- [ ] Tutorial walks through all 7 steps with spotlight highlighting\n- [ ] Next/Back navigation works, step indicator shows progress\n- [ ] Ending tutorial returns to normal help content\n- [ ] `pnpm build` passes with zero errors\n- [ ] All 7 highlighted elements are visible and correctly spotlighted","notes":"## Follow-up commits (post-review polish)\n\n4 additional tasks (.7-.10) for iterative fixes after user testing:\n\n13. 68dffea — Rename Help->Learn, first emoji restyle + overlay fix attempt (task .7)\n14. fc08d3f — Redesign: Catppuccin colored dots, strip emojis, fix unicode escapes (task .8)\n15. c47a631 — Fix sidebar click-through: raise HelpPanel z-[60] above overlay z-[55] (task .9)\n16. 288ce6c — Emerald accent for Next/Done buttons, unified bullet colors per section (task .10)\n\n### Key lesson: SVG masks are visual only\nSVG mask cutouts do NOT affect pointer-event hit testing. To make elements clickable through an overlay, raise their z-index above it.\n\n### Final z-index stack during tutorial\n- z-30: other sidebars\n- z-[55]: dark overlay (clickable, advances tutorial)\n- z-[56]: spotlight ring (visual only)\n- z-[60]: HelpPanel sidebar (fully interactive)","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-12T15:24:59.311936+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:51:47.512892+13:00","closed_at":"2026-02-12T15:51:47.512892+13:00","close_reason":"Closed"},{"id":"beads-map-s0c.1","title":"Fix help page description: arrows → flowing particles","description":"## What\n\nThe current HelpPanel describes dependency links as \"arrows\" — but the actual visual is **flowing particles** (animated dots that move along the link line to show direction). Fix the text to match reality.\n\n## File: `components/HelpPanel.tsx`\n\n### Change 1: Line 90-92\n**Current text:**\n```tsx\nEach <strong>circle</strong> is a task or issue. The <strong>arrows</strong>{\" \"}\nbetween them show dependencies &mdash; what needs to happen before\nsomething else can start.\n```\n\n**Replace with:**\n```tsx\nEach <strong>circle</strong> is a task or issue. The <strong>flowing particles</strong>{\" \"}\nbetween them show dependencies &mdash; they stream in the direction things\nneed to happen.\n```\n\n### Change 2: Line 97-99 (the \"Solid arrows\" bullet)\n**Current text:**\n```tsx\n<span><strong>Solid arrows</strong> = &ldquo;blocks&rdquo; (A must finish before B)</span>\n```\n\n**Replace with:**\n```tsx\n<span><strong>Solid lines with flowing particles</strong> = &ldquo;blocks&rdquo; (A must finish before B)</span>\n```\n\n### No change needed for the \"Dashed lines\" bullet (line 102-104)\nDashed lines (parent-child) do NOT have particles, so the current description is correct.\n\n## Acceptance criteria\n\n- [ ] \"arrows\" no longer appears in HelpPanel text\n- [ ] \"flowing particles\" describes dependency links\n- [ ] \"Solid lines with flowing particles\" describes blocks links\n- [ ] \"Dashed lines\" description unchanged\n- [ ] `pnpm build` passes","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:25:11.056603+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:28:22.694325+13:00","closed_at":"2026-02-12T15:28:22.694325+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.1","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:25:11.058045+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.10","title":"Use emerald accent for Next/Done buttons, unify bullet dot colors per section","description":"## What\n\nFinal polish pass on tutorial and help panel styling. Two changes:\n\n1. **Next/Done buttons** — Changed from Catppuccin Green (`#40a02b` via inline style) to the app's emerald accent (`bg-emerald-500 hover:bg-emerald-600` via Tailwind classes) to match the Start Tutorial and other primary buttons.\n\n2. **Bullet dot colors** — Each section's bullet dots now use one consistent color matching their section title, instead of each bullet having a different Catppuccin color.\n\n## Commit\n- 288ce6c — Use emerald accent for tutorial Next/Done buttons, unify bullet colors per section\n\n## File modified\n\n### `components/HelpPanel.tsx`\n\n**Tutorial nav buttons (lines 179-195):**\n```diff\n- className=\"... text-white rounded-lg transition-colors hover:opacity-90\"\n- style={{ backgroundColor: CAT.green }}\n+ className=\"... text-white rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors\"\n```\nApplied to both the \"Done\" (last step) and \"Next\" (other steps) buttons.\n\n**Bullet colors — before (rainbow per section):**\n```tsx\n// Graph section had: CAT.red, CAT.peach, CAT.teal, CAT.blue\n// Navigation had: CAT.blue, CAT.sapphire, CAT.teal, CAT.green, CAT.mauve\n```\n\n**Bullet colors — after (uniform per section):**\n```tsx\n// Graph section: all CAT.red (matches SectionTitle color={CAT.red})\n// Navigation: all CAT.blue (matches SectionTitle color={CAT.blue})\n// Layouts: all CAT.teal (matches SectionTitle color={CAT.teal})\n// Color modes: all CAT.peach (matches SectionTitle color={CAT.peach})\n// More: all CAT.mauve (matches SectionTitle color={CAT.mauve})\n```\n\nAlso removed inline `style={{ color: CAT.xxx }}` from layout name `<strong>` tags — they no longer need individual colors since the dots handle the visual distinction.\n\n## Design rationale\n- Emerald-500 is the app's primary accent used everywhere (active nav pills, layout mode buttons, color mode selector, auth button highlight). Using it for tutorial nav buttons keeps things consistent.\n- Uniform dot colors per section creates a cleaner visual rhythm. The section title color provides the identity; individual bullet colors were noise.\n\n## Acceptance criteria (all met)\n- [x] Next button is emerald-500\n- [x] Done button is emerald-500\n- [x] All bullets in \"The graph\" section use red dots\n- [x] All bullets in \"Navigation\" section use blue dots\n- [x] All bullets in \"Layouts\" section use teal dots\n- [x] All bullets in \"Color modes\" section use peach dots\n- [x] All bullets in \"More\" section use mauve dots\n- [x] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:51:17.69638+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:51:24.554543+13:00","closed_at":"2026-02-12T15:51:24.554543+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.10","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:51:17.69846+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.2","title":"Add data-tutorial attributes to existing UI elements for spotlight targeting","description":"## What\n\nAdd `data-tutorial=\"<step-name>\"` attributes to 6 key UI elements so the TutorialOverlay can find them via `document.querySelector(\"[data-tutorial=X]\")` and compute their bounding rect for the spotlight cutout.\n\n## Attributes to add\n\n| Attribute value | Element | File | Approx line |\n|----------------|---------|------|-------------|\n| `graph` | Graph container div | `components/BeadsGraph.tsx:1791` | `<div ref={containerRef} className=\"w-full h-full relative\">` |\n| `layouts` | Layout button group | `components/BeadsGraph.tsx:1795` | `<div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg ...\">` |\n| `legend` | Legend info panel | `components/BeadsGraph.tsx:2013-2014` | `<div className=\"absolute bottom-4 z-10 bg-white/90 ...\">` |\n| `minimap` | Minimap wrapper div | `components/BeadsGraph.tsx:2094` | `<div className=\"hidden sm:block absolute bottom-4 left-4 z-10\" ...>` |\n| `search` | Search bar wrapper | `app/page.tsx:982` | `<div className=\"relative w-full max-w-md\">` |\n| `nav-pills` | Nav pills group div | `app/page.tsx:1137` | `<div className=\"hidden md:flex items-center gap-1 shrink-0\">` |\n\n## Exact changes\n\n### `components/BeadsGraph.tsx`\n\n**Line 1791** — graph container:\n```diff\n- <div ref={containerRef} className=\"w-full h-full relative\">\n+ <div ref={containerRef} className=\"w-full h-full relative\" data-tutorial=\"graph\">\n```\n\n**Line 1795** — layout button group:\n```diff\n- <div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\">\n+ <div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\" data-tutorial=\"layouts\">\n```\n\n**Line 2013-2014** — legend panel:\n```diff\n- <div\n- 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\"\n+ <div\n+ 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\"\n+ data-tutorial=\"legend\"\n```\n\n**Line 2094** — minimap wrapper:\n```diff\n- <div\n- className=\"hidden sm:block absolute bottom-4 left-4 z-10\"\n+ <div\n+ className=\"hidden sm:block absolute bottom-4 left-4 z-10\"\n+ data-tutorial=\"minimap\"\n```\n\n### `app/page.tsx`\n\n**Line 982** — search bar wrapper:\n```diff\n- <div className=\"relative w-full max-w-md\">\n+ <div className=\"relative w-full max-w-md\" data-tutorial=\"search\">\n```\n\n**Line 1137** — nav pills group:\n```diff\n- <div className=\"hidden md:flex items-center gap-1 shrink-0\">\n+ <div className=\"hidden md:flex items-center gap-1 shrink-0\" data-tutorial=\"nav-pills\">\n```\n\n## Key constraint\n\nThese are attribute-only additions — no logic changes. The attributes are inert until TutorialOverlay reads them via `querySelector`.\n\n## Acceptance criteria\n\n- [ ] All 6 `data-tutorial` attributes present in the DOM when the page renders\n- [ ] No visual or behavioral changes to any existing UI\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:25:28.383794+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:28:56.171439+13:00","closed_at":"2026-02-12T15:28:56.171439+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.2","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:25:28.384637+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.2","depends_on_id":"beads-map-s0c.1","type":"blocks","created_at":"2026-02-12T15:25:28.392+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.3","title":"Create TutorialOverlay component: spotlight cutout with dark backdrop","description":"## What\n\nNew component `components/TutorialOverlay.tsx` that renders a semi-transparent dark overlay covering the entire viewport with a rounded rectangular \"cutout\" (transparent hole) around the currently highlighted UI element.\n\n## Props interface\n\n```typescript\ninterface TutorialOverlayProps {\n step: number | null; // null = hidden, 0-6 = active step\n onNext: () => void; // advance to next step\n onPrev: () => void; // go to previous step\n onEnd: () => void; // end tutorial\n}\n```\n\n## Step configuration\n\nDefine a `TUTORIAL_STEPS` array inside the component (or as a shared constant):\n\n```typescript\ninterface TutorialStep {\n target: string; // data-tutorial attribute value\n title: string; // step title for sidebar\n description: string; // step description for sidebar\n padding?: number; // extra px around the cutout (default 8)\n}\n\nconst TUTORIAL_STEPS: TutorialStep[] = [\n {\n target: \"graph\",\n title: \"The Dependency Graph\",\n description: \"Welcome to Heartbeads! Each circle is a task or issue. The flowing particles between them show the direction of dependencies — what needs to happen before something else can start. Bigger circles are more connected and more important.\",\n },\n {\n target: \"layouts\",\n title: \"Layout Modes\",\n description: \"Switch how the graph is arranged. Force is organic and physics-based. DAG gives you a clean top-down tree. Radial spreads nodes in rings. Cluster groups by project. Spread spaces everything out for screenshots.\",\n },\n {\n target: \"legend\",\n title: \"Color Modes & Legend\",\n description: \"Color nodes by different dimensions. Status shows open/in-progress/blocked/closed. Priority goes from P0 (critical) to P4 (backlog). Owner and Assignee color by person. Prefix colors by project. The ring around each node always shows the project.\",\n },\n {\n target: \"minimap\",\n title: \"Minimap\",\n description: \"A bird's-eye view of your entire graph. Click anywhere on it to jump to that area. Drag the edges to resize it. The highlighted rectangle shows what's currently visible on screen.\",\n },\n {\n target: \"search\",\n title: \"Search\",\n description: \"Press Cmd+F (or Ctrl+F on Windows/Linux) to search by issue name, ID, owner, assignee, or even commenter handle. Use arrow keys to navigate results and Enter to focus on an issue.\",\n },\n {\n target: \"graph\",\n title: \"Interacting with Nodes\",\n description: \"Click any node to open its details in the sidebar. Hover for a quick summary tooltip. Right-click for actions: view the full description, add a comment, claim the task as yours, or collapse/expand an epic.\",\n },\n {\n target: \"nav-pills\",\n title: \"Navigation Bar\",\n description: \"Replay lets you step through your project's history like a movie. Comments shows all conversations across issues. Activity shows a real-time feed of what's happening. Help brings you back here anytime.\",\n },\n];\n```\n\n**Export `TUTORIAL_STEPS` so HelpPanel can import it for rendering step content.**\n\n## Rendering approach\n\n1. **When `step === null`**: render nothing (return null).\n2. **When `step` is a number**:\n a. Query `document.querySelector(\\`[data-tutorial=\"${TUTORIAL_STEPS[step].target}\"]\\`)` to find the target element.\n b. Call `getBoundingClientRect()` on it.\n c. Render a full-viewport overlay (`fixed inset-0 z-[45]`) with a dark semi-transparent background.\n d. Use an SVG with a `<rect>` fill covering the whole viewport and a `<rect>` cutout (via `<mask>` or `clipPath`) creating a transparent rounded rectangle at the target element's position + padding.\n e. Add a subtle animated border/glow around the cutout (optional, via a separate absolutely-positioned `<div>` with `ring-2 ring-emerald-400 animate-pulse` or similar).\n\n## SVG mask approach (recommended)\n\n```tsx\n<svg className=\"fixed inset-0 w-full h-full z-[45] pointer-events-none\" style={{ mixBlendMode: \"normal\" }}>\n <defs>\n <mask id=\"tutorial-mask\">\n <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n <rect\n x={rect.left - padding}\n y={rect.top - padding}\n width={rect.width + padding * 2}\n height={rect.height + padding * 2}\n rx={8}\n fill=\"black\"\n />\n </mask>\n </defs>\n <rect\n width=\"100%\"\n height=\"100%\"\n fill=\"rgba(0,0,0,0.5)\"\n mask=\"url(#tutorial-mask)\"\n />\n</svg>\n```\n\nThen separately render a clickable overlay div (`fixed inset-0 z-[44]`) that captures clicks outside the cutout to advance to the next step (or end the tutorial on the last step).\n\n## Resize handling\n\nUse a `useEffect` + `ResizeObserver` or `window.addEventListener(\"resize\")` to recalculate the target rect when the viewport changes. Also recalculate on step change.\n\n## Positioning edge cases\n\n- If `querySelector` returns null (element not rendered, e.g., minimap on mobile), skip the spotlight and just show the overlay without a cutout, or auto-advance to the next step.\n- For step 0 and 5 (both target `graph`), the cutout will be very large (the whole graph canvas). That's fine — it draws attention to the whole graph area.\n\n## Z-index considerations\n\n- The overlay SVG: `z-[45]` — above sidebars (z-30), below header (z-50)\n- For steps 4 (search) and 6 (nav-pills), the targets are IN the z-50 header. Two options:\n a. Use `z-[55]` for the overlay on those steps (above the header)\n b. Or keep z-[45] and accept that the header shows above the overlay (the spotlight still highlights the right area visually)\n **Recommended: Use `z-[55]` for ALL steps** to ensure the spotlight is always above everything. The cutout hole will still allow the underlying element to be visible through it.\n\n## Acceptance criteria\n\n- [ ] `TutorialOverlay` renders nothing when `step === null`\n- [ ] Dark backdrop covers viewport when step is active\n- [ ] Rounded cutout hole appears around the correct target element for each step\n- [ ] Cutout position updates on window resize\n- [ ] `TUTORIAL_STEPS` array exported for use by HelpPanel\n- [ ] Graceful fallback when target element is not found (no crash)\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:26:04.16039+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:29:35.95281+13:00","closed_at":"2026-02-12T15:29:35.95281+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.3","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:26:04.162359+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.3","depends_on_id":"beads-map-s0c.2","type":"blocks","created_at":"2026-02-12T15:26:04.163786+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.4","title":"Update HelpPanel: Start Tutorial button and step-by-step tutorial content","description":"## What\n\nModify `components/HelpPanel.tsx` to:\n1. Add a \"Start Tutorial\" button at the top of the help content\n2. When a tutorial is active (`tutorialStep !== null`), replace the static help content with a step-by-step tutorial view showing the current step's title, description, step indicator, and Next/Back buttons\n\n## New props\n\n```typescript\ninterface HelpPanelProps {\n isOpen: boolean;\n onClose: () => void;\n // New tutorial props:\n tutorialStep: number | null; // null = normal mode, 0-6 = tutorial mode\n onStartTutorial: () => void; // callback when user clicks \"Start Tutorial\"\n onNextStep: () => void; // advance tutorial\n onPrevStep: () => void; // go back\n onEndTutorial: () => void; // end tutorial (returns to normal help)\n}\n```\n\n## Changes to HelpContent\n\n### Add \"Start Tutorial\" button\n\nAt the top of `HelpContent` (before the first `<SectionTitle>`), add a prominent button:\n\n```tsx\n<button\n onClick={onStartTutorial}\n className=\"w-full flex items-center justify-center gap-2 px-4 py-2.5 mb-4 bg-emerald-500 text-white text-sm font-medium rounded-lg hover:bg-emerald-600 transition-colors\"\n>\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5\" />\n </svg>\n Start Tutorial\n</button>\n```\n\n### Tutorial mode content\n\nWhen `tutorialStep !== null`, instead of rendering `<HelpContent>`, render a `<TutorialContent>` component:\n\n```tsx\nfunction TutorialContent({\n step,\n onNext,\n onPrev,\n onEnd,\n}: {\n step: number;\n onNext: () => void;\n onPrev: () => void;\n onEnd: () => void;\n}) {\n const currentStep = TUTORIAL_STEPS[step]; // imported from TutorialOverlay\n const totalSteps = TUTORIAL_STEPS.length;\n const isFirst = step === 0;\n const isLast = step === totalSteps - 1;\n\n return (\n <div className=\"px-5 py-4 flex flex-col h-full\">\n {/* Step indicator: \"Step 1 of 7\" + dots */}\n <div className=\"flex items-center justify-between mb-4\">\n <span className=\"text-xs text-zinc-400 font-medium\">\n Step {step + 1} of {totalSteps}\n </span>\n <div className=\"flex gap-1\">\n {Array.from({ length: totalSteps }).map((_, i) => (\n <div\n key={i}\n className={`w-1.5 h-1.5 rounded-full transition-colors ${\n i === step ? \"bg-emerald-500\" : i < step ? \"bg-emerald-300\" : \"bg-zinc-200\"\n }`}\n />\n ))}\n </div>\n </div>\n\n {/* Step title */}\n <h3 className=\"text-base font-semibold text-zinc-900 mb-2\">\n {currentStep.title}\n </h3>\n\n {/* Step description */}\n <p className=\"text-[13px] text-zinc-600 leading-relaxed mb-6\">\n {currentStep.description}\n </p>\n\n {/* Spacer */}\n <div className=\"flex-1\" />\n\n {/* Navigation buttons */}\n <div className=\"flex items-center gap-2 pt-4 border-t border-zinc-100\">\n {!isFirst && (\n <button\n onClick={onPrev}\n className=\"px-4 py-2 text-sm font-medium text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-50 transition-colors\"\n >\n Back\n </button>\n )}\n <div className=\"flex-1\" />\n {isLast ? (\n <button\n onClick={onEnd}\n className=\"px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors\"\n >\n Done\n </button>\n ) : (\n <button\n onClick={onNext}\n className=\"px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors\"\n >\n Next\n </button>\n )}\n </div>\n </div>\n );\n}\n```\n\n### Header changes\n\nWhen tutorial is active, change the header text from \"Welcome to Heartbeads\" to \"Tutorial\" and add an X button that calls `onEndTutorial` (the existing close button can double as this).\n\n### Conditional rendering\n\nIn the main `HelpPanel` component, replace:\n```tsx\n<div className=\"flex-1 overflow-y-auto custom-scrollbar\">\n <HelpContent />\n</div>\n```\n\nWith:\n```tsx\n<div className=\"flex-1 overflow-y-auto custom-scrollbar\">\n {tutorialStep !== null ? (\n <TutorialContent\n step={tutorialStep}\n onNext={onNextStep}\n onPrev={onPrevStep}\n onEnd={onEndTutorial}\n />\n ) : (\n <HelpContent onStartTutorial={onStartTutorial} />\n )}\n</div>\n```\n\nNote: `HelpContent` now receives `onStartTutorial` as a prop to render the button.\n\n### Both desktop and mobile\n\nApply the same conditional rendering to both the desktop sidebar `<aside>` and the mobile bottom drawer `<div>`. Both currently render `<HelpContent />` — both need the conditional.\n\n### Also fix the typo on line 48\n\nThe mobile drawer header says \"Welcome to Heartbeats\" (missing the \"d\") — should be \"Welcome to Heartbeads\" to match the desktop header on line 20.\n\n## Acceptance criteria\n\n- [ ] \"Start Tutorial\" button renders at top of help content in both desktop and mobile\n- [ ] Clicking \"Start Tutorial\" calls `onStartTutorial`\n- [ ] When `tutorialStep !== null`, tutorial content replaces normal help content\n- [ ] Step indicator shows current step and total with dots\n- [ ] Title and description match the current TUTORIAL_STEPS entry\n- [ ] Back button hidden on first step, shows on steps 1-6\n- [ ] Last step shows \"Done\" instead of \"Next\"\n- [ ] Mobile drawer typo \"Heartbeats\" → \"Heartbeads\" fixed\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:26:35.75358+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:30:37.487173+13:00","closed_at":"2026-02-12T15:30:37.487173+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.4","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:26:35.755265+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.4","depends_on_id":"beads-map-s0c.3","type":"blocks","created_at":"2026-02-12T15:26:35.756906+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.5","title":"Wire tutorial state in page.tsx: state, callbacks, TutorialOverlay, HelpPanel props","description":"## What\n\nAdd tutorial state management in `app/page.tsx` and wire TutorialOverlay + HelpPanel together.\n\n## State\n\nAdd near line 251 (after `helpPanelOpen` state):\n\n```typescript\nconst [tutorialStep, setTutorialStep] = useState<number | null>(null);\n```\n\n## Callbacks\n\nAdd after the `helpPanelOpen` state:\n\n```typescript\nconst handleStartTutorial = useCallback(() => {\n // Open help panel and close other sidebars\n setHelpPanelOpen(true);\n setSelectedNode(null);\n setAllCommentsPanelOpen(false);\n setActivityPanelOpen(false);\n // Start at step 0\n setTutorialStep(0);\n}, []);\n\nconst handleNextTutorialStep = useCallback(() => {\n setTutorialStep((prev) => {\n if (prev === null) return null;\n // Import TUTORIAL_STEPS.length or use 7 directly\n if (prev >= 6) return prev; // already at last step\n return prev + 1;\n });\n}, []);\n\nconst handlePrevTutorialStep = useCallback(() => {\n setTutorialStep((prev) => {\n if (prev === null || prev <= 0) return prev;\n return prev - 1;\n });\n}, []);\n\nconst handleEndTutorial = useCallback(() => {\n setTutorialStep(null);\n // Keep help panel open showing normal content\n}, []);\n```\n\n## Import TutorialOverlay\n\n```typescript\nimport { TutorialOverlay } from \"@/components/TutorialOverlay\";\n```\n\n## Render TutorialOverlay\n\nAdd the TutorialOverlay right before the closing `</div>` of the root container (around line 1637), or inside the main content area. It should be a sibling of the main content, not nested inside the graph area:\n\n```tsx\n{/* Tutorial spotlight overlay */}\n<TutorialOverlay\n step={tutorialStep}\n onNext={handleNextTutorialStep}\n onPrev={handlePrevTutorialStep}\n onEnd={handleEndTutorial}\n/>\n```\n\nPlace this between the `</div>` that closes the `flex-1 flex overflow-hidden relative` (line 1636) and the final `</div>` that closes the root `h-screen` (line 1637). This ensures it is portaled above the main content.\n\n## Update HelpPanel props\n\nChange the `<HelpPanel>` JSX (around line 1632-1635) from:\n\n```tsx\n<HelpPanel\n isOpen={helpPanelOpen}\n onClose={() => setHelpPanelOpen(false)}\n/>\n```\n\nTo:\n\n```tsx\n<HelpPanel\n isOpen={helpPanelOpen}\n onClose={() => {\n setHelpPanelOpen(false);\n setTutorialStep(null); // end tutorial when closing help panel\n }}\n tutorialStep={tutorialStep}\n onStartTutorial={handleStartTutorial}\n onNextStep={handleNextTutorialStep}\n onPrevStep={handlePrevTutorialStep}\n onEndTutorial={handleEndTutorial}\n/>\n```\n\n## Sidebar mutual exclusivity update\n\nWhen the tutorial is active and user clicks a different sidebar button (Comments, Activity, Node), the tutorial should end:\n\n- Update `handleNodeClick` (line 526-531): add `setTutorialStep(null)` alongside the existing `setHelpPanelOpen(false)`\n- Update the Comments pill `onClick` (line 1161-1167): add `setTutorialStep(null)` if opening\n- Update the Activity pill `onClick` (line 1192-1198): add `setTutorialStep(null)` if opening\n\n## sidebarOpen prop update\n\nThe `sidebarOpen` prop passed to `BeadsGraph` (line 1285) already includes `helpPanelOpen`, so no change needed there. The legend panel will correctly slide inward when the help panel is open during the tutorial.\n\n## Acceptance criteria\n\n- [ ] `tutorialStep` state managed in page.tsx\n- [ ] \"Start Tutorial\" button in HelpPanel triggers step 0 + opens help panel + closes other sidebars\n- [ ] Next/Back/Done navigation works through all 7 steps\n- [ ] Ending tutorial (Done or close) resets to null and shows normal help content\n- [ ] Opening another sidebar during tutorial ends the tutorial\n- [ ] TutorialOverlay rendered with correct props\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:26:57.639244+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:31:52.386289+13:00","closed_at":"2026-02-12T15:31:52.386289+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.5","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:26:57.640666+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.5","depends_on_id":"beads-map-s0c.3","type":"blocks","created_at":"2026-02-12T15:26:57.642105+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.5","depends_on_id":"beads-map-s0c.4","type":"blocks","created_at":"2026-02-12T15:26:57.643209+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.6","title":"Build verify, bd sync, and push interactive tutorial feature","description":"## What\n\nFinal quality gate for the interactive tutorial feature. Run the build, fix any errors, sync beads, commit, and push.\n\n## Steps\n\n1. **Run `pnpm build`** — must pass with zero errors\n2. **If `PageNotFoundError` or `Cannot find module`**: Run `rm -rf .next node_modules/.cache && sleep 1 && pnpm build`\n3. **If type errors**: Fix them in the relevant files and rebuild\n4. **Close all child tasks**: `bd close beads-map-s0c.1` through `bd close beads-map-s0c.5`\n5. **Close the epic**: `bd close beads-map-s0c`\n6. **Sync and push**:\n ```bash\n git add -A\n git commit -m \"Add interactive tutorial with spotlight overlay and fix help page descriptions\"\n bd sync\n git push\n git status # MUST show \"up to date with origin\"\n ```\n\n## Common build issues to watch for\n\n- **Missing imports**: `TUTORIAL_STEPS` exported from `TutorialOverlay.tsx` and imported in `HelpPanel.tsx`\n- **Unused imports**: If any old imports are no longer needed after refactoring HelpContent\n- **Type mismatches**: Ensure `HelpPanelProps` interface updated with all new optional props\n- **ESLint exhaustive-deps**: The new useCallbacks in page.tsx should have correct deps arrays\n\n## Acceptance criteria\n\n- [ ] `pnpm build` exits with code 0\n- [ ] All child beads closed\n- [ ] Epic beads closed\n- [ ] `bd sync` succeeds\n- [ ] `git push` succeeds\n- [ ] `git status` shows clean working tree, up to date with origin","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:27:10.154802+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:32:22.229746+13:00","closed_at":"2026-02-12T15:32:22.229746+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.6","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:27:10.156391+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.6","depends_on_id":"beads-map-s0c.5","type":"blocks","created_at":"2026-02-12T15:27:10.157664+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.7","title":"Rename Help pill to Learn with lightbulb icon, first pass at emoji restyle and overlay fix","description":"## What\n\nFirst iteration of polish after the initial tutorial build. Three changes in one commit:\n\n1. **Rename Help -> Learn** in the navbar pill button\n2. **Restyle HelpPanel** with emojis and Catppuccin colors (later reverted to dots)\n3. **First attempt at overlay click fix** using sidebar rect cutout + click delegation (later replaced)\n\n## Commit\n- 68dffea — Fix tutorial Back button, restyle help with emojis + Catppuccin colors, rename Help to Learn\n\n## Files modified\n\n### `app/page.tsx`\n- Changed comment `{/* Help pill */}` -> `{/* Learn pill */}`\n- Replaced question-mark-circle SVG with Heroicons lightbulb outline SVG:\n ```\n d=\"M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18\"\n ```\n- Changed label text from \"Help\" to \"Learn\"\n\n### `components/HelpPanel.tsx`\n- Added Catppuccin Latte color constants (`CAT_RED`, `CAT_TEAL`, `CAT_PEACH`, `CAT_BLUE`, `CAT_GREEN`, `CAT_MAUVE`)\n- Header text uses emoji: `\"\\ud83d\\udc9a Welcome to Heartbeads\"` / `\"\\u2728 Tutorial\"`\n- Replaced all `--` bullet prefixes with emoji characters (later reverted)\n- `SectionTitle` now accepts `color` prop, renders with `style={{ color }}`\n- Start Tutorial button changed to Catppuccin Mauve background with lightbulb SVG\n- Tutorial nav: Back button uses `CAT_BLUE`, Next/Done uses `CAT_GREEN`\n- First step shows \"Skip\" button instead of hiding Back entirely\n- Step indicator dots use `CAT_GREEN`/`CAT_TEAL`/`CAT.surface` palette\n\n### `components/TutorialOverlay.tsx`\n- Added `sidebarRect` state and sidebar detection via `document.querySelector(\"aside.translate-x-0\")`\n- SVG mask now has two cutout rects: target element + sidebar\n- `handleOverlayClick` checks if click lands in sidebar rect and returns early\n- Tutorial step titles prefixed with emojis (later removed)\n- Last step description changed \"Help\" to \"Learn\"\n\n## Key discovery\nThe sidebar rect cutout approach did NOT work — SVG mask cutouts are visual only, they do not affect pointer-event hit testing. The overlay div still captured clicks over the sidebar area. This was fixed in commit c47a631.\n\n## Acceptance criteria (all met at time of commit)\n- [x] Navbar shows \"Learn\" with lightbulb icon\n- [x] Help content uses Catppuccin colors for section titles\n- [x] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:50:23.532045+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:51:24.462279+13:00","closed_at":"2026-02-12T15:51:24.462279+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.7","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:50:23.533357+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.8","title":"Redesign help content: Catppuccin colored dots, strip emojis, fix unicode escapes","description":"## What\n\nMajor redesign of the help panel content after emoji overload feedback. Replaced all emoji bullets with small Catppuccin-colored dot indicators, removed broken unicode escape sequences, and tightened copy.\n\n## Commit\n- fc08d3f — Fix tutorial overlay click-through, redesign help with Catppuccin colored dots\n\n## Files modified\n\n### `components/HelpPanel.tsx`\n- **New `Bullet` component:** Small 6px (`w-1.5 h-1.5`) rounded Catppuccin-colored dot with `mt-[6px]` vertical alignment\n ```tsx\n function Bullet({ color, children }: { color: string; children: React.ReactNode }) {\n return (\n <li className=\"flex gap-2.5 items-start\">\n <span className=\"w-1.5 h-1.5 rounded-full mt-[6px] shrink-0\" style={{ backgroundColor: color }} />\n <span>{children}</span>\n </li>\n );\n }\n ```\n- Replaced `CAT_RED` etc. individual constants with single `CAT` object:\n ```tsx\n const CAT = { red: \"#d20f39\", teal: \"#179299\", peach: \"#fe640b\", blue: \"#1e66f5\", green: \"#40a02b\", mauve: \"#8839ef\", sapphire: \"#209fb5\", pink: \"#ea76cb\", surface: \"#dce0e8\" };\n ```\n- `SectionTitle` tightened: `text-[11px] font-bold uppercase tracking-widest`\n- Removed all emoji from bullet items — each section uses different Catppuccin color dots\n- Removed emojis from header text: back to plain \"Tutorial\" / \"Welcome to Heartbeads\"\n- Start Tutorial button changed to Catppuccin Mauve with text \"Take the guided tour\"\n- **Fixed unicode escape bug:** `\\u2190` and `\\u2192` in JSX text nodes render as literal backslash sequences, not arrows. Changed to plain \"Back\" / \"Next\" / \"Done\" / \"Skip\"\n- Tutorial step titles: removed emoji prefixes, clean text only\n- Layout names in Layouts section: each colored with matching Catppuccin accent (Force=teal, DAG=green, Radial=peach, Cluster=blue, Spread=mauve)\n- Color mode names in Color modes section: each colored (Status=green, Priority=red, Owner=blue, Assignee=teal, Prefix=mauve)\n\n### `components/TutorialOverlay.tsx`\n- Changed overlay approach: SVG is `pointer-events: none`, only the dark-fill `<rect>` has `pointer-events: auto`\n- Removed sidebar rect tracking (sidebarRect state, querySelector for aside.translate-x-0)\n- Simplified mask: only one cutout rect (target element), no sidebar cutout\n- Pulsing ring moved from `absolute` to `fixed` positioning with `z-[56]`\n- Tutorial step descriptions tightened for conciseness\n\n## Key discovery\nSetting `pointer-events: auto` on an SVG `<rect>` inside a masked SVG still captures clicks on the full bounding box of the rect element — the mask only affects visual rendering. This approach still did not fix the sidebar click problem (fixed in next commit c47a631).\n\n## Acceptance criteria (all met)\n- [x] No emoji bullets in help content\n- [x] Small colored dots per section\n- [x] No broken unicode escape text\n- [x] Clean tutorial step titles\n- [x] `pnpm build` passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:50:44.278328+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:51:24.492945+13:00","closed_at":"2026-02-12T15:51:24.492945+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.8","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:50:44.2796+13:00","created_by":"daviddao"}]},{"id":"beads-map-s0c.9","title":"Fix tutorial sidebar click-through: raise HelpPanel z-index above overlay during tutorial","description":"## What\n\nThe definitive fix for the tutorial sidebar click-through bug. Previous attempts (sidebar rect cutout, SVG pointer-events delegation) all failed because SVG masks are visual-only — they do NOT affect hit testing. The solution is simple: raise the sidebar z-index above the overlay.\n\n## Root cause\n\n- `TutorialOverlay` renders at `z-[55]` (a `<div>` or SVG `<rect>` covering the full viewport)\n- `HelpPanel` sidebar is at `z-30`\n- Overlay is on top and captures all clicks, even in the \"cut out\" area\n- SVG `<mask>` makes pixels visually transparent but the element's bounding box still receives pointer events\n\n## Solution\n\nWhen the tutorial is active (`tutorialStep !== null`), bump the HelpPanel sidebar to `z-[60]` so it naturally sits above the `z-[55]` overlay. The sidebar receives clicks normally because it is physically above the overlay in the stacking context.\n\n## Commit\n- c47a631 — Fix tutorial sidebar click-through: raise sidebar z-index above overlay\n\n## Files modified\n\n### `components/HelpPanel.tsx`\n- Desktop sidebar: conditional z-index\n ```tsx\n className={`... ${isTutorialActive ? \"z-[60]\" : \"z-30\"}`}\n ```\n (Removed hardcoded `z-30` from the className, replaced with conditional)\n- Mobile drawer: same pattern\n ```tsx\n className={`... ${isTutorialActive ? \"z-[60]\" : \"z-20\"}`}\n ```\n- Start Tutorial button changed from Catppuccin Mauve to `bg-emerald-500 hover:bg-emerald-600` (app accent color)\n\n### `components/TutorialOverlay.tsx`\n- Simplified to a clean two-element approach:\n 1. A `<div>` with `fixed inset-0 z-[55]` dark background + `onClick={handleClick}` — handles advancing/ending\n 2. An SVG with `pointer-events: none` that masks out the spotlight cutout (visual only)\n- Removed all sidebar detection logic (`sidebarRect` state, `querySelector(\"aside.translate-x-0\")`, sidebar mask rect)\n- Pulsing ring at `z-[56]` with `pointer-events: none`\n\n## Z-index stack during tutorial\n- `z-30` — other sidebars (NodeDetail, Comments, Activity)\n- `z-[55]` — dark overlay backdrop (clickable, advances tutorial)\n- `z-[56]` — spotlight pulsing ring (visual only)\n- `z-[60]` — HelpPanel sidebar (fully interactive, Back/Skip/Next/Done)\n\n## Acceptance criteria (all met)\n- [x] Back button clickable during tutorial\n- [x] Skip button clickable on first step\n- [x] Next/Done buttons clickable\n- [x] Clicking dark overlay area still advances tutorial\n- [x] `pnpm build` passes","status":"closed","priority":0,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-12T15:51:01.3789+13:00","created_by":"daviddao","updated_at":"2026-02-12T15:51:24.523752+13:00","closed_at":"2026-02-12T15:51:24.523752+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-s0c.9","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:51:01.379692+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg","title":"Enhanced comments: all-comments panel, likes, and threaded replies","description":"## Enhanced comments: all-comments panel, likes, and threaded replies\n\n### Summary\nThree major enhancements to the beads-map comment system, modeled after Hyperscan's ReviewSection:\n\n1. **All Comments panel** — A pill button in the top-right header area that opens a sidebar showing ALL comments across all nodes, sorted newest-first. Each comment links to its target node. This gives users a global activity feed.\n\n2. **Likes on comments** — Heart toggle on each comment (rose-500 when liked, zinc-300 when not), using the `org.impactindexer.review.like` lexicon. The like subject URI is the comment's AT-URI. Likes fetched from Hypergoat indexer. Same create/delete pattern as Hyperscan.\n\n3. **Threaded replies** — Reply button on each comment that shows an inline reply form. Uses the `replyTo` field on `org.impactindexer.review.comment` lexicon. Replies are indented with a left border (Hyperscan pattern: `ml-4 pl-3 border-l border-zinc-100`). Thread tree built client-side from flat comment list.\n\n### Architecture\n\n**Data fetching changes (`hooks/useBeadsComments.ts`):**\n- Fetch BOTH `org.impactindexer.review.comment` AND `org.impactindexer.review.like` from Hypergoat\n- Extend `BeadsComment` type: add `replyTo?: string`, `likes: BeadsLike[]`, `replies: BeadsComment[]`\n- Add `BeadsLike` type: `{ did, handle, displayName?, avatar?, createdAt, uri, rkey }`\n- Build thread tree: flat comments with `replyTo` assembled into nested `replies` arrays\n- Attach likes to their target comments (like subject.uri === comment AT-URI)\n- Export `allComments: BeadsComment[]` (flat list, newest first, for the All Comments panel)\n\n**New component (`components/AllCommentsPanel.tsx`):**\n- Slide-in sidebar from right (same pattern as NodeDetail sidebar)\n- Header: 'All Comments' title + close button\n- List of all comments sorted newest-first\n- Each comment shows: avatar, handle, time, text, target node ID (clickable to navigate)\n- Like button + reply count shown per comment\n\n**NodeDetail comment section enhancements (`components/NodeDetail.tsx`):**\n- Add HeartIcon component (Hyperscan pattern: filled/outline toggle)\n- Add like button on each CommentItem (heart + count, rose-500 when liked)\n- Add 'reply' text button on each CommentItem\n- Add InlineReplyForm (appears below comment being replied to)\n- Render threaded replies with recursive CommentItem (depth-based indentation)\n\n**page.tsx wiring:**\n- Add `allCommentsPanelOpen` state\n- Add pill button in header area\n- Add like/reply handlers\n- Render AllCommentsPanel component\n- Wire node navigation from AllCommentsPanel\n\n### Subject URI conventions\n- Comment on a beads issue: `{ uri: 'beads:<issue-id>', type: 'record' }`\n- Like on a comment: `{ uri: 'at://<did>/org.impactindexer.review.comment/<rkey>', type: 'record' }`\n- Reply to a comment: comment record with `replyTo: 'at://<did>/org.impactindexer.review.comment/<rkey>'`\n\n### Dependency chain\n- .1 (extend hook) is independent — foundational data layer\n- .2 (likes on comments in NodeDetail) depends on .1\n- .3 (threaded replies in NodeDetail) depends on .1\n- .4 (AllCommentsPanel component) depends on .1\n- .5 (page.tsx wiring + pill button) depends on .2, .3, .4\n- .6 (build verification) depends on .5\n\n### Reference files\n- Hyperscan ReviewSection: `/Users/david/Projects/gainforest/hyperscan/src/components/ReviewSection.tsx`\n- Like lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/like.json`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Current hook: `hooks/useBeadsComments.ts`\n- Current NodeDetail: `components/NodeDetail.tsx`\n- Current page.tsx: `app/page.tsx`","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T01:24:13.04637+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:37:27.139182+13:00","closed_at":"2026-02-11T01:37:27.139182+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg","depends_on_id":"beads-map-dyi","type":"blocks","created_at":"2026-02-11T01:26:33.09446+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.1","title":"Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees","description":"## Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees\n\n### Goal\nExtend `hooks/useBeadsComments.ts` to fetch likes from Hypergoat, parse the `replyTo` field on comments, and build threaded comment trees.\n\n### File to modify\n`hooks/useBeadsComments.ts` (currently 289 lines)\n\n### Step 1: Add new types\n\n```typescript\nexport interface BeadsLike {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n createdAt: string;\n uri: string; // AT-URI of the like record\n rkey: string;\n}\n\n// Extend BeadsComment:\nexport interface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string;\n rkey: string;\n replyTo?: string; // NEW — AT-URI of parent comment\n likes: BeadsLike[]; // NEW — likes on this comment\n replies: BeadsComment[]; // NEW — nested child comments\n}\n```\n\n### Step 2: Fetch likes from Hypergoat\n\nUse the same `FETCH_COMMENTS_QUERY` GraphQL query but with `collection: 'org.impactindexer.review.like'`. Create a `fetchLikeRecords()` function (same pagination pattern as `fetchCommentRecords()`).\n\n### Step 3: Parse replyTo from comment records\n\nIn the comment processing loop (currently line 217-238), extract `replyTo` from the record value:\n```typescript\nconst replyTo = (value.replyTo as string) || undefined;\n```\nAdd it to the BeadsComment object.\n\n### Step 4: Attach likes to comments\n\nAfter fetching both comments and likes:\n1. Filter likes to those whose `subject.uri` is an AT-URI of a comment (starts with `at://`)\n2. Build a `Map<commentUri, BeadsLike[]>` \n3. Attach likes to their target comments\n\n### Step 5: Build thread trees\n\nAfter grouping comments by node:\n1. For each node's comments, put all in a `Map<uri, BeadsComment>`\n2. For each comment with `replyTo`, push into `parent.replies`\n3. Root comments = those without `replyTo` (or whose parent is missing)\n4. Sort: root comments newest-first, replies oldest-first (chronological conversation)\n\n### Step 6: Export allComments\n\nAdd to the return value:\n```typescript\nallComments: BeadsComment[] // flat list of all root+reply comments, newest-first, for All Comments panel\n```\n\n### Step 7: Update UseBeadsCommentsResult interface\n\n```typescript\nexport interface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // now threaded trees\n commentedNodeIds: Map<string, number>;\n allComments: BeadsComment[]; // NEW — flat list, newest-first\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n```\n\n### Testing\n- `pnpm build` must pass\n- Verify that comments with `replyTo` fields are correctly nested\n- Verify that likes are attached to the correct comments\n- `allComments` should contain all comments (including replies) sorted newest-first","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:24:33.393775+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.516403+13:00","closed_at":"2026-02-11T01:33:11.516403+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.1","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:33.395429+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.2","title":"Add heart-toggle likes on comments in NodeDetail","description":"## Add heart-toggle likes on comments in NodeDetail\n\n### Goal\nAdd a heart icon like button on each comment in `components/NodeDetail.tsx`, following the Hyperscan pattern exactly.\n\n### File to modify\n`components/NodeDetail.tsx` (currently 511 lines)\n\n### Dependencies\n- beads-map-vdg.1 (likes data available on BeadsComment objects)\n\n### Step 1: Add HeartIcon component\n\nPort from Hyperscan — two variants (filled/outline):\n```typescript\nfunction HeartIcon({ className = 'w-3 h-3', filled = false }: { className?: string; filled?: boolean }) {\n if (filled) {\n return (\n <svg className={className} viewBox='0 0 24 24' fill='currentColor'>\n <path d='M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z' />\n </svg>\n );\n }\n return (\n <svg className={className} fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z' />\n </svg>\n );\n}\n```\n\n### Step 2: Add like button to CommentItem actions row\n\nIn the `CommentItem` component, add a like button in the actions area alongside the existing delete button.\n\nThe actions row for each comment should be: heart-like button | reply button | delete button (own only)\n\n```tsx\n// In CommentItem actions area\n<div className='flex items-center gap-2 mt-1 text-[10px]'>\n <button\n onClick={() => onLike?.(comment)}\n disabled={!isAuthenticated || isLiking}\n className={`flex items-center gap-0.5 transition-colors ${\n hasLiked ? 'text-rose-500' : 'text-zinc-300 hover:text-rose-500'\n } disabled:opacity-50`}\n >\n <HeartIcon className='w-3 h-3' filled={hasLiked} />\n {comment.likes.length > 0 && <span>{comment.likes.length}</span>}\n </button>\n {/* ... reply button (task .3) ... */}\n {/* ... delete button (existing) ... */}\n</div>\n```\n\n### Step 3: Add like handler props\n\nAdd to `NodeDetailProps`:\n```typescript\nonLikeComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nAdd to `CommentItem` props:\n```typescript\nonLike?: (comment: BeadsComment) => Promise<void>;\nisAuthenticated?: boolean;\n```\n\n### Step 4: Determine `hasLiked` state\n\nIn CommentItem, check if the current user has liked:\n```typescript\nconst hasLiked = currentDid ? comment.likes.some(l => l.did === currentDid) : false;\n```\n\n### Like create/delete will be wired in page.tsx (task .5)\nThe actual API calls (POST/DELETE to /api/records with org.impactindexer.review.like) will be handled by callbacks passed from page.tsx.\n\n### Testing\n- `pnpm build` must pass\n- Heart icon renders in outline state by default\n- Heart icon renders filled + rose-500 when liked by current user\n- Like count shows next to heart when > 0","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:24:55.516758+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.647637+13:00","closed_at":"2026-02-11T01:33:11.647637+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:55.518315+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.248408+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.3","title":"Add threaded replies with inline reply form in NodeDetail","description":"## Add threaded replies with inline reply form in NodeDetail\n\n### Goal\nAdd reply functionality to comments in `components/NodeDetail.tsx`: a 'reply' text button on each comment, an inline reply form, and recursive threaded rendering with indentation.\n\n### File to modify\n`components/NodeDetail.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (BeadsComment now has `replies: BeadsComment[]` and `replyTo?: string`)\n\n### Step 1: Add InlineReplyForm component\n\nPort from Hyperscan ReviewSection:\n```tsx\nfunction InlineReplyForm({\n replyingTo,\n replyText,\n onTextChange,\n onSubmit,\n onCancel,\n isSubmitting,\n}: {\n replyingTo: BeadsComment;\n replyText: string;\n onTextChange: (text: string) => void;\n onSubmit: () => void;\n onCancel: () => void;\n isSubmitting: boolean;\n}) {\n return (\n <div className='mt-2 ml-4 pl-3 border-l border-emerald-200 space-y-1.5'>\n <div className='flex items-center gap-1.5 text-[10px] text-zinc-400'>\n <span>Replying to</span>\n <span className='font-medium text-zinc-600'>\n {replyingTo.displayName || replyingTo.handle}\n </span>\n </div>\n <div className='flex gap-2'>\n <input\n type='text'\n value={replyText}\n onChange={(e) => onTextChange(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && onSubmit()}\n placeholder='Write a reply...'\n disabled={isSubmitting}\n autoFocus\n className='flex-1 px-2 py-1 text-xs bg-white border border-zinc-200 rounded placeholder-zinc-400 focus:outline-none focus:border-emerald-400 disabled:opacity-50'\n />\n <button onClick={onSubmit} disabled={!replyText.trim() || isSubmitting}\n className='px-2 py-1 text-[10px] font-medium text-emerald-600 hover:text-emerald-700 disabled:opacity-50'>\n {isSubmitting ? '...' : 'Reply'}\n </button>\n <button onClick={onCancel} disabled={isSubmitting}\n className='px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-600 disabled:opacity-50'>\n Cancel\n </button>\n </div>\n </div>\n );\n}\n```\n\n### Step 2: Add reply button to CommentItem actions row\n\nAdd a 'reply' text button (Hyperscan pattern):\n```tsx\n<button\n onClick={() => onStartReply?.(comment)}\n disabled={!isAuthenticated}\n className={`transition-colors disabled:opacity-50 ${\n isReplyingToThis ? 'text-emerald-500' : 'text-zinc-300 hover:text-zinc-500'\n }`}\n>\n reply\n</button>\n```\n\n### Step 3: Make CommentItem recursive for threading\n\nAdd `depth` prop (default 0). When `depth > 0`, add indentation:\n```tsx\n<div className={`${depth > 0 ? 'ml-4 pl-3 border-l border-zinc-100' : ''}`}>\n```\n\nAfter the comment content, render replies recursively:\n```tsx\n{comment.replies.length > 0 && (\n <div className='space-y-0'>\n {comment.replies.map((reply) => (\n <CommentItem key={reply.uri} comment={reply} depth={depth + 1} ... />\n ))}\n </div>\n)}\n```\n\n### Step 4: Add reply state management props\n\nAdd to `NodeDetailProps`:\n```typescript\nonReplyComment?: (parentComment: BeadsComment, text: string) => Promise<void>;\n```\n\nAdd reply state to the Comments section (managed locally in NodeDetail):\n```typescript\nconst [replyingToUri, setReplyingToUri] = useState<string | null>(null);\nconst [replyText, setReplyText] = useState('');\nconst [isSubmittingReply, setIsSubmittingReply] = useState(false);\n```\n\n### Step 5: Wire InlineReplyForm rendering\n\nIn CommentItem, show InlineReplyForm when `replyingToUri === comment.uri`:\n```tsx\n{isReplyingToThis && (\n <InlineReplyForm\n replyingTo={comment}\n replyText={replyText}\n onTextChange={onReplyTextChange}\n onSubmit={onSubmitReply}\n onCancel={onCancelReply}\n isSubmitting={isSubmittingReply}\n />\n)}\n```\n\n### Testing\n- `pnpm build` must pass \n- Reply button appears on each comment\n- Clicking reply shows inline form below that comment\n- Replies render indented with left border\n- Nested replies (reply to a reply) indent further","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:25:16.081672+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.780553+13:00","closed_at":"2026-02-11T01:33:11.780553+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:16.083107+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.371142+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.4","title":"Create AllCommentsPanel sidebar component","description":"## Create AllCommentsPanel sidebar component\n\n### Goal\nCreate `components/AllCommentsPanel.tsx` — a slide-in sidebar that shows ALL comments across all beads nodes, sorted newest-first. This provides a global activity feed view.\n\n### File to create\n`components/AllCommentsPanel.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (allComments flat list available from hook)\n\n### Props interface\n```typescript\ninterface AllCommentsPanelProps {\n isOpen: boolean;\n onClose: () => void;\n allComments: BeadsComment[]; // flat list, newest-first (from useBeadsComments)\n onNodeNavigate: (nodeId: string) => void; // click a comment's node ID to navigate\n isAuthenticated?: boolean;\n currentDid?: string;\n onLikeComment?: (comment: BeadsComment) => Promise<void>;\n onDeleteComment?: (comment: BeadsComment) => Promise<void>;\n}\n```\n\n### Design\nSame slide-in pattern as the NodeDetail sidebar:\n```tsx\n<aside className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${\n isOpen ? 'translate-x-0' : 'translate-x-full'\n}`}>\n```\n\n### Layout\n1. **Header**: 'All Comments' title + close X button + comment count badge\n2. **Scrollable list**: Each comment shows:\n - Target node pill: clickable badge with node ID (e.g., 'beads-map-cvh') in emerald — clicking calls `onNodeNavigate`\n - Avatar (24px circle) + handle + relative time\n - Comment text\n - Heart like button (rose-500 when liked) + like count\n - If it's a reply, show 'Re: {parentHandle}' label in zinc-400\n - Delete X for own comments\n3. **Empty state**: 'No comments yet' when list is empty\n4. **Footer**: count summary\n\n### Comment card design\n```tsx\n<div className='py-3 border-b border-zinc-50'>\n {/* Node target pill */}\n <button onClick={() => onNodeNavigate(comment.nodeId)}\n className='inline-flex items-center px-1.5 py-0.5 mb-1.5 rounded text-[10px] font-mono bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors'>\n {comment.nodeId}\n </button>\n {/* Rest of comment: avatar + name + time + text + actions */}\n</div>\n```\n\n### Important: nodeId on comments\nThe allComments list from the hook needs to include the target nodeId on each comment. Either:\n- Add `nodeId: string` to BeadsComment interface (set during processing in the hook)\n- Or derive it from `subject.uri.replace(/^beads:/, '')` at render time\n\nPreferred: add `nodeId` to BeadsComment in the hook (task .1) since it's needed here.\n\n### Testing\n- `pnpm build` must pass\n- Panel slides in/out with animation\n- Comments sorted newest-first\n- Clicking node ID navigates to that node\n- Like/delete actions work","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:25:35.922861+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:11.912418+13:00","closed_at":"2026-02-11T01:33:11.912418+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:35.924466+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.487447+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.5","title":"Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel","description":"## Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel\n\n### Goal\nConnect all new components in `app/page.tsx`: add the 'Comments' pill button in the header, wire like/reply API handlers, render AllCommentsPanel.\n\n### File to modify\n`app/page.tsx` (currently 926 lines)\n\n### Dependencies\n- beads-map-vdg.1 (extended hook with allComments, likes, thread trees)\n- beads-map-vdg.2 (NodeDetail with like UI)\n- beads-map-vdg.3 (NodeDetail with reply UI) \n- beads-map-vdg.4 (AllCommentsPanel component)\n\n### Step 1: Update imports\n\n```typescript\nimport AllCommentsPanel from '@/components/AllCommentsPanel';\n```\n\n### Step 2: Update useBeadsComments destructuring\n\n```typescript\nconst { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } = useBeadsComments();\n```\n\n### Step 3: Add state\n\n```typescript\nconst [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);\n```\n\n### Step 4: Add like handler\n\n```typescript\nconst handleLikeComment = useCallback(async (comment: BeadsComment) => {\n // Check if already liked by current user\n const existingLike = comment.likes.find(l => l.did === session?.did);\n \n if (existingLike) {\n // Unlike: DELETE the like record\n const response = await fetch(\n \\`/api/records?collection=\\${encodeURIComponent('org.impactindexer.review.like')}&rkey=\\${encodeURIComponent(existingLike.rkey)}\\`,\n { method: 'DELETE' }\n );\n if (!response.ok) throw new Error('Failed to unlike');\n } else {\n // Like: POST a new like record\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.like',\n record: {\n subject: { uri: comment.uri, type: 'record' },\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to like');\n }\n \n await refetchComments();\n}, [session?.did, refetchComments]);\n```\n\n### Step 5: Add reply handler\n\n```typescript\nconst handleReplyComment = useCallback(async (parentComment: BeadsComment, text: string) => {\n // Extract the nodeId from the parent comment's subject\n // The parent comment targets beads:<nodeId>, replies still target the same node\n // but include replyTo pointing to the parent comment's AT-URI\n const nodeId = /* derive from parentComment — needs nodeId field from task .1 */;\n \n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n subject: { uri: \\`beads:\\${nodeId}\\`, type: 'record' },\n text,\n replyTo: parentComment.uri,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to post reply');\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 6: Add pill button in header\n\nIn the stats/right area of the header (around line 716), add a comments pill:\n```tsx\n<button\n onClick={() => setAllCommentsPanelOpen(prev => !prev)}\n className={\\`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border transition-colors \\${\n allCommentsPanelOpen\n ? 'bg-emerald-50 text-emerald-600 border-emerald-200'\n : 'bg-white text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:border-zinc-300'\n }\\`}\n>\n <svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z' />\n </svg>\n Comments\n {allComments.length > 0 && (\n <span className='px-1.5 py-0.5 bg-red-500 text-white rounded-full text-[10px] font-medium min-w-[18px] text-center'>\n {allComments.length}\n </span>\n )}\n</button>\n```\n\n### Step 7: Render AllCommentsPanel\n\nAfter the NodeDetail sidebar (around line 866), add:\n```tsx\n<AllCommentsPanel\n isOpen={allCommentsPanelOpen}\n onClose={() => setAllCommentsPanelOpen(false)}\n allComments={allComments}\n onNodeNavigate={(nodeId) => {\n handleNodeNavigate(nodeId);\n setAllCommentsPanelOpen(false);\n }}\n isAuthenticated={isAuthenticated}\n currentDid={session?.did}\n onLikeComment={handleLikeComment}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n### Step 8: Pass new props to NodeDetail (both desktop + mobile instances)\n\nAdd to both NodeDetail instances:\n```typescript\nonLikeComment={handleLikeComment}\nonReplyComment={handleReplyComment}\n```\n\n### Step 9: Close AllCommentsPanel when NodeDetail opens (and vice versa)\n\nWhen selecting a node, close the all-comments panel:\n```typescript\n// In handleNodeClick:\nsetAllCommentsPanelOpen(false);\n```\n\n### Testing\n- `pnpm build` must pass\n- Pill button visible in header\n- Clicking pill opens AllCommentsPanel\n- Like toggle works (creates/deletes like records)\n- Reply creates comment with replyTo field\n- Panel and NodeDetail don't overlap","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:26:04.167505+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:12.043078+13:00","closed_at":"2026-02-11T01:33:12.043078+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:04.1688+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.2","type":"blocks","created_at":"2026-02-11T01:26:28.611742+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.3","type":"blocks","created_at":"2026-02-11T01:26:28.725946+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.4","type":"blocks","created_at":"2026-02-11T01:26:28.84169+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.6","title":"Build verification and final cleanup","description":"## Build verification and final cleanup\n\n### Goal\nVerify the full build passes, clean up any TypeScript errors, and ensure all features work together.\n\n### Steps\n1. Run `pnpm build` — must pass with zero errors\n2. Verify no unused imports or dead code from the refactoring\n3. Test the full flow mentally: \n - Comments pill shows in header with count badge\n - Clicking opens AllCommentsPanel with all comments newest-first\n - Each comment shows heart like button\n - Clicking heart toggles like (creates/deletes org.impactindexer.review.like)\n - Reply button shows inline form\n - Submitting reply creates comment with replyTo field\n - Replies render threaded with indentation\n - Node ID in AllCommentsPanel navigates to that node\n4. Update AGENTS.md with new component/hook documentation\n\n### Testing\n- `pnpm build` must pass with zero errors","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:26:12.40132+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:33:12.174234+13:00","closed_at":"2026-02-11T01:33:12.174234+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:12.402978+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg.5","type":"blocks","created_at":"2026-02-11T01:26:28.956024+13:00","created_by":"daviddao"}]},{"id":"beads-map-vdg.7","title":"Restyle Comments pill to match layout toggle pill styling, remove red counter badge","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T01:36:49.288253+13:00","created_by":"daviddao","updated_at":"2026-02-11T01:37:27.025479+13:00","closed_at":"2026-02-11T01:37:27.025479+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-vdg.7","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:36:49.289175+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w","title":"Right-click context menu: show description or add comment","description":"## Right-Click Context Menu\n\n### Summary\nReplace the current right-click behavior (which directly opens CommentTooltip) with a two-step interaction:\n1. Right-click a graph node → small context menu appears at cursor with two options\n2. User picks \"Show description\" → opens description modal, OR \"Add comment\" → opens the existing CommentTooltip\n\n### Current behavior\n- Right-click a node in BeadsGraph → `handleNodeRightClick` in `app/page.tsx:425-430` sets `contextMenu: { node, x, y }`\n- `contextMenu` state directly renders `CommentTooltip` component at `app/page.tsx:997-1010`\n- CommentTooltip shows node info, existing comments preview, and compose area\n\n### New behavior\n- Right-click a node → `contextMenu` state renders a NEW `ContextMenu` component (small 2-item menu)\n- \"Show description\" → opens a description modal (same portal modal currently in NodeDetail.tsx:332-370)\n- \"Add comment\" → opens the existing CommentTooltip at the same cursor position\n\n### Architecture\nThree changes needed:\n1. **New `ContextMenu` component** — small floating menu at cursor position with 2 items\n2. **Extract `DescriptionModal` component** — lift the portal modal from NodeDetail into a reusable component\n3. **Wire in `page.tsx`** — new state for `commentTooltipState` and `descriptionModalNode`, replace direct CommentTooltip render with ContextMenu → action flow\n\n### State model (page.tsx)\n```\ncontextMenu: { node, x, y } | null // phase 1: shows ContextMenu\ncommentTooltipState: { node, x, y } | null // phase 2a: shows CommentTooltip\ndescriptionModalNode: GraphNode | null // phase 2b: shows DescriptionModal\n```\n\nRight-click → sets contextMenu → renders ContextMenu\nContextMenu \"Show description\" → sets descriptionModalNode, clears contextMenu\nContextMenu \"Add comment\" → sets commentTooltipState, clears contextMenu\n\n### Subtasks\n- .1 Create ContextMenu component\n- .2 Extract DescriptionModal component from NodeDetail\n- .3 Wire context menu + actions in page.tsx\n- .4 Build verify and push\n\n### Acceptance criteria\n- Right-clicking a graph node shows a small 2-item context menu\n- \"Show description\" opens a full-screen modal with the issue description (markdown rendered)\n- \"Add comment\" opens the existing CommentTooltip (unchanged behavior)\n- Escape / click-outside dismisses the context menu\n- \"View in window\" button in NodeDetail sidebar still works\n- pnpm build passes","status":"closed","priority":1,"issue_type":"epic","owner":"david@gainforest.net","created_at":"2026-02-11T09:18:37.401674+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:47:46.54973+13:00","closed_at":"2026-02-11T10:47:46.54973+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.328556+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.1","title":"Create ContextMenu component","description":"## Create ContextMenu component\n\n### What\nA small floating context menu that appears on right-click of a graph node. Shows two options: \"Show description\" and \"Add comment\". Styled to match the existing app aesthetic.\n\n### New file: `components/ContextMenu.tsx`\n\n#### Props interface\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number; // clientX from right-click event\n y: number; // clientY from right-click event\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n```\n\n#### Positioning\n- `position: fixed`, placed at `(x + 4, y + 4)` — slightly offset from cursor\n- Viewport clamping: if menu would overflow right edge, shift left; if overflow bottom, shift up\n- The menu is small (~160px wide, ~80px tall) so clamping is simple\n- Use `useEffect` + `getBoundingClientRect()` on mount to measure and clamp (same pattern as CommentTooltip.tsx:35-55 but simpler)\n\n#### Visual design\n```\n┌─────────────────┐\n│ 📄 Show description │\n│ 💬 Add comment │\n└─────────────────┘\n```\n\n- Container: `bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden`\n- Shadow: `box-shadow: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.06)`\n- Each item: `px-3 py-2 text-xs text-zinc-700 hover:bg-zinc-50 cursor-pointer flex items-center gap-2 transition-colors`\n- Icons: small SVGs (w-3.5 h-3.5 text-zinc-400)\n - \"Show description\": document/page icon\n - \"Add comment\": chat bubble icon (same as CommentTooltip.tsx:172-183)\n- Divider between items: `border-b border-zinc-100` on first item\n- Animate in: opacity 0→1, translateY(2px)→0, transition 0.15s\n\n#### Dismiss behavior\n- Escape key → calls `onClose()`\n- Click outside → calls `onClose()` (with 50ms delay, same as CommentTooltip.tsx:76-94)\n- Prevent browser context menu on the component itself: `onContextMenu={(e) => e.preventDefault()}`\n\n#### Item click behavior\n- \"Show description\" → calls `onShowDescription()`\n- \"Add comment\" → calls `onAddComment()`\n- Both should also implicitly close the menu (parent handles this by clearing contextMenu state)\n\n#### Full component structure\n```tsx\n\"use client\";\nimport { useState, useRef, useEffect } from \"react\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n\nexport function ContextMenu({ node, x, y, onShowDescription, onAddComment, onClose }: ContextMenuProps) {\n const menuRef = useRef<HTMLDivElement>(null);\n const [pos, setPos] = useState({ x: 0, y: 0 });\n const [visible, setVisible] = useState(false);\n\n // Position + clamp to viewport\n useEffect(() => {\n if (!menuRef.current) return;\n const rect = menuRef.current.getBoundingClientRect();\n const vw = window.innerWidth;\n const vh = window.innerHeight;\n let nx = x + 4;\n let ny = y + 4;\n if (nx + rect.width > vw - 16) nx = vw - rect.width - 16;\n if (nx < 16) nx = 16;\n if (ny + rect.height > vh - 16) ny = vh - rect.height - 16;\n if (ny < 16) ny = 16;\n setPos({ x: nx, y: ny });\n setVisible(true);\n }, [x, y]);\n\n // Escape key\n useEffect(() => {\n const handler = (e: KeyboardEvent) => { if (e.key === \"Escape\") onClose(); };\n window.addEventListener(\"keydown\", handler);\n return () => window.removeEventListener(\"keydown\", handler);\n }, [onClose]);\n\n // Click outside (with delay)\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();\n };\n const timer = setTimeout(() => window.addEventListener(\"mousedown\", handler), 50);\n return () => { clearTimeout(timer); window.removeEventListener(\"mousedown\", handler); };\n }, [onClose]);\n\n return (\n <div ref={menuRef} style={{ position: \"fixed\", left: pos.x, top: pos.y, zIndex: 100,\n opacity: visible ? 1 : 0, transform: visible ? \"translateY(0)\" : \"translateY(2px)\",\n transition: \"opacity 0.15s ease, transform 0.15s ease\" }}\n onContextMenu={(e) => e.preventDefault()}\n >\n <div className=\"bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden\" style={{ minWidth: 180 }}>\n <button onClick={onShowDescription}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-b border-zinc-100\">\n {/* Document icon SVG */}\n Show description\n </button>\n <button onClick={onAddComment}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors\">\n {/* Chat bubble icon SVG */}\n Add comment\n </button>\n </div>\n </div>\n );\n}\n```\n\n### SVG Icons\n**Document icon** (Show description):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z\" />\n</svg>\n```\n\n**Chat bubble icon** (Add comment):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z\" />\n</svg>\n```\n\n### Files to create\n- `components/ContextMenu.tsx`\n\n### Acceptance criteria\n- ContextMenu renders at cursor position with 2 items\n- Hover states on items (bg-zinc-50)\n- Escape key dismisses\n- Click outside dismisses\n- Clicking \"Show description\" calls onShowDescription\n- Clicking \"Add comment\" calls onAddComment\n- Viewport clamping works (menu stays on screen)\n- Animate-in transition (opacity + translateY)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:19:10.934581+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.14949+13:00","closed_at":"2026-02-11T09:23:40.14949+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.1","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:10.936853+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.10","title":"Avatar visual tuning: full opacity, persistent in clusters, minimap display","description":"## Avatar visual tuning\n\n### Changes\n\n#### 1. Full opacity (components/BeadsGraph.tsx paintNode)\n- Changed `ctx.globalAlpha = Math.min(opacity, 0.6)` → `ctx.globalAlpha = 1`\n- Avatars are now always at full opacity, not subtle/translucent\n\n#### 2. Never fade in clusters (components/BeadsGraph.tsx paintNode)\n- Removed `globalScale > 0.4` threshold from the avatar drawing condition\n- Changed `if (claimInfo && globalScale > 0.4)` → `if (claimInfo)`\n- Avatars remain visible even when zoomed out to cluster view\n\n#### 3. Constant screen-space size on zoom (components/BeadsGraph.tsx paintNode)\n- Changed `avatarSize = Math.min(8, Math.max(4, 10 / globalScale))` → `avatarSize = Math.max(4, 10 / globalScale)`\n- Removed the `Math.min(8, ...)` cap so avatar grows in graph-space as you zoom out, maintaining roughly the same pixel size on screen\n\n#### 4. Minimap avatar display (components/BeadsGraph.tsx redrawMinimap)\n- Added avatar drawing loop after the node dots loop in `redrawMinimap`\n- For each claimed node, draws a small circular avatar (radius 5px) at the node position on the minimap\n- Uses the same `getAvatarImage` cache for image loading\n- Fallback: gray circle if image not loaded\n- White border ring for contrast\n\n### Commits\n- `8693bec` Reduce claim avatar opacity to 0.6, constant screen-space size on zoom\n- `0a23755` Avatar: full opacity, never fade in clusters, show on minimap\n\n### Files changed\n- `components/BeadsGraph.tsx` — paintNode avatar section + redrawMinimap avatar loop\n\n### Status: DONE","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:26:08.912736+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:26:45.656242+13:00","closed_at":"2026-02-11T10:26:45.656242+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:08.914469+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w.9","type":"blocks","created_at":"2026-02-11T10:26:08.915791+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.11","title":"Avatar hover tooltip: show profile info on mouseover","description":"## Avatar hover tooltip\n\n### What\nWhen hovering over a claimed node avatar on the graph, a small tooltip appears showing the profile picture and @handle.\n\n### Implementation\n\n#### 1. New prop on BeadsGraph (`components/BeadsGraph.tsx`)\n- Added `onAvatarHover?: (info: { handle: string; avatar?: string; x: number; y: number } | null) => void` to `BeadsGraphProps`\n- Added `onAvatarHoverRef` (stable ref for the callback, avoids stale closures)\n- Added `hoveredAvatarNodeRef` (tracks which avatar is hovered to avoid redundant callbacks)\n- Added `viewNodesRef` (ref synced from `viewNodes` memo, so mousemove respects epics view)\n\n#### 2. Mousemove hit-testing (`components/BeadsGraph.tsx`)\n- `useEffect` registers a `mousemove` listener on the container div\n- Converts screen coords → graph coords via `fg.screen2GraphCoords()`\n- Iterates `viewNodesRef.current` (respects full/epics view mode)\n- For each node with a claim, computes the avatar circle position (`node.x + size * 0.7`, `node.y + size * 0.7`) and radius (`Math.max(4, 10 / globalScale)`)\n- Hit-tests: if mouse is inside the avatar circle, emits `onAvatarHover({ handle, avatar, x, y })`\n- If mouse leaves all avatar circles, emits `onAvatarHover(null)`\n- Uses `[]` dependency (refs for everything) so listener is registered once\n\n#### 3. Epics view fix\n- Initially the hit-test iterated `nodes` (raw prop) which broke in epics view since child nodes are collapsed\n- Fixed to iterate `viewNodesRef.current` which reflects the current view mode\n- `viewNodesRef.current` is synced inline after the `viewNodes` useMemo\n\n#### 4. Tooltip rendering (`app/page.tsx`)\n- Added `avatarTooltip` state: `{ handle, avatar, x, y } | null`\n- Passed `onAvatarHover={setAvatarTooltip}` to `<BeadsGraph>`\n- Renders a `position: fixed` tooltip at `(x+12, y-8)` from cursor with `pointerEvents: none`\n- Tooltip shows: profile pic (20x20 rounded circle) + `@handle` text\n- White bg, zinc border, rounded-lg, shadow-lg — matches app aesthetic\n- Fallback: gray circle with first letter if no avatar URL\n\n### Commits\n- `ea0a905` Add avatar hover tooltip showing profile pic and handle\n- `5817941` Fix avatar tooltip in epics view: hit-test against viewNodes not raw nodes\n\n### Files changed\n- `components/BeadsGraph.tsx` — onAvatarHover prop, refs, mousemove useEffect, viewNodesRef\n- `app/page.tsx` — avatarTooltip state, onAvatarHover prop, tooltip JSX\n\n### Status: DONE","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:26:37.012238+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:26:45.776772+13:00","closed_at":"2026-02-11T10:26:45.776772+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:37.013442+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w.10","type":"blocks","created_at":"2026-02-11T10:26:37.015186+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.12","title":"Unclaim task: right-click to remove claim from a node","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:47:43.376019+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:47:46.432125+13:00","closed_at":"2026-02-11T10:47:46.432125+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.12","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:47:43.377971+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.2","title":"Extract DescriptionModal component from NodeDetail","description":"## Extract DescriptionModal component from NodeDetail\n\n### What\nThe description modal currently lives inside `components/NodeDetail.tsx` (lines 332-370) using local `descriptionExpanded` state. We need the same modal accessible from the right-click context menu (which lives in `page.tsx`, not inside NodeDetail). \n\nExtract the modal into a standalone `DescriptionModal` component, then use it from both NodeDetail and page.tsx.\n\n### Current implementation in NodeDetail.tsx\n\n**State (line 52):**\n```typescript\nconst [descriptionExpanded, setDescriptionExpanded] = useState(false);\n```\n\n**\"View in window\" button (lines 317-322):**\n```tsx\n<button\n onClick={() => setDescriptionExpanded(true)}\n className=\"text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors\"\n>\n View in window\n</button>\n```\n\n**Portal modal (lines 332-370):**\n```tsx\n{descriptionExpanded && node.description && createPortal(\n <div className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={() => setDescriptionExpanded(false)}>\n <div className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}>\n {/* Header: node.id + node.title + X button */}\n {/* Body: ReactMarkdown with node.description */}\n </div>\n </div>,\n document.body\n)}\n```\n\n### New file: `components/DescriptionModal.tsx`\n\n```typescript\n\"use client\";\nimport { createPortal } from \"react-dom\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface DescriptionModalProps {\n node: GraphNode;\n onClose: () => void;\n}\n\nexport function DescriptionModal({ node, onClose }: DescriptionModalProps) {\n if (!node.description) return null;\n\n return createPortal(\n <div\n className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={onClose}\n >\n <div\n className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}\n >\n {/* Modal header */}\n <div className=\"flex items-center justify-between px-5 py-3 border-b border-zinc-100\">\n <div className=\"flex items-center gap-2 min-w-0\">\n <span className=\"text-xs font-mono font-semibold text-emerald-600 shrink-0\">\n {node.id}\n </span>\n <span className=\"text-sm font-semibold text-zinc-900 truncate\">\n {node.title}\n </span>\n </div>\n <button\n onClick={onClose}\n className=\"shrink-0 p-1 text-zinc-400 hover:text-zinc-600 transition-colors\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {/* Modal body */}\n <div className=\"flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed\">\n <ReactMarkdown remarkPlugins={[remarkGfm]}>\n {node.description}\n </ReactMarkdown>\n </div>\n </div>\n </div>,\n document.body\n );\n}\n```\n\n### Refactor NodeDetail.tsx\n\n**Remove from NodeDetail.tsx:**\n- The `descriptionExpanded` state (line 52)\n- The portal modal JSX (lines 332-370)\n\n**Keep in NodeDetail.tsx:**\n- The \"View in window\" button (lines 317-322)\n- But change its onClick to use the new component locally\n\n**Two approaches for NodeDetail:**\n\n**Option A (keep local state):** NodeDetail keeps its own `descriptionExpanded` state and renders `<DescriptionModal>` when true. Simple, no prop drilling needed. The \"View in window\" button works exactly as before.\n\n**Option B (lift state via callback):** Add an `onExpandDescription?: () => void` prop to NodeDetail. \"View in window\" calls this callback, parent (page.tsx) sets `descriptionModalNode`. More centralized but adds a prop.\n\n**Recommended: Option A.** Keep NodeDetail self-contained. It renders `<DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />` instead of the inline portal. The context menu in page.tsx independently renders `<DescriptionModal>` from its own state. Two independent entry points, same component. No coupling needed.\n\n### Changes to NodeDetail.tsx\n\n1. **Add import:**\n```typescript\nimport { DescriptionModal } from \"./DescriptionModal\";\n```\n\n2. **Keep `descriptionExpanded` state** (line 52) — unchanged\n\n3. **Replace lines 332-370** (the inline createPortal block) with:\n```tsx\n{descriptionExpanded && node.description && (\n <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />\n)}\n```\n\n4. **Remove import** of `createPortal` from NodeDetail.tsx IF it is no longer used elsewhere in the file. Check: `createPortal` is only used for the description modal, so remove `import { createPortal } from \"react-dom\"` from NodeDetail.tsx.\n\n5. **Remove import** of `ReactMarkdown` and `remarkGfm` from NodeDetail.tsx IF they are no longer used. Check: ReactMarkdown is still used for the inline description preview (line 325-327), so KEEP these imports.\n\nActually wait — `createPortal` is imported at the top of NodeDetail.tsx. Let me check if it is used anywhere else in the file besides the description modal. Looking at the NodeDetail component, createPortal is ONLY used for the description modal (lines 332-370). So yes, remove the createPortal import.\n\n### Files to create\n- `components/DescriptionModal.tsx`\n\n### Files to edit \n- `components/NodeDetail.tsx`:\n - Add import for DescriptionModal\n - Remove import for createPortal (from \"react-dom\")\n - Replace inline portal JSX (lines 332-370) with `<DescriptionModal>` usage\n - Keep descriptionExpanded state and \"View in window\" button unchanged\n\n### Acceptance criteria\n- New `DescriptionModal` component renders the same modal as before\n- \"View in window\" button in NodeDetail sidebar still works identically\n- DescriptionModal uses createPortal to document.body with z-[100]\n- Backdrop click and X button close the modal\n- Markdown rendering with remarkGfm works\n- No visual regression in the modal appearance\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:19:41.231435+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.327949+13:00","closed_at":"2026-02-11T09:23:40.327949+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.2","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:41.234513+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.3","title":"Wire context menu and actions in page.tsx","description":"## Wire context menu and actions in page.tsx\n\n### What\nConnect the new ContextMenu and DescriptionModal components into the main page. Replace the direct CommentTooltip render with a two-phase flow: ContextMenu → action (description modal OR comment tooltip).\n\n### Current code to change\n\n**State (line 184-188):**\n```typescript\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n**Right-click handler (lines 425-430):**\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n },\n []\n);\n```\n\n**CommentTooltip render (lines 997-1010):**\n```tsx\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n**Background click (lines 420-423):**\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n}, []);\n```\n\n### New state\n\nAdd after existing `contextMenu` state (~line 188):\n```typescript\n// Separate state for CommentTooltip (opened from context menu \"Add comment\")\nconst [commentTooltipState, setCommentTooltipState] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n\n// Description modal (opened from context menu \"Show description\")\nconst [descriptionModalNode, setDescriptionModalNode] = useState<GraphNode | null>(null);\n```\n\n### New imports\n\nAdd at top of file:\n```typescript\nimport { ContextMenu } from \"@/components/ContextMenu\";\nimport { DescriptionModal } from \"@/components/DescriptionModal\";\n```\n\n### Changes to handleNodeRightClick\n\nNo change needed — it still sets `contextMenu` state. But now `contextMenu` renders `ContextMenu` instead of `CommentTooltip`.\n\n### Changes to handleBackgroundClick\n\nAlso clear the new states:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n setCommentTooltipState(null);\n}, []);\n```\n\nNote: do NOT clear `descriptionModalNode` on background click — the modal has its own backdrop click handler.\n\n### Replace CommentTooltip render block (lines 997-1010)\n\nReplace the entire block with:\n\n```tsx\n{/* Right-click context menu */}\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => {\n setDescriptionModalNode(contextMenu.node);\n setContextMenu(null);\n }}\n onAddComment={() => {\n setCommentTooltipState({\n node: contextMenu.node,\n x: contextMenu.x,\n y: contextMenu.y,\n });\n setContextMenu(null);\n }}\n onClose={() => setContextMenu(null)}\n />\n)}\n\n{/* Comment tooltip (opened from context menu \"Add comment\") */}\n{commentTooltipState && (\n <CommentTooltip\n node={commentTooltipState.node}\n x={commentTooltipState.x}\n y={commentTooltipState.y}\n onClose={() => setCommentTooltipState(null)}\n onSubmit={async (text) => {\n await handlePostComment(commentTooltipState.node.id, text);\n setCommentTooltipState(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(commentTooltipState.node.id)}\n />\n)}\n\n{/* Description modal (opened from context menu \"Show description\") */}\n{descriptionModalNode && (\n <DescriptionModal\n node={descriptionModalNode}\n onClose={() => setDescriptionModalNode(null)}\n />\n)}\n```\n\n### Placement in JSX\n\nThe three blocks above should go at the same location where the CommentTooltip currently renders (around line 997, just before `</div>` closing the graph area div at line 1012).\n\nThe `DescriptionModal` uses createPortal to document.body with z-[100], so its placement in the JSX tree does not matter for visual layering. But keeping it near the other overlays is cleaner.\n\n### Edge cases\n\n1. **Right-click while context menu is open**: Existing handler overwrites `contextMenu` state — ContextMenu repositions. Works correctly.\n2. **Right-click while CommentTooltip is open**: The `handleNodeRightClick` sets `contextMenu` which shows ContextMenu. The CommentTooltip from a previous action stays open (its state is separate). The user can dismiss CommentTooltip via its own Escape/click-outside, or just interact with the new context menu. To be cleaner, we should clear `commentTooltipState` when a new right-click happens:\n ```typescript\n const handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n setCommentTooltipState(null); // dismiss any open comment tooltip\n },\n []\n );\n ```\n3. **Right-click while description modal is open**: The modal has z-[100] with backdrop. A right-click on the graph behind the backdrop would not fire (backdrop captures click). If the modal IS open and user clicks backdrop to dismiss it, then right-clicks a node, the normal flow happens. No special handling needed.\n4. **Node with no description**: If user picks \"Show description\" on a node without a description, `DescriptionModal` receives a node with `node.description` being undefined/empty. The component should handle this gracefully — show a \"No description\" message, or the ContextMenu could disable/hide the option. **Recommended: hide \"Show description\" if `!node.description`** — add a `hasDescription` check in ContextMenu.\n\n### Update ContextMenu to conditionally show \"Show description\"\n\nPass `hasDescription` or check `node.description` inside ContextMenu. If no description, either:\n- (a) Hide the item entirely (cleaner)\n- (b) Show it grayed out / disabled\n\n**Recommended: (a) hide it.** If the node has no description, the context menu shows only \"Add comment\". If it has a description, both items show.\n\nTo handle this: ContextMenu already receives the `node` prop. It can check `node.description` internally:\n```tsx\n{node.description && (\n <button onClick={onShowDescription} ...>Show description</button>\n)}\n<button onClick={onAddComment} ...>Add comment</button>\n```\n\nBut wait — if a node has no description and the context menu only shows 1 item, the right-click context menu is pointless overhead (same as before — just open CommentTooltip directly). \n\n**Better approach:** In `handleNodeRightClick`, if the node has no description, skip the context menu and directly open the comment tooltip:\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setCommentTooltipState(null);\n if (!node.description) {\n // No description → skip context menu, open comment tooltip directly\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n },\n []\n);\n```\n\nThis preserves the existing UX for nodes without descriptions (identical to current behavior) and only shows the context menu when there is a meaningful choice.\n\n### Files to edit\n- `app/page.tsx`:\n - Add imports for ContextMenu and DescriptionModal\n - Add `commentTooltipState` and `descriptionModalNode` state\n - Update `handleNodeRightClick` to check for description\n - Update `handleBackgroundClick` to clear new states\n - Replace CommentTooltip render block with ContextMenu + CommentTooltip + DescriptionModal\n\n### Acceptance criteria\n- Right-click a node WITH description → context menu with 2 items\n- Right-click a node WITHOUT description → CommentTooltip opens directly (no context menu)\n- \"Show description\" → description modal opens, context menu closes\n- \"Add comment\" → CommentTooltip opens at same position, context menu closes\n- Right-click another node while CommentTooltip is open → CommentTooltip closes, new context menu opens\n- Background click clears context menu and comment tooltip\n- Escape closes whichever overlay is topmost\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:20:21.369074+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.50276+13:00","closed_at":"2026-02-11T09:23:40.50276+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:21.370692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.1","type":"blocks","created_at":"2026-02-11T09:20:21.372378+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.2","type":"blocks","created_at":"2026-02-11T09:20:21.374047+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.4","title":"Build verify and push context menu feature","description":"## Build verify and push\n\n### What\nFinal task: run pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.1\nbd close beads-map-z5w.2\nbd close beads-map-z5w.3\nbd close beads-map-z5w.4\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add right-click context menu with show description and add comment options (beads-map-z5w)\"\ngit push\n```\n\n### Edge cases to verify\n- Right-click node with description → context menu → \"Show description\" → modal opens\n- Right-click node with description → context menu → \"Add comment\" → CommentTooltip opens\n- Right-click node without description → CommentTooltip opens directly (no context menu)\n- Escape dismisses context menu / comment tooltip / description modal\n- Click outside dismisses context menu / comment tooltip\n- Backdrop click dismisses description modal\n- \"View in window\" in NodeDetail sidebar still works\n- Right-click during timeline replay still works\n- Context menu does not overlap viewport edges\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks and epic closed in beads","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:20:31.850081+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:23:40.674292+13:00","closed_at":"2026-02-11T09:23:40.674292+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:31.852407+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w.3","type":"blocks","created_at":"2026-02-11T09:20:31.854127+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.5","title":"Add 'Claim task' menu item to ContextMenu and post claim comment","description":"## Add \"Claim task\" menu item to ContextMenu and post claim comment\n\n### What\nAdd a third option \"Claim task\" to the right-click context menu. When clicked, it posts a comment on the node with the text `@<handle>` (e.g., `@satyam2.climateai.org`). The menu item only appears when the user is authenticated AND the node is not already claimed by anyone.\n\n### Detecting if a node is already claimed\nA node is \"claimed\" if any of its comments has text that starts with `@` and matches a handle pattern. We need to check `commentsByNode.get(nodeId)` to see if any comment text starts with `@`. Since claims are just `@handle`, a simple check is:\n\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\nThis checks: text starts with `@`, and is a single word (no spaces) — i.e., just a handle tag.\n\n### Changes to `components/ContextMenu.tsx`\n\n#### New props:\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClaimTask?: () => void; // NEW — undefined if not authenticated or already claimed\n onClose: () => void;\n}\n```\n\n#### New menu item (after \"Add comment\", before closing `</div>`):\n```tsx\n{onClaimTask && (\n <button\n onClick={onClaimTask}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-t border-zinc-100\"\n >\n <svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z\" />\n </svg>\n Claim task\n </button>\n)}\n```\n\nThe person/user icon SVG is from Heroicons (user outline).\n\nAlso add `border-t border-zinc-100` to the \"Add comment\" button when \"Claim task\" follows it. Actually simpler: just add `border-t border-zinc-100` to the claim button itself (as shown above), and keep \"Add comment\" unchanged — it already has no bottom border.\n\n### Changes to `app/page.tsx`\n\n#### 1. Add claim handler function (after `handlePostComment`, around line 485):\n```typescript\nconst handleClaimTask = useCallback(\n async (nodeId: string) => {\n if (!session?.handle) return;\n await handlePostComment(nodeId, `@${session.handle}`);\n },\n [session?.handle, handlePostComment]\n);\n```\n\nThis reuses the existing `handlePostComment` which:\n- POSTs to `/api/records` with collection `org.impactindexer.review.comment`\n- Creates a comment with `subject.uri = \"beads:<nodeId>\"` and `text = \"@handle\"`\n- Calls `refetchComments()` to update the UI\n\n#### 2. Add `isNodeClaimed` helper (at module level or as a function in page.tsx):\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n // A claim comment is just \"@handle\" — starts with @ and has no spaces\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\n#### 3. Update ContextMenu render (around line 1022):\n```tsx\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => { ... }} // unchanged\n onAddComment={() => { ... }} // unchanged\n onClaimTask={\n isAuthenticated && !isNodeClaimed(commentsByNode.get(contextMenu.node.id))\n ? () => {\n handleClaimTask(contextMenu.node.id);\n setContextMenu(null);\n }\n : undefined\n }\n onClose={() => setContextMenu(null)}\n />\n)}\n```\n\nWhen `onClaimTask` is undefined, the ContextMenu hides the \"Claim task\" button.\n\n### Edge cases\n1. **Not authenticated**: `onClaimTask` is undefined → button hidden\n2. **Already claimed**: `isNodeClaimed` returns true → button hidden\n3. **User claims their own node**: Works fine, comment is posted\n4. **Node with no description + not authenticated**: Right-click opens CommentTooltip directly (existing behavior in `handleNodeRightClick` which checks `!node.description`)\n5. **Node with no description + authenticated + not claimed**: Currently skips context menu. Need to update `handleNodeRightClick` to show context menu even for nodes without description IF the user is authenticated (so they can see \"Claim task\"). Update the condition:\n ```typescript\n if (!node.description && !isAuthenticated) {\n // No description and not logged in → only action is comment → skip menu\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n ```\n Wait, but if `isAuthenticated` but node has no description AND is already claimed, the menu would show just \"Add comment\" which is pointless overhead. The clean rule is: show context menu if there are 2+ items to choose from. For now, keep it simple: always show context menu when authenticated (even if only \"Add comment\" + \"Claim task\", or just \"Add comment\" if claimed). The overhead of one extra click is fine for the authenticated UX.\n\n### Files to edit\n- `components/ContextMenu.tsx` — add `onClaimTask` prop and conditional button\n- `app/page.tsx` — add `handleClaimTask`, `isNodeClaimed` helper, update ContextMenu render, update `handleNodeRightClick` condition\n\n### Acceptance criteria\n- \"Claim task\" appears in context menu when authenticated AND node not already claimed\n- \"Claim task\" hidden when not authenticated OR node already claimed\n- Clicking \"Claim task\" posts a comment `@<handle>` on the node\n- Comments refetch after claiming\n- Context menu closes after claiming\n- When authenticated, context menu always shows (even for nodes without description)\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:47:43.132495+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.219995+13:00","closed_at":"2026-02-11T09:54:17.219995+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.5","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:47:43.133427+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.6","title":"Compute claimed-node avatar map from comments","description":"## Compute claimed-node avatar map from comments\n\n### What\nDerive a `Map<string, { avatar?: string; handle: string }>` from `allComments` that maps node IDs to the claimant profile info. This map is then passed to `BeadsGraph` for rendering the avatar on the canvas.\n\n### How claims are detected\nA claim comment has text that starts with `@` and is a single word (no spaces). For example: `@satyam2.climateai.org`. Only the first claim per node counts (one claim only).\n\nThe comment object (`BeadsComment`) already has the resolved profile info: `handle`, `avatar`, `displayName`, `did`. So when we find a claim comment, we already have the avatar URL.\n\n### Implementation in `app/page.tsx`\n\n#### 1. Add a useMemo to compute claimed nodes (after `commentsByNode` is available):\n\n```typescript\n// Compute claimed node avatars from comments\n// A claim comment has text \"@handle\" (starts with @, no spaces)\nconst claimedNodeAvatars = useMemo(() => {\n const map = new Map<string, { avatar?: string; handle: string }>();\n if (!allComments) return map;\n for (const comment of allComments) {\n // Skip if this node already has a claimant (first claim wins)\n if (map.has(comment.nodeId)) continue;\n const text = comment.text.trim();\n if (text.startsWith(\"@\") && text.indexOf(\" \") === -1) {\n map.set(comment.nodeId, {\n avatar: comment.avatar,\n handle: comment.handle,\n });\n }\n }\n return map;\n}, [allComments]);\n```\n\nNote: `allComments` is the flat array from `useBeadsComments()` (already available in page.tsx at line 179). It contains all comments across all nodes, each with resolved profile info.\n\n#### 2. Pass to BeadsGraph:\n\n```tsx\n<BeadsGraph\n // ... existing props ...\n claimedNodeAvatars={claimedNodeAvatars}\n/>\n```\n\n#### 3. Add prop to BeadsGraphProps:\n\nIn `components/BeadsGraph.tsx`, add to the props interface:\n```typescript\nclaimedNodeAvatars?: Map<string, { avatar?: string; handle: string }>;\n```\n\nAnd add a ref to sync it (same pattern as `commentedNodeIdsRef`):\n```typescript\nconst claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string }>>(\n claimedNodeAvatars || new Map()\n);\n\nuseEffect(() => {\n claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map();\n refreshGraph(graphRef);\n}, [claimedNodeAvatars]);\n```\n\n### Why use a ref in BeadsGraph\nSame reason as `commentedNodeIdsRef`: the `paintNode` callback has `[]` dependencies (never recreated). It reads from refs, not from props or state. If we used props directly, we would need to add `claimedNodeAvatars` to the paintNode dependency array, which would cause the ForceGraph component to re-render and re-heat the simulation. The ref pattern avoids this.\n\n### Data flow summary\n```\nuseBeadsComments() → allComments (flat array with resolved profiles)\n ↓\nuseMemo → claimedNodeAvatars: Map<nodeId, { avatar, handle }>\n ↓\n<BeadsGraph claimedNodeAvatars={...}>\n ↓\nclaimedNodeAvatarsRef.current (ref, synced via useEffect)\n ↓\npaintNode reads claimedNodeAvatarsRef.current.get(nodeId)\n```\n\n### Files to edit\n- `app/page.tsx` — add `claimedNodeAvatars` useMemo, pass to BeadsGraph\n- `components/BeadsGraph.tsx` — add `claimedNodeAvatars` prop, add ref + sync effect\n\n### Acceptance criteria\n- `claimedNodeAvatars` correctly maps node IDs to claimant profile info\n- Only the first claim comment per node is used\n- Map updates reactively when comments change (useMemo dependency on allComments)\n- BeadsGraph receives the map and syncs it to a ref\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:48:05.021917+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.341949+13:00","closed_at":"2026-02-11T09:54:17.341949+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:05.022796+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w.5","type":"blocks","created_at":"2026-02-11T09:48:05.023797+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.7","title":"Draw claimant avatar on canvas nodes in BeadsGraph paintNode","description":"## Draw claimant avatar on canvas nodes in BeadsGraph paintNode\n\n### What\nWhen a node has a claimant (from `claimedNodeAvatarsRef`), draw their profile picture as a small circular avatar at the bottom-right of the node on the canvas. If no avatar URL is available, draw a fallback circle with the first letter of the handle.\n\n### Avatar image cache\nCanvas `ctx.drawImage()` requires an `HTMLImageElement`. We need to pre-load avatar images and cache them. Use a **module-level cache** (outside the component, like the existing `_ForceGraph2DModule` pattern) to persist across re-renders:\n\n```typescript\n// Module-level avatar image cache\nconst avatarImageCache = new Map<string, HTMLImageElement | \"loading\" | \"failed\">();\n\nfunction getAvatarImage(url: string, onLoad: () => void): HTMLImageElement | null {\n const cached = avatarImageCache.get(url);\n if (cached === \"loading\" || cached === \"failed\") return null;\n if (cached) return cached;\n\n // Start loading\n avatarImageCache.set(url, \"loading\");\n const img = new Image();\n img.crossOrigin = \"anonymous\"; // Required for canvas drawImage with external URLs\n img.onload = () => {\n avatarImageCache.set(url, img);\n onLoad(); // Trigger a canvas redraw\n };\n img.onerror = () => {\n avatarImageCache.set(url, \"failed\");\n };\n img.src = url;\n return null;\n}\n```\n\nThe `onLoad` callback should call `refreshGraph(graphRef)` to trigger a canvas redraw once the image is loaded. We need `graphRef` accessible from the cache callback. Two approaches:\n\n**Approach A:** Store `graphRef` in a module-level variable that gets set on component mount. Ugly but simple.\n\n**Approach B:** Instead of `onLoad` callback, just call `refreshGraph` from within `paintNode` when cache state changes. But `paintNode` runs every frame anyway, so once the image loads and is in cache, the next frame will pick it up. The issue is that we need ONE extra redraw after the image loads to show it. Solution: in `getAvatarImage`, when image loads, set a module-level flag `avatarCacheDirty = true`. In `paintNode`, if `avatarCacheDirty`, call `refreshGraph` once and reset the flag. Actually this is complicated.\n\n**Approach C (recommended):** The simplest approach. In `paintNode`, just try to get the cached image. If not cached yet, start loading and draw fallback. On next `paintNode` call (which happens on every frame when force simulation is running, or on next user interaction), the image will be ready. For the case where simulation has settled (no movement), the image load triggers no redraw. Fix: attach `img.onload` that triggers `refreshGraph(graphRef)`. Since `graphRef` is available in the component scope, pass a `refreshFn` to the cache function.\n\nActually, the cleanest approach: keep the image cache at module level, but have `paintNode` call a helper that uses the graphRef from the component:\n\n```typescript\n// Inside the BeadsGraph component:\nconst avatarRefreshRef = useRef<() => void>(() => {});\nuseEffect(() => {\n avatarRefreshRef.current = () => refreshGraph(graphRef);\n}, []);\n```\n\nThen in paintNode:\n```typescript\nconst claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id);\nif (claimInfo && globalScale > 0.4) {\n const avatarSize = Math.min(8, Math.max(4, 10 / globalScale));\n const avatarX = node.x + animatedSize * 0.7;\n const avatarY = node.y + animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = Math.min(opacity, 0.95);\n\n // Circular clipping path for avatar\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n\n if (claimInfo.avatar) {\n const img = getAvatarImage(claimInfo.avatar, () => avatarRefreshRef.current());\n if (img) {\n ctx.save();\n ctx.clip();\n ctx.drawImage(\n img,\n avatarX - avatarSize,\n avatarY - avatarSize,\n avatarSize * 2,\n avatarSize * 2\n );\n ctx.restore();\n } else {\n // Loading fallback — gray circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n } else {\n // No avatar URL — fallback circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n\n // White border ring around avatar for contrast\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n ctx.strokeStyle = \"#ffffff\";\n ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);\n ctx.stroke();\n\n ctx.restore();\n}\n```\n\n### Fallback avatar drawing\n```typescript\nfunction drawAvatarFallback(\n ctx: CanvasRenderingContext2D,\n x: number, y: number, radius: number,\n handle: string, globalScale: number\n) {\n // Light gray circle\n ctx.beginPath();\n ctx.arc(x, y, radius, 0, Math.PI * 2);\n ctx.fillStyle = \"#e4e4e7\"; // zinc-200\n ctx.fill();\n\n // First letter of handle\n const letter = handle.replace(\"@\", \"\").charAt(0).toUpperCase();\n const fontSize = Math.min(7, Math.max(3, radius * 1.3));\n ctx.font = `600 ${fontSize}px \"Inter\", system-ui, sans-serif`;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillStyle = \"#71717a\"; // zinc-500\n ctx.fillText(letter, x, y + 0.3);\n}\n```\n\n### Placement: bottom-right of node\nThe existing comment badge is at top-right:\n```\nbadgeX = node.x + animatedSize * 0.75 // top-right\nbadgeY = node.y - animatedSize * 0.75 // top-right (negative Y = up)\n```\n\nFor the avatar at bottom-right:\n```\navatarX = node.x + animatedSize * 0.7 // right\navatarY = node.y + animatedSize * 0.7 // bottom (positive Y = down)\n```\n\n### Drawing order in paintNode\nAdd the avatar drawing AFTER the comment badge (line ~746), before the `ctx.restore()` at the end of paintNode. The order:\n1. ... existing drawing (body, ring, label, etc.) ...\n2. Comment count badge at top-right (lines 716-746) — existing\n3. **Claimant avatar at bottom-right** — NEW (lines ~748+)\n\n### `crossOrigin = \"anonymous\"` \nRequired because avatar URLs are from `cdn.bsky.app` (external domain). Without this, canvas becomes \"tainted\" and some operations may fail. The `crossOrigin = \"anonymous\"` on the Image element tells the browser to request the image with CORS headers. Bluesky CDN supports CORS.\n\n### Visibility threshold\nSame as comment badges: `globalScale > 0.4`. When zoomed out too far, avatars are invisible (too small to see anyway).\n\n### Files to edit\n- `components/BeadsGraph.tsx`:\n - Add module-level `avatarImageCache` and `getAvatarImage()` function\n - Add module-level `drawAvatarFallback()` function\n - Add `avatarRefreshRef` inside component\n - Add avatar drawing section in `paintNode` after comment badge\n - Destructure `claimedNodeAvatars` from props (already added in .6)\n\n### Acceptance criteria\n- Claimed nodes show a small circular avatar at bottom-right\n- Avatar loads asynchronously and appears after image loads\n- Fallback shows gray circle with first letter of handle when no avatar URL\n- Fallback shows while image is loading\n- Avatar has white border ring for contrast\n- Avatar only visible when zoomed in enough (globalScale > 0.4)\n- Avatar scales appropriately with zoom level\n- No canvas tainting errors (crossOrigin = \"anonymous\")\n- Multiple claimed nodes each show their respective claimant avatars\n- pnpm build passes","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:48:47.291793+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.463406+13:00","closed_at":"2026-02-11T09:54:17.463406+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:47.292966+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w.6","type":"blocks","created_at":"2026-02-11T09:48:47.301709+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.8","title":"Build verify and push claim task feature","description":"## Build verify and push claim task feature\n\n### What\nFinal task: run pnpm build, fix any errors, close beads tasks, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.5\nbd close beads-map-z5w.6\nbd close beads-map-z5w.7\nbd close beads-map-z5w.8\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add claim task feature: right-click to claim with avatar on node (beads-map-z5w.5-8)\"\ngit push\n```\n\n### Edge cases to verify\n1. **Not authenticated** → \"Claim task\" not in context menu\n2. **Already claimed by someone** → \"Claim task\" not in context menu\n3. **Claim with avatar** → small circular avatar appears at bottom-right of node\n4. **Claim without avatar (no profile pic)** → gray circle with first letter of handle\n5. **Multiple nodes claimed by different users** → each shows correct avatar\n6. **Zoom out far** → avatars disappear (globalScale < 0.4)\n7. **Avatar image fails to load** → fallback circle shown\n8. **Timeline replay** → claimed avatars still show on visible nodes\n9. **Comment badges + avatar** → both visible (top-right badge, bottom-right avatar, no overlap)\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks (.5, .6, .7, .8) and epic closed in beads","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T09:48:59.026151+13:00","created_by":"daviddao","updated_at":"2026-02-11T09:54:17.602817+13:00","closed_at":"2026-02-11T09:54:17.602817+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:59.028136+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w.7","type":"blocks","created_at":"2026-02-11T09:48:59.029382+13:00","created_by":"daviddao"}]},{"id":"beads-map-z5w.9","title":"Fix claim avatar loading: optimistic display, Bluesky API fallback, CORS fix","description":"## Fix claim avatar loading\n\n### Problem\nAfter implementing the claim feature (.5–.8), two bugs were found:\n1. **Avatar only appeared after page refresh**, not immediately after claiming — the Hypergoat indexer has latency before it indexes new comments, so `refetchComments()` returned stale data.\n2. **Avatar image never loaded (only fallback letter \"S\" shown)** — two causes:\n a. `session.avatar` was undefined (OAuth profile fetch failed silently during login)\n b. `crossOrigin = \"anonymous\"` on the HTMLImageElement caused CORS rejection from Bluesky CDN (`cdn.bsky.app`), triggering `onerror` and permanently caching the image as \"failed\"\n\n### Fixes applied\n\n#### 1. Optimistic claim display (`app/page.tsx`)\n- Added `optimisticClaims` state: `Map<string, { avatar?: string; handle: string }>`\n- `handleClaimTask` immediately sets the claimant avatar in `optimisticClaims` before posting the comment\n- `claimedNodeAvatars` useMemo merges optimistic claims (priority) with comment-derived claims\n- `isNodeClaimed` check in ContextMenu now uses `claimedNodeAvatars.has()` instead of `isNodeClaimed(commentsByNode)` so \"Claim task\" button hides immediately\n- Added 3-second delayed `refetchComments()` after claiming to eventually pick up the indexed comment\n\n#### 2. Bluesky public API avatar fallback (`app/page.tsx`)\n- In `handleClaimTask`, if `session.avatar` is undefined, fetches avatar from `public.api.bsky.app/xrpc/app.bsky.actor.getProfile` using `session.did`\n- This is the same API that `useBeadsComments` uses for profile resolution\n\n#### 3. Removed crossOrigin restriction (`components/BeadsGraph.tsx`)\n- Removed `img.crossOrigin = \"anonymous\"` from `getAvatarImage()` function\n- Canvas `drawImage()` works without CORS — canvas becomes \"tainted\" (cant read pixels back) but we never need `getImageData`/`toDataURL`\n- This fixed the Bluesky CDN image loading failure\n\n### Commits\n- `877b037` Fix claim: optimistic avatar display + delayed refetch for indexer latency\n- `efd6275` Fix claim avatar: remove crossOrigin restriction, fetch avatar from Bluesky public API as fallback\n\n### Files changed\n- `app/page.tsx` — optimisticClaims state, handleClaimTask with API fallback, claimedNodeAvatars merge, isNodeClaimed check\n- `components/BeadsGraph.tsx` — removed crossOrigin from Image element\n\n### Status: DONE","status":"closed","priority":1,"issue_type":"task","owner":"david@gainforest.net","created_at":"2026-02-11T10:25:55.858566+13:00","created_by":"daviddao","updated_at":"2026-02-11T10:26:45.538096+13:00","closed_at":"2026-02-11T10:26:45.538096+13:00","close_reason":"Closed","dependencies":[{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:25:55.859836+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w.8","type":"blocks","created_at":"2026-02-11T10:25:55.860815+13:00","created_by":"daviddao"}]},{"id":"beads-map-zr4","title":"Show/hide hierarchical cluster labels toggle","description":"## What (retroactive — already done)\n\nAdded a toggle button in the top-left controls to show/hide the hierarchical cluster circles and labels that appear when zoomed out. Clusters represent epic parent nodes and their children, rendered as dashed circles with titles at centroids.\n\n## Commit\n- 6cfc26c — Add toggle to show/hide hierarchical cluster labels when zoomed out\n\n## Changes\n\n### components/BeadsGraph.tsx\n\n1. **New state** (line ~278): \\`const [showClusters, setShowClusters] = useState(true)\\`\n - Defaults to true (existing behavior preserved)\n\n2. **Guard in paintClusterLabels** (line ~1333): Added \\`if (!showClusters) return;\\` at the top of the callback, before any computation. Added \\`showClusters\\` to the dependency array.\n\n3. **Toggle button** in top-left controls (after Collapse/Expand button):\n - Emerald-500 background when active (clusters visible), white/zinc when inactive\n - Dashed circle + text lines SVG icon representing the cluster overlay\n - Label: \"Clusters\" (hidden on mobile, visible on sm+)\n - Title tooltip: \"Hide cluster labels\" / \"Show cluster labels\"\n - Same style as the collapse/expand button (rounded-lg, border, shadow-sm, backdrop-blur)\n\n## UI placement\n```\n[Force][DAG][Radial][Cluster][Spread] [Collapse all] [Clusters]\n```\nThe Clusters button is the rightmost in the top-left control row.\n\n## Behavior\n- **ON (default)**: Cluster circles fade in when zoomed out past globalScale 0.8, fully visible below 0.4. Shows dashed prefix-colored circle, epic title, epic ID, and member count.\n- **OFF**: No cluster rendering at any zoom level. Saves rendering cost on large graphs.","status":"closed","priority":2,"issue_type":"feature","owner":"david@gainforest.net","created_at":"2026-02-12T14:22:32.483636+13:00","created_by":"daviddao","updated_at":"2026-02-12T14:22:50.485493+13:00","closed_at":"2026-02-12T14:22:50.485493+13:00","close_reason":"Closed"}],"dependencies":[{"issue_id":"beads-map-21c","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.244292+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.1","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:27.389228+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:34.243147+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.10","depends_on_id":"beads-map-21c.9","type":"blocks","created_at":"2026-02-11T02:07:38.658953+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.11","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:07.010331+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:12:14.930729+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.12","depends_on_id":"beads-map-21c.11","type":"blocks","created_at":"2026-02-11T02:12:15.066268+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.2","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:47:41.591486+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:09.027391+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.3","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.440174+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.4","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:48:40.961908+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:28.830802+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.2","type":"blocks","created_at":"2026-02-11T01:51:32.557476+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.3","type":"blocks","created_at":"2026-02-11T01:51:32.669716+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.5","depends_on_id":"beads-map-21c.4","type":"blocks","created_at":"2026-02-11T01:51:32.780421+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T01:49:44.216474+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.6","depends_on_id":"beads-map-21c.5","type":"blocks","created_at":"2026-02-11T01:51:32.89299+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.7","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:49.847169+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:00:58.652019+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.8","depends_on_id":"beads-map-21c.7","type":"blocks","created_at":"2026-02-11T02:01:02.543151+13:00","created_by":"daviddao"},{"issue_id":"beads-map-21c.9","depends_on_id":"beads-map-21c","type":"parent-child","created_at":"2026-02-11T02:07:25.358814+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.39819+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2fk","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.995145+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.706041+13:00","created_by":"daviddao"},{"issue_id":"beads-map-2qg","depends_on_id":"beads-map-mq9","type":"blocks","created_at":"2026-02-10T23:19:29.394542+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.1","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:08:05.404061+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.2","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:08:31.187092+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.2","depends_on_id":"beads-map-3pg.1","type":"blocks","created_at":"2026-02-12T16:08:31.188462+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.3","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:08:40.196547+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.3","depends_on_id":"beads-map-3pg.1","type":"blocks","created_at":"2026-02-12T16:08:40.197777+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.4","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:09:08.246591+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.4","depends_on_id":"beads-map-3pg.3","type":"blocks","created_at":"2026-02-12T16:09:08.248185+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:09:17.523491+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.2","type":"blocks","created_at":"2026-02-12T16:09:17.525027+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.4","type":"blocks","created_at":"2026-02-12T16:09:17.527425+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.7","type":"blocks","created_at":"2026-02-12T16:15:02.933093+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.5","depends_on_id":"beads-map-3pg.8","type":"blocks","created_at":"2026-02-12T16:15:03.067512+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.6","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:13:04.418858+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.6","depends_on_id":"beads-map-3pg.4","type":"blocks","created_at":"2026-02-12T16:13:04.420528+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.7","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:14:39.402553+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.7","depends_on_id":"beads-map-3pg.1","type":"blocks","created_at":"2026-02-12T16:14:39.40454+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.7","depends_on_id":"beads-map-3pg.3","type":"blocks","created_at":"2026-02-12T16:14:39.406102+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.8","depends_on_id":"beads-map-3pg","type":"parent-child","created_at":"2026-02-12T16:14:58.679502+13:00","created_by":"daviddao"},{"issue_id":"beads-map-3pg.8","depends_on_id":"beads-map-3pg.7","type":"blocks","created_at":"2026-02-12T16:14:58.681794+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.316735+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7j2","depends_on_id":"beads-map-m1o","type":"blocks","created_at":"2026-02-10T23:19:28.909987+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.410329+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.1","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.795118+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:21.923002+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:24.073985+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.2","depends_on_id":"beads-map-7r6.7","type":"blocks","created_at":"2026-02-11T12:12:27.830152+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.048183+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.3","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:12.799635+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.174711+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.4","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:16.524399+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.303116+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.5","depends_on_id":"beads-map-7r6.3","type":"blocks","created_at":"2026-02-11T12:12:20.162124+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.428287+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.2","type":"blocks","created_at":"2026-02-11T12:12:31.588158+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.4","type":"blocks","created_at":"2026-02-11T12:12:35.542205+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.6","depends_on_id":"beads-map-7r6.5","type":"blocks","created_at":"2026-02-11T12:12:39.650845+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.552867+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.7","depends_on_id":"beads-map-7r6.1","type":"blocks","created_at":"2026-02-11T12:12:09.2907+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6","type":"parent-child","created_at":"2026-02-11T11:54:22.675891+13:00","created_by":"daviddao"},{"issue_id":"beads-map-7r6.8","depends_on_id":"beads-map-7r6.6","type":"blocks","created_at":"2026-02-11T12:12:44.251892+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np","depends_on_id":"beads-map-9d3","type":"blocks","created_at":"2026-02-12T10:39:55.489578+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.1","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:33:56.34421+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:01.699953+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.2","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.820355+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np","type":"parent-child","created_at":"2026-02-12T10:34:07.490637+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8np.3","depends_on_id":"beads-map-8np.1","type":"blocks","created_at":"2026-02-12T10:34:12.951842+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.1","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:12:32.786117+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.10","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:15:12.207348+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.2","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:12:51.584816+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.2","depends_on_id":"beads-map-8tp.1","type":"blocks","created_at":"2026-02-12T15:12:51.586097+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.3","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:08.950526+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.3","depends_on_id":"beads-map-8tp.2","type":"blocks","created_at":"2026-02-12T15:13:08.951859+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.4","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:26.55342+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.5","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:41.075186+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.6","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:13:55.422504+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.7","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:14:12.523784+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.7","depends_on_id":"beads-map-8tp.5","type":"blocks","created_at":"2026-02-12T15:14:12.525112+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.8","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:14:28.79067+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.8","depends_on_id":"beads-map-8tp.1","type":"blocks","created_at":"2026-02-12T15:14:28.792079+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp","type":"parent-child","created_at":"2026-02-12T15:14:47.216998+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.1","type":"blocks","created_at":"2026-02-12T15:14:47.218878+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.2","type":"blocks","created_at":"2026-02-12T15:14:47.220248+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.3","type":"blocks","created_at":"2026-02-12T15:14:47.221773+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.4","type":"blocks","created_at":"2026-02-12T15:14:47.223045+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.5","type":"blocks","created_at":"2026-02-12T15:14:47.224557+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.6","type":"blocks","created_at":"2026-02-12T15:14:47.225986+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.7","type":"blocks","created_at":"2026-02-12T15:14:47.227396+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8tp.9","depends_on_id":"beads-map-8tp.8","type":"blocks","created_at":"2026-02-12T15:14:47.228629+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.1","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:17.43527+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:27.399923+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.2","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.363125+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.3","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:36.643125+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1","type":"parent-child","created_at":"2026-02-12T10:50:48.970023+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.1","type":"blocks","created_at":"2026-02-12T10:50:56.478692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.2","type":"blocks","created_at":"2026-02-12T10:50:56.600112+13:00","created_by":"daviddao"},{"issue_id":"beads-map-8z1.4","depends_on_id":"beads-map-8z1.3","type":"blocks","created_at":"2026-02-12T10:50:56.718812+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9d3.2","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:40.269874+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9d3.3","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:46.062066+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9d3.4","depends_on_id":"beads-map-9d3","type":"parent-child","created_at":"2026-02-12T10:26:51.725102+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.1","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:23:50.371471+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:08.11827+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.3","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.58504+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:14.898283+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.4","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.764197+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:20.513717+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.5","depends_on_id":"beads-map-9lm.1","type":"blocks","created_at":"2026-02-12T11:24:35.949509+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm","type":"parent-child","created_at":"2026-02-12T11:24:29.192269+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.3","type":"blocks","created_at":"2026-02-12T11:24:36.145483+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.4","type":"blocks","created_at":"2026-02-12T11:24:36.312505+13:00","created_by":"daviddao"},{"issue_id":"beads-map-9lm.6","depends_on_id":"beads-map-9lm.5","type":"blocks","created_at":"2026-02-12T11:24:36.484971+13:00","created_by":"daviddao"},{"issue_id":"beads-map-a2e.1","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:15:45.738001+13:00","created_by":"daviddao"},{"issue_id":"beads-map-a2e.2","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:15:54.279327+13:00","created_by":"daviddao"},{"issue_id":"beads-map-a2e.3","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:16:00.098891+13:00","created_by":"daviddao"},{"issue_id":"beads-map-a2e.4","depends_on_id":"beads-map-a2e","type":"parent-child","created_at":"2026-02-12T17:16:11.444833+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh","depends_on_id":"beads-map-3jy","type":"blocks","created_at":"2026-02-12T10:39:55.001081+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.1","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:56:38.694406+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:01.112211+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.2","depends_on_id":"beads-map-cvh.1","type":"blocks","created_at":"2026-02-10T23:57:01.113311+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:16.26232+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.3","depends_on_id":"beads-map-cvh.2","type":"blocks","created_at":"2026-02-10T23:57:16.263416+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:32.924539+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.4","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:57:32.926286+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:57:56.263692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.5","depends_on_id":"beads-map-cvh.4","type":"blocks","created_at":"2026-02-10T23:57:56.264726+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:15.699689+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.6","depends_on_id":"beads-map-cvh.5","type":"blocks","created_at":"2026-02-10T23:58:15.700911+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:28.65065+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.7","depends_on_id":"beads-map-cvh.3","type":"blocks","created_at":"2026-02-10T23:58:28.65195+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh","type":"parent-child","created_at":"2026-02-10T23:58:49.015822+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.6","type":"blocks","created_at":"2026-02-10T23:58:49.016931+13:00","created_by":"daviddao"},{"issue_id":"beads-map-cvh.8","depends_on_id":"beads-map-cvh.7","type":"blocks","created_at":"2026-02-10T23:58:49.017826+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.1","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T13:58:57.259359+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.2","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T13:59:08.218477+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.2","depends_on_id":"beads-map-dwk.1","type":"blocks","created_at":"2026-02-12T13:59:08.220794+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.3","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T13:59:56.526648+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.3","depends_on_id":"beads-map-dwk.1","type":"blocks","created_at":"2026-02-12T13:59:56.528522+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.3","depends_on_id":"beads-map-dwk.2","type":"blocks","created_at":"2026-02-12T13:59:56.530415+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.4","depends_on_id":"beads-map-dwk","type":"parent-child","created_at":"2026-02-12T14:00:09.763561+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dwk.4","depends_on_id":"beads-map-dwk.3","type":"blocks","created_at":"2026-02-12T14:00:09.76645+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi","depends_on_id":"beads-map-cvh","type":"blocks","created_at":"2026-02-12T10:39:55.083326+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.1","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:20.161533+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.2","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:28.754207+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.3","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:39.227376+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:47.745514+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.4","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.253835+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:31:54.778714+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.5","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.395175+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:32:01.725925+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.1","type":"blocks","created_at":"2026-02-11T00:38:43.522633+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.2","type":"blocks","created_at":"2026-02-11T00:38:43.647344+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.3","type":"blocks","created_at":"2026-02-11T00:38:43.773371+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.4","type":"blocks","created_at":"2026-02-11T00:38:43.895718+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.6","depends_on_id":"beads-map-dyi.5","type":"blocks","created_at":"2026-02-11T00:38:44.013093+13:00","created_by":"daviddao"},{"issue_id":"beads-map-dyi.7","depends_on_id":"beads-map-dyi","type":"parent-child","created_at":"2026-02-11T00:45:37.232842+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.476318+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-7j2","type":"blocks","created_at":"2026-02-10T23:19:29.07598+13:00","created_by":"daviddao"},{"issue_id":"beads-map-ecl","depends_on_id":"beads-map-2fk","type":"blocks","created_at":"2026-02-10T23:19:29.155362+13:00","created_by":"daviddao"},{"issue_id":"beads-map-f8f","depends_on_id":"beads-map-2u2","type":"blocks","created_at":"2026-02-12T14:22:17.912819+13:00","created_by":"daviddao"},{"issue_id":"beads-map-gjo","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.148777+13:00","created_by":"daviddao"},{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.553429+13:00","created_by":"daviddao"},{"issue_id":"beads-map-iyn","depends_on_id":"beads-map-ecl","type":"blocks","created_at":"2026-02-10T23:19:29.234083+13:00","created_by":"daviddao"},{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.23277+13:00","created_by":"daviddao"},{"issue_id":"beads-map-m1o","depends_on_id":"beads-map-gjo","type":"blocks","created_at":"2026-02-10T23:19:28.823723+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-8np","type":"blocks","created_at":"2026-02-12T10:39:55.570556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.652022+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mfw.1","depends_on_id":"beads-map-mfw","type":"parent-child","created_at":"2026-02-12T10:38:08.455822+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-3jy","type":"parent-child","created_at":"2026-02-10T23:19:22.630363+13:00","created_by":"daviddao"},{"issue_id":"beads-map-mq9","depends_on_id":"beads-map-iyn","type":"blocks","created_at":"2026-02-10T23:19:29.312556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.1","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:25:11.058045+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.10","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:51:17.69846+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.2","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:25:28.384637+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.2","depends_on_id":"beads-map-s0c.1","type":"blocks","created_at":"2026-02-12T15:25:28.392+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.3","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:26:04.162359+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.3","depends_on_id":"beads-map-s0c.2","type":"blocks","created_at":"2026-02-12T15:26:04.163786+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.4","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:26:35.755265+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.4","depends_on_id":"beads-map-s0c.3","type":"blocks","created_at":"2026-02-12T15:26:35.756906+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.5","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:26:57.640666+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.5","depends_on_id":"beads-map-s0c.3","type":"blocks","created_at":"2026-02-12T15:26:57.642105+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.5","depends_on_id":"beads-map-s0c.4","type":"blocks","created_at":"2026-02-12T15:26:57.643209+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.6","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:27:10.156391+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.6","depends_on_id":"beads-map-s0c.5","type":"blocks","created_at":"2026-02-12T15:27:10.157664+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.7","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:50:23.533357+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.8","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:50:44.2796+13:00","created_by":"daviddao"},{"issue_id":"beads-map-s0c.9","depends_on_id":"beads-map-s0c","type":"parent-child","created_at":"2026-02-12T15:51:01.379692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg","depends_on_id":"beads-map-dyi","type":"blocks","created_at":"2026-02-11T01:26:33.09446+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.1","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:33.395429+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:24:55.518315+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.2","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.248408+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:16.083107+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.3","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.371142+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:25:35.924466+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.4","depends_on_id":"beads-map-vdg.1","type":"blocks","created_at":"2026-02-11T01:26:28.487447+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:04.1688+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.2","type":"blocks","created_at":"2026-02-11T01:26:28.611742+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.3","type":"blocks","created_at":"2026-02-11T01:26:28.725946+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.5","depends_on_id":"beads-map-vdg.4","type":"blocks","created_at":"2026-02-11T01:26:28.84169+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:26:12.402978+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.6","depends_on_id":"beads-map-vdg.5","type":"blocks","created_at":"2026-02-11T01:26:28.956024+13:00","created_by":"daviddao"},{"issue_id":"beads-map-vdg.7","depends_on_id":"beads-map-vdg","type":"parent-child","created_at":"2026-02-11T01:36:49.289175+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w","depends_on_id":"beads-map-vdg","type":"blocks","created_at":"2026-02-12T10:39:55.328556+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.1","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:10.936853+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:08.914469+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.10","depends_on_id":"beads-map-z5w.9","type":"blocks","created_at":"2026-02-11T10:26:08.915791+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:26:37.013442+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.11","depends_on_id":"beads-map-z5w.10","type":"blocks","created_at":"2026-02-11T10:26:37.015186+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.12","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:47:43.377971+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.2","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:19:41.234513+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:21.370692+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.1","type":"blocks","created_at":"2026-02-11T09:20:21.372378+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.3","depends_on_id":"beads-map-z5w.2","type":"blocks","created_at":"2026-02-11T09:20:21.374047+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:20:31.852407+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.4","depends_on_id":"beads-map-z5w.3","type":"blocks","created_at":"2026-02-11T09:20:31.854127+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.5","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:47:43.133427+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:05.022796+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.6","depends_on_id":"beads-map-z5w.5","type":"blocks","created_at":"2026-02-11T09:48:05.023797+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:47.292966+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.7","depends_on_id":"beads-map-z5w.6","type":"blocks","created_at":"2026-02-11T09:48:47.301709+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T09:48:59.028136+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.8","depends_on_id":"beads-map-z5w.7","type":"blocks","created_at":"2026-02-11T09:48:59.029382+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w","type":"parent-child","created_at":"2026-02-11T10:25:55.859836+13:00","created_by":"daviddao"},{"issue_id":"beads-map-z5w.9","depends_on_id":"beads-map-z5w.8","type":"blocks","created_at":"2026-02-11T10:25:55.860815+13:00","created_by":"daviddao"}],"graphData":{"nodes":[{"id":"beads-map-21c","title":"Timeline replay: scrubber bar to animate project history","description":"## Timeline Replay: scrubber bar to animate project history\n\n### Summary\nAdd a timeline replay feature that lets users watch the project's history unfold. A playback bar at the bottom-right of the graph (replacing the current floating legend hint) provides play/pause, a draggable scrubber, and speed controls. As the virtual clock advances, nodes pop into existence (at their createdAt time), show status changes (at updatedAt), and fade to closed (at closedAt). Links appear when their dependency was created.\n\n### Architecture\n\n**Data layer (`lib/timeline.ts` — new file):**\n- `TimelineEvent` type: `{ time: number, type: 'node-created'|'node-closed'|'link-created', id: string }`\n- `buildTimelineEvents(nodes, links)`: extracts all timestamped events from nodes (createdAt, closedAt) and links (createdAt), sorts chronologically, returns `{ events: TimelineEvent[], minTime: number, maxTime: number }`\n- `filterDataAtTime(allNodes, allLinks, currentTime)`: returns `{ nodes: GraphNode[], links: GraphLink[] }` containing only items visible at `currentTime`. Nodes visible when `createdAt <= currentTime`. Node status = closed if `closedAt && closedAt <= currentTime`, else original status. Links visible when both endpoints visible AND `link.createdAt <= currentTime`.\n\n**Component (`components/TimelineBar.tsx` — new file):**\n- Positioned absolute bottom-right inside BeadsGraph, replaces the floating legend hint\n- Contains: play/pause button (svg icons), horizontal range slider, current date/time label, speed toggle (1x/2x/4x)\n- `requestAnimationFrame` loop advances currentTime when playing\n- Dragging slider pauses playback and updates currentTime\n- Props: `minTime`, `maxTime`, `currentTime`, `isPlaying`, `speed`, `onTimeChange`, `onPlayPause`, `onSpeedChange`\n- Tick marks on slider for event density (optional visual enhancement)\n\n**Wiring (`app/page.tsx` + `components/BeadsGraph.tsx`):**\n- New state in page.tsx: `timelineActive: boolean`, `timelineTime: number`, `timelinePlaying: boolean`, `timelineSpeed: number`\n- New pill button in header (same style as Force/DAG/Comments pills) to toggle timeline mode\n- When timelineActive: compute filtered nodes/links via filterDataAtTime, stamp _spawnTime on newly-visible nodes, pass filtered data to BeadsGraph\n- SSE live updates still accumulate into `data` but filtered view controls what's shown\n- TimelineBar rendered inside BeadsGraph (or as overlay in graph area)\n\n**NodeDetail date format enhancement:**\n- Change formatDate() to include hour:minute — \"Feb 10, 2026 at 11:48\"\n\n**GraphLink.createdAt:**\n- Add optional `createdAt?: string` to GraphLink type\n- Populate from BeadDependency.created_at in buildGraphData()\n\n### Subject areas\n- `lib/types.ts` — GraphLink.createdAt addition\n- `lib/parse-beads.ts` — populate link createdAt\n- `lib/timeline.ts` — new file, pure functions for event extraction and time-filtering\n- `components/TimelineBar.tsx` — new component\n- `components/BeadsGraph.tsx` — render TimelineBar, replace legend hint when timeline active\n- `components/NodeDetail.tsx` — formatDate with time\n- `app/page.tsx` — state, pill button, filtering logic, wiring\n\n### Status at a point in time\nSince we only have createdAt/updatedAt/closedAt (not per-status-change history), the replay shows:\n- Before createdAt: node doesn't exist\n- Between createdAt and closedAt: node shows as \"open\" (original non-closed status)\n- At closedAt: node transitions to \"closed\" status with ripple animation\n- updatedAt: can trigger a subtle pulse to indicate activity\n\n### Speed mapping\n1x = 1 real second per calendar day of project time. 2x = 2 days/sec. 4x = 4 days/sec.\n\n### Dependency chain\n.1 (formatDate) is independent\n.2 (GraphLink.createdAt) is independent\n.3 (timeline.ts) depends on .2 (needs link createdAt)\n.4 (TimelineBar component) is independent (pure UI)\n.5 (wiring in page.tsx + BeadsGraph) depends on .2, .3, .4\n.6 (build verification) depends on .5","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:47:14.847191+13:00","updatedAt":"2026-02-11T02:13:05.329913+13:00","closedAt":"2026-02-11T02:13:05.329913+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":12,"dependentCount":1,"blockerIds":["beads-map-21c.1","beads-map-21c.10","beads-map-21c.11","beads-map-21c.12","beads-map-21c.2","beads-map-21c.3","beads-map-21c.4","beads-map-21c.5","beads-map-21c.6","beads-map-21c.7","beads-map-21c.8","beads-map-21c.9"],"dependentIds":["beads-map-3jy"]},{"id":"beads-map-21c.1","title":"Add hour:minute to date display in NodeDetail","description":"## Add hour:minute to date display in NodeDetail\n\n### What\nChange the formatDate() function in components/NodeDetail.tsx to include hour and minute alongside the existing date.\n\n### Current code (components/NodeDetail.tsx, lines 132-144)\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n } catch {\n return dateStr;\n }\n};\n```\n\nCurrent output: \"Feb 10, 2026\"\n\n### Target output\n\"Feb 10, 2026 at 11:48\"\n\n### Implementation\nReplace the formatDate function body. Use toLocaleDateString for the date part and toLocaleTimeString for the time part:\n\n```typescript\nconst formatDate = (dateStr: string) => {\n try {\n const d = new Date(dateStr);\n const date = d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n const time = d.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n hour12: false,\n });\n return `${date} at ${time}`;\n } catch {\n return dateStr;\n }\n};\n```\n\n### Where it's used\nThe formatDate function is called in three places in the same file (lines 220-258):\n- `formatDate(node.createdAt)` — Created row\n- `formatDate(node.updatedAt)` — Updated row\n- `formatDate(node.closedAt)` — Closed row (conditional)\n\nAll three will automatically pick up the new format.\n\n### Files to edit\n- `components/NodeDetail.tsx` — lines 132-144, formatDate function only\n\n### Acceptance criteria\n- Date rows in NodeDetail show \"Feb 10, 2026 at 11:48\" format\n- Hours use 24h format (no AM/PM) for compactness\n- No other files changed\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:47:27.387689+13:00","updatedAt":"2026-02-11T01:53:53.448072+13:00","closedAt":"2026-02-11T01:53:53.448072+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.10","title":"Build verify and push timeline link/preamble/speed fix","description":"## Build verify and push\n\nRun pnpm build, fix any type errors, commit and push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.10\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline: links with both nodes, empty preamble, 2s per event (beads-map-21c.9)\"\ngit push\n```\n\n### Edge cases\n- Link between two nodes that appear on the same step — link should appear immediately\n- Preamble (step -1) shows empty canvas, then step 0 shows first event\n- Scrubbing slider to step 0 shows first event (not preamble)\n- Speed change during playback — interval restarts correctly\n- Toggle off during preamble — clears state\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:07:34.241545+13:00","updatedAt":"2026-02-11T02:09:44.108526+13:00","closedAt":"2026-02-11T02:09:44.108526+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.9"]},{"id":"beads-map-21c.11","title":"Fix timeline replay links: normalize source/target to string IDs before diff/merge","description":"## Fix timeline replay links: normalize source/target to string IDs\n\n### Bug\nLinks during timeline replay appear but are NOT connected to their nodes — they float/draw to wrong positions.\n\n### Root cause\nreact-force-graph-2d mutates link objects in-place, replacing link.source and link.target from string IDs to object references pointing to actual node objects in the simulation.\n\nWhen filterDataAtTime() is called with data.graphData.links, those links already have mutated source/target (object refs pointing to the MAIN graph's node objects). These mutated links flow through mergeBeadsData() and get passed to BeadsGraph as timeline data. ForceGraph2D sees already-resolved object references and uses them directly — but they point to the WRONG node objects (main graph nodes, not timeline nodes). Links draw to invisible ghost positions.\n\n### Fix\nIn filterDataAtTime() in lib/timeline.ts, line 126, normalize source/target back to string IDs when pushing links into the result:\n\nCurrent code (lib/timeline.ts, lines 111-128):\n```typescript\nfor (const link of allLinks) {\n const src =\n typeof link.source === \"object\"\n ? (link.source as { id: string }).id\n : link.source;\n const tgt =\n typeof link.target === \"object\"\n ? (link.target as { id: string }).id\n : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n links.push(link); // <-- BUG: pushes mutated link with object refs\n}\n```\n\nFix — replace the last line:\n```typescript\n links.push({\n ...link,\n source: src,\n target: tgt,\n });\n```\n\nThe src and tgt variables are already extracted as string IDs (lines 112-118). By spreading a new link object with string source/target, d3-force will resolve them to the correct node objects in the timeline's node array.\n\n### Files to edit\n- lib/timeline.ts — line 126: replace links.push(link) with links.push({ ...link, source: src, target: tgt })\n\n### Acceptance criteria\n- During timeline replay, links visually connect to their nodes\n- Links draw correctly at every step, including first appearance and after scrubbing\n- pnpm build passes","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:12:07.008838+13:00","updatedAt":"2026-02-11T02:13:05.073793+13:00","closedAt":"2026-02-11T02:13:05.073793+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.12"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.12","title":"Build verify and push timeline link connection fix","description":"## Build verify and push\n\npnpm build, close tasks, sync, commit, push.\n\n### Commands\n```bash\npnpm build\nbd close beads-map-21c.11\nbd close beads-map-21c.12\nbd close beads-map-21c\nbd sync\ngit add -A\ngit commit -m \"Fix timeline links: normalize source/target to string IDs (beads-map-21c.11)\"\ngit push\n```\n\n### Acceptance criteria\n- pnpm build passes\n- git status clean after push","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:12:14.928323+13:00","updatedAt":"2026-02-11T02:13:05.202556+13:00","closedAt":"2026-02-11T02:13:05.202556+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.11"]},{"id":"beads-map-21c.2","title":"Add createdAt field to GraphLink type and populate from dependency data","description":"## Add createdAt to GraphLink\n\n### What\nGraphLink currently has no timestamp. Add an optional createdAt field and populate it from BeadDependency.created_at so links can be time-filtered in the timeline replay.\n\n### Current GraphLink type (lib/types.ts, lines 70-78)\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 1: lib/types.ts\nAdd `createdAt?: string;` to GraphLink, after `type` and before `_spawnTime`:\n\n```typescript\nexport interface GraphLink {\n source: string;\n target: string;\n type: \"blocks\" | \"parent-child\" | \"relates_to\";\n createdAt?: string; // <-- ADD THIS: ISO 8601 from BeadDependency.created_at\n _spawnTime?: number;\n _removeTime?: number;\n}\n```\n\n### Change 2: lib/parse-beads.ts\nIn buildGraphData(), the link mapping (lines 161-175) currently drops created_at:\n\n```typescript\nconst links: GraphLink[] = dependencies\n .filter(\n (d) =>\n (d.type === \"blocks\" || d.type === \"parent-child\") &&\n issueMap.has(d.issue_id) &&\n issueMap.has(d.depends_on_id)\n )\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n }));\n```\n\nAdd `createdAt: d.created_at,` to the .map() return object:\n\n```typescript\n .map((d) => ({\n source: d.depends_on_id,\n target: d.issue_id,\n type: d.type,\n createdAt: d.created_at, // <-- ADD THIS\n }));\n```\n\n### Files to edit\n- `lib/types.ts` — add createdAt to GraphLink interface\n- `lib/parse-beads.ts` — add createdAt to link mapping in buildGraphData()\n\n### What NOT to change\n- Do NOT change BeadDependency type (it already has created_at)\n- Do NOT change diff-beads.ts (link diffing uses linkKey which only considers source/target/type)\n- Do NOT change mergeBeadsData in page.tsx (it spreads link objects, so createdAt will be preserved)\n\n### Acceptance criteria\n- GraphLink.createdAt is optional string type\n- Links built from JSONL data carry their dependency creation timestamp\n- pnpm build passes\n- No runtime behavior changes (createdAt is informational until timeline feature uses it)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:47:41.589951+13:00","updatedAt":"2026-02-11T01:53:53.622113+13:00","closedAt":"2026-02-11T01:53:53.622113+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-21c.3","beads-map-21c.5"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.3","title":"Build timeline event extraction and time-filter logic (lib/timeline.ts)","description":"## Build timeline.ts: event extraction and time-filtering\n\n### What\nCreate a new file lib/timeline.ts with pure functions for:\n1. Extracting a sorted list of temporal events from graph data\n2. Filtering nodes/links to only show what exists at a given point in time\n\n### New file: lib/timeline.ts\n\n```typescript\nimport type { GraphNode, GraphLink } from \"./types\";\n\n// --- Types ---\n\nexport type TimelineEventType = \"node-created\" | \"node-closed\" | \"link-created\";\n\nexport interface TimelineEvent {\n time: number; // unix ms\n type: TimelineEventType;\n id: string; // node ID or link key (source->target)\n}\n\nexport interface TimelineRange {\n events: TimelineEvent[];\n minTime: number; // earliest event (unix ms)\n maxTime: number; // latest event (unix ms)\n}\n\n// --- Event extraction ---\n\n/**\n * Extract all temporal events from nodes and links, sorted chronologically.\n *\n * Events:\n * - node-created: from node.createdAt\n * - node-closed: from node.closedAt (if present)\n * - link-created: from link.createdAt (if present)\n *\n * Nodes/links missing timestamps are skipped.\n * Returns { events, minTime, maxTime }.\n */\nexport function buildTimelineEvents(\n nodes: GraphNode[],\n links: GraphLink[]\n): TimelineRange {\n const events: TimelineEvent[] = [];\n\n for (const node of nodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (!isNaN(createdMs)) {\n events.push({ time: createdMs, type: \"node-created\", id: node.id });\n }\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs)) {\n events.push({ time: closedMs, type: \"node-closed\", id: node.id });\n }\n }\n }\n\n for (const link of links) {\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs)) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n events.push({ time: linkMs, type: \"link-created\", id: `${src}->${tgt}` });\n }\n }\n }\n\n events.sort((a, b) => a.time - b.time);\n\n const times = events.map(e => e.time);\n const minTime = times.length > 0 ? times[0] : Date.now();\n const maxTime = times.length > 0 ? times[times.length - 1] : Date.now();\n\n return { events, minTime, maxTime };\n}\n\n// --- Time filtering ---\n\n/**\n * Filter nodes and links to only include items visible at `currentTime`.\n *\n * Node visibility: createdAt <= currentTime (parsed as Date).\n * Node status override: if closedAt && closedAt <= currentTime, force status to \"closed\".\n * Link visibility: both source and target nodes are visible AND link.createdAt <= currentTime.\n * If link has no createdAt, it appears when both endpoints are visible.\n *\n * Returns shallow copies of node objects with status potentially overridden.\n * Does NOT mutate input arrays.\n */\nexport function filterDataAtTime(\n allNodes: GraphNode[],\n allLinks: GraphLink[],\n currentTime: number\n): { nodes: GraphNode[]; links: GraphLink[] } {\n // Filter visible nodes\n const visibleNodeIds = new Set<string>();\n const nodes: GraphNode[] = [];\n\n for (const node of allNodes) {\n const createdMs = new Date(node.createdAt).getTime();\n if (isNaN(createdMs) || createdMs > currentTime) continue;\n\n visibleNodeIds.add(node.id);\n\n // Check if node should show as closed at this time\n let status = node.status;\n if (node.closedAt) {\n const closedMs = new Date(node.closedAt).getTime();\n if (!isNaN(closedMs) && closedMs <= currentTime) {\n status = \"closed\";\n } else if (node.status === \"closed\") {\n // Node is closed in current data but we're before closedAt — show as open\n status = \"open\";\n }\n } else if (node.status === \"closed\") {\n // Closed but no closedAt timestamp — show as closed always (legacy data)\n status = \"closed\";\n }\n\n // Shallow copy with potentially overridden status\n if (status !== node.status) {\n nodes.push({ ...node, status } as GraphNode);\n } else {\n nodes.push(node);\n }\n }\n\n // Filter visible links\n const links: GraphLink[] = [];\n for (const link of allLinks) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n\n // Both endpoints must be visible\n if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n\n // If link has createdAt, check it\n if (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n }\n\n links.push(link);\n }\n\n return { nodes, links };\n}\n```\n\n### Key design decisions\n- Pure functions, no React, no side effects — easy to test\n- filterDataAtTime returns shallow copies when status is overridden, original objects when not (preserves x/y positions from force simulation)\n- Link source/target can be string or object (force-graph mutates these) — handle both\n- Links without createdAt appear as soon as both endpoints are visible (graceful fallback)\n- For nodes that are \"closed\" in current data but we're scrubbing to before closedAt, we show them as \"open\"\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt must exist in the type)\n\n### Files to create\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- buildTimelineEvents extracts events from nodes and links, sorted by time\n- filterDataAtTime correctly shows only nodes/links that exist at a given time\n- Closed nodes appear as \"open\" when scrubbing before their closedAt\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:48:09.026383+13:00","updatedAt":"2026-02-11T01:54:26.759387+13:00","closedAt":"2026-02-11T01:54:26.759387+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-21c.5"],"dependentIds":["beads-map-21c","beads-map-21c.2"]},{"id":"beads-map-21c.4","title":"Create TimelineBar component with play/pause, scrubber, speed controls","description":"## TimelineBar component\n\n### What\nA horizontal playback bar positioned at the bottom-right of the graph area. Replaces the current floating legend hint when timeline mode is active. Contains play/pause, a scrubber slider, date/time display, and speed toggle.\n\n### Layout & positioning\nThe TimelineBar replaces the existing floating legend hint (currently at bottom-4 right-4 z-10 in BeadsGraph.tsx lines 1227-1234):\n```tsx\n{!selectedNode && !hoveredNode && (\n <div className=\"absolute bottom-4 right-4 z-10 text-xs text-zinc-400 bg-white/90 ...\">\n Node size = dependency importance | Color = status | Ring = project\n </div>\n)}\n```\n\nThe TimelineBar should be rendered in the same position: `absolute bottom-4 right-4 z-10` with similar styling (`bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm`). It should NOT overlap with the minimap (bottom-4 left-4, 160x120px).\n\n### New file: components/TimelineBar.tsx\n\nProps interface:\n```typescript\ninterface TimelineBarProps {\n minTime: number; // earliest event timestamp (unix ms)\n maxTime: number; // latest event timestamp (unix ms)\n currentTime: number; // current playback position (unix ms)\n isPlaying: boolean;\n speed: number; // 1, 2, or 4\n onTimeChange: (time: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\n### Visual design\n```\n┌──────────────────────────────────────────────────────────┐\n│ ▶ ──────────────●────────────────── Feb 10, 2026 2x │\n└──────────────────────────────────────────────────────────┘\n```\n\nElements left to right:\n1. **Play/Pause button**: SVG icon, toggle between play (triangle) and pause (two bars). Size: w-6 h-6. Color: emerald-500 when playing, zinc-500 when paused.\n2. **Scrubber slider**: HTML `<input type=\"range\">` styled with Tailwind. Min=minTime, max=maxTime, value=currentTime, step=1. Full width (flex-1). Track: h-1 bg-zinc-200 rounded. Thumb: w-3 h-3 bg-emerald-500 rounded-full. Filled portion: emerald-500.\n3. **Current date/time label**: Shows the date at the scrubber position. Format: \"Feb 10, 2026\" (compact). Font: text-xs text-zinc-500 font-medium. Fixed width to prevent layout shift (~100px).\n4. **Speed button**: Cycles through 1x -> 2x -> 4x -> 1x on click. Shows current speed as text. Font: text-xs font-medium. Color: emerald-500 background pill when not 1x, zinc border when 1x. Style: same pill as layout buttons.\n\n### Styling\n- Container: `bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2`\n- Width: auto-sized, roughly 300-400px, using `min-w-[300px] max-w-[480px]`\n- Height: compact, ~40px\n- Flex row layout: `flex items-center gap-2`\n- On mobile (sm:hidden for the full bar, show just play/pause + date)\n\n### Range input custom styling\nUse CSS in globals.css or inline styles to customize the range slider:\n```css\n/* In globals.css */\n.timeline-slider::-webkit-slider-track {\n height: 4px;\n background: #e4e4e7; /* zinc-200 */\n border-radius: 2px;\n}\n.timeline-slider::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n background: #10b981; /* emerald-500 */\n border-radius: 50%;\n margin-top: -4px;\n cursor: pointer;\n}\n```\n\n### Date formatting\n```typescript\nfunction formatTimelineDate(ms: number): string {\n const d = new Date(ms);\n return d.toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n}\n```\n\n### Play/Pause SVG icons\nPlay icon (triangle pointing right):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M4 2l10 6-10 6V2z\" />\n</svg>\n```\n\nPause icon (two vertical bars):\n```tsx\n<svg className=\"w-4 h-4\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <rect x=\"3\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n <rect x=\"9.5\" y=\"2\" width=\"3.5\" height=\"12\" rx=\"1\" />\n</svg>\n```\n\n### Interaction behavior\n- Dragging the slider calls onTimeChange(newTime) continuously\n- The component does NOT manage the rAF playback loop — that lives in page.tsx\n- Clicking speed cycles: 1 -> 2 -> 4 -> 1\n- The component is purely controlled (all state via props)\n\n### Files to create\n- `components/TimelineBar.tsx`\n\n### Files to edit\n- `app/globals.css` — add .timeline-slider custom range input styles\n\n### Acceptance criteria\n- TimelineBar renders play/pause button, slider, date label, speed toggle\n- Slider responds to drag, calls onTimeChange\n- Play/pause button calls onPlayPause\n- Speed button cycles through 1x/2x/4x\n- Matches existing UI style (white/90, backdrop-blur, rounded-lg, zinc borders)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:48:40.960221+13:00","updatedAt":"2026-02-11T01:53:53.797119+13:00","closedAt":"2026-02-11T01:53:53.797119+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.5"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.5","title":"Wire timeline into page.tsx and BeadsGraph: state, filtering, animations, pill button","description":"## Wire timeline into page.tsx and BeadsGraph\n\n### What\nThis is the integration task. Connect the timeline data layer (lib/timeline.ts), the TimelineBar component, and the existing graph rendering. Add a pill button to toggle timeline mode, manage playback state, and filter nodes/links based on the virtual clock.\n\n### Overview of changes\n\n**page.tsx** — Add state, pill button, rAF playback loop, filtering, and TimelineBar wiring\n**BeadsGraph.tsx** — Accept optional timeline props, conditionally render TimelineBar instead of legend hint\n\n---\n\n### Change 1: page.tsx — New imports\n\nAdd at top of file:\n```typescript\nimport { buildTimelineEvents, filterDataAtTime } from \"@/lib/timeline\";\nimport type { TimelineRange } from \"@/lib/timeline\";\nimport TimelineBar from \"@/components/TimelineBar\";\n```\n\n### Change 2: page.tsx — New state variables\n\nAdd after existing state declarations (around line 190):\n```typescript\n// Timeline replay state\nconst [timelineActive, setTimelineActive] = useState(false);\nconst [timelineTime, setTimelineTime] = useState(0); // current virtual clock (unix ms)\nconst [timelinePlaying, setTimelinePlaying] = useState(false);\nconst [timelineSpeed, setTimelineSpeed] = useState(1); // 1x, 2x, 4x\n```\n\n### Change 3: page.tsx — Compute timeline range\n\nAdd a useMemo that computes the timeline event range from the full data. This MUST be computed from the full (unfiltered) data set:\n\n```typescript\nconst timelineRange = useMemo<TimelineRange | null>(() => {\n if (!data) return null;\n return buildTimelineEvents(data.graphData.nodes, data.graphData.links);\n}, [data]);\n```\n\n### Change 4: page.tsx — Initialize timelineTime when activating\n\nWhen timeline mode is activated, set timelineTime to minTime:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next && timelineRange) {\n setTimelineTime(timelineRange.minTime);\n setTimelinePlaying(false);\n }\n if (!next) {\n setTimelinePlaying(false);\n }\n return next;\n });\n}, [timelineRange]);\n```\n\n### Change 5: page.tsx — rAF playback loop\n\nAdd a useEffect that advances timelineTime when playing. Speed mapping: 1x = 1 real second advances 1 calendar day of project time. So:\n- msPerFrame = (1000/60) * speed * (86400000 / 1000) = speed * 1440000 / 60 = speed * 24000 per frame at 60fps\n\nActually simpler: track last rAF timestamp, compute real elapsed ms, multiply by speed factor:\n- 1x: 1 real second = 1 day (86400000ms) of project time -> factor = 86400\n- 2x: factor = 172800\n- 4x: factor = 345600\n\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n\n let rafId: number;\n let lastTs: number | null = null;\n const factor = timelineSpeed * 86400; // 1 real ms = factor project ms\n\n function tick(ts: number) {\n if (lastTs !== null) {\n const realElapsed = ts - lastTs;\n setTimelineTime(prev => {\n const next = prev + realElapsed * factor;\n if (next >= timelineRange!.maxTime) {\n setTimelinePlaying(false);\n return timelineRange!.maxTime;\n }\n return next;\n });\n }\n lastTs = ts;\n rafId = requestAnimationFrame(tick);\n }\n\n rafId = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(rafId);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\n### Change 6: page.tsx — Filter data for timeline mode\n\nCompute the filtered nodes/links using a useMemo. This is what gets passed to BeadsGraph when timeline is active:\n\n```typescript\nconst timelineFilteredData = useMemo(() => {\n if (!timelineActive || !data) return null;\n return filterDataAtTime(\n data.graphData.nodes,\n data.graphData.links,\n timelineTime\n );\n}, [timelineActive, data, timelineTime]);\n```\n\n### Change 7: page.tsx — Stamp _spawnTime on newly visible nodes\n\nTo get pop-in animations as nodes appear during playback, track previously visible node IDs and stamp _spawnTime on new ones:\n\n```typescript\nconst prevTimelineNodeIdsRef = useRef<Set<string>>(new Set());\n\nconst timelineNodes = useMemo(() => {\n if (!timelineFilteredData) return null;\n const prevIds = prevTimelineNodeIdsRef.current;\n const now = Date.now();\n const nodes = timelineFilteredData.nodes.map(node => {\n if (!prevIds.has(node.id)) {\n return { ...node, _spawnTime: now } as GraphNode;\n }\n return node;\n });\n // Update prev set for next frame\n prevTimelineNodeIdsRef.current = new Set(timelineFilteredData.nodes.map(n => n.id));\n return nodes;\n}, [timelineFilteredData]);\n\nconst timelineLinks = useMemo(() => {\n if (!timelineFilteredData) return null;\n return timelineFilteredData.links;\n}, [timelineFilteredData]);\n```\n\n**IMPORTANT**: This runs in useMemo which is a pure computation. The ref update inside useMemo is a known pattern but impure. An alternative: use useEffect to update the ref. Choose whichever approach doesn't cause visual glitches. If useMemo causes double-stamping in StrictMode, move to useEffect with a separate state.\n\n### Change 8: page.tsx — Pass filtered or full data to BeadsGraph\n\nCurrently (line 863-864):\n```tsx\n<BeadsGraph\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n```\n\nChange to:\n```tsx\n<BeadsGraph\n nodes={timelineActive && timelineNodes ? timelineNodes : data.graphData.nodes}\n links={timelineActive && timelineLinks ? timelineLinks : data.graphData.links}\n```\n\n### Change 9: page.tsx — Timeline pill button in header\n\nAdd a pill button next to the Comments pill (before the `<span className=\"w-px h-4 bg-zinc-200\" />` separator before AuthButton). Same styling as Comments/layout pills:\n\n```tsx\n<span className=\"w-px h-4 bg-zinc-200\" />\n{/* Timeline pill */}\n<div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\">\n <button\n onClick={handleTimelineToggle}\n className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n timelineActive\n ? \"bg-emerald-500 text-white\"\n : \"text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n >\n <span className=\"flex items-center gap-1.5\">\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n {/* Clock/replay icon */}\n <circle cx=\"8\" cy=\"8\" r=\"6\" />\n <polyline points=\"8,4 8,8 11,10\" />\n </svg>\n <span className=\"hidden sm:inline\">Replay</span>\n </span>\n </button>\n</div>\n```\n\nPlace this BEFORE the Comments pill separator.\n\n### Change 10: page.tsx — Render TimelineBar\n\nAdd TimelineBar inside the graph area div (after BeadsGraph, before the closing </div> of the graph area). It should render only when timeline is active:\n\n```tsx\n{timelineActive && timelineRange && (\n <div className=\"absolute bottom-4 right-4 z-10\">\n <TimelineBar\n minTime={timelineRange.minTime}\n maxTime={timelineRange.maxTime}\n currentTime={timelineTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onTimeChange={setTimelineTime}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n />\n </div>\n)}\n```\n\n### Change 11: BeadsGraph.tsx — Hide legend hint when timeline is active\n\nAdd a new prop to BeadsGraphProps:\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n timelineActive?: boolean; // <-- ADD THIS\n}\n```\n\nChange the legend hint conditional (lines 1227-1234) from:\n```tsx\n{!selectedNode && !hoveredNode && (\n```\nto:\n```tsx\n{!selectedNode && !hoveredNode && !timelineActiveRef.current && (\n```\n\nAdd a ref for timelineActive (same pattern as selectedNodeRef etc):\n```typescript\nconst timelineActiveRef = useRef(false);\nuseEffect(() => { timelineActiveRef.current = timelineActive ?? false; }, [timelineActive]);\n```\n\nWait — actually the legend hint is in the JSX return, not in paintNode, so we can use the prop directly:\n```tsx\n{!selectedNode && !hoveredNode && !props.timelineActive && (\n```\n\nBut BeadsGraph destructures props at the top. Add `timelineActive` to the destructured props and use it directly in the JSX conditional. No ref needed for this since it's in JSX, not in a useCallback.\n\n### Change 12: page.tsx — Pass timelineActive to BeadsGraph\n\n```tsx\n<BeadsGraph\n ...\n timelineActive={timelineActive} // <-- ADD THIS\n/>\n```\n\n### Summary of files to edit\n- `app/page.tsx` — state, imports, memos, pill button, TimelineBar render, data filtering\n- `components/BeadsGraph.tsx` — timelineActive prop, hide legend when active\n\n### Depends on\n- beads-map-21c.2 (GraphLink.createdAt)\n- beads-map-21c.3 (lib/timeline.ts)\n- beads-map-21c.4 (TimelineBar component)\n\n### Acceptance criteria\n- \"Replay\" pill button in header toggles timeline mode\n- When active, graph shows only nodes/links that exist at the virtual clock time\n- Pressing play animates nodes appearing over time with pop-in animations\n- Scrubbing the slider immediately updates visible nodes\n- Speed toggle cycles 1x/2x/4x\n- Legend hint hidden when timeline is active (TimelineBar replaces it)\n- When timeline is deactivated, full live data is shown again\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:49:28.829389+13:00","updatedAt":"2026-02-11T01:56:10.694555+13:00","closedAt":"2026-02-11T01:56:10.694555+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":4,"blockerIds":["beads-map-21c.6"],"dependentIds":["beads-map-21c","beads-map-21c.2","beads-map-21c.3","beads-map-21c.4"]},{"id":"beads-map-21c.6","title":"Build verification, edge cases, and polish for timeline replay","description":"## Build verification and polish\n\n### What\nFinal task: verify the build passes, test edge cases, and polish any rough edges.\n\n### Build gate\n```bash\npnpm build\n```\nMust pass with zero errors. If there are type errors, fix them.\n\n### Edge cases to verify\n\n1. **Empty graph**: If no nodes have timestamps, timeline should gracefully handle minTime === maxTime (slider disabled or shows single point)\n2. **Single node**: Timeline with one node should still work (slider shows one point in time)\n3. **All nodes already closed**: Scrubbing to maxTime should show all nodes as closed\n4. **Scrubbing backward**: Moving slider left should remove nodes (they should just disappear, no exit animation needed for scrub-back — only forward playback gets spawn animations)\n5. **Rapid scrubbing**: Fast slider movement should not cause performance issues. The filterDataAtTime function should be fast (O(n) where n = total nodes + links)\n6. **Toggle off during playback**: Turning off timeline while playing should stop playback and restore full data\n7. **Node selection during timeline**: Clicking a node during timeline playback should work normally (open NodeDetail sidebar)\n8. **Comments during timeline**: Comment badges should still work on visible nodes (commentedNodeIds filtering still applies)\n\n### Polish items\n- Ensure TimelineBar doesn't overlap with minimap on small screens\n- Verify the rAF loop cleans up properly on unmount\n- Check that prevTimelineNodeIdsRef resets when timeline is deactivated\n- Verify nodes retain their force-simulation positions when switching between timeline and live mode (x/y should be preserved since we're using the same node objects from data.graphData.nodes)\n\n### Stale .next cache\nIf you see module resolution errors, run:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Files potentially needing fixes\n- `app/page.tsx`\n- `components/BeadsGraph.tsx`\n- `components/TimelineBar.tsx`\n- `lib/timeline.ts`\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- All edge cases handled gracefully\n- No console errors during timeline playback\n- Clean git status after commit","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:49:44.214947+13:00","updatedAt":"2026-02-11T01:56:40.00756+13:00","closedAt":"2026-02-11T01:56:40.00756+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.5"]},{"id":"beads-map-21c.7","title":"Rewrite timeline to event-step playback using diff/merge pipeline","description":"## Rewrite timeline to event-step playback using diff/merge pipeline\n\n### Problem\nTwo bugs in the current timeline replay:\n1. **Too fast**: rAF loop maps real time to project time (1 sec = 1 day), so months of history play in seconds\n2. **Graph mess**: Timeline bypasses the diff/merge pipeline, so nodes appear without x/y positions and the force simulation doesn't organize them. Links and nodes are disconnected.\n\n### Root cause\nThe current timeline creates a parallel data path: filterDataAtTime() returns raw nodes (no x/y), stamps _spawnTime manually in useMemo, and passes them directly to BeadsGraph. This skips:\n- mergeBeadsData() which preserves x/y from old nodes and places new ones near neighbors\n- diffBeadsData() which detects added/removed/changed for proper animation stamps\n- The force simulation reheat that react-force-graph does when graphData changes\n\n### Fix: event-step model + diff/merge pipeline\n\n**Playback model change:**\n- Replace continuous time scrubber with discrete event steps\n- Each step = one event from the sorted events array\n- Playback advances one step every 5 seconds (at 1x), giving force simulation time to settle\n- Speed: 1x = 5s/step, 2x = 2.5s/step, 4x = 1.25s/step\n- Slider maps to step index (0 to events.length-1), not unix timestamps\n\n**Data pipeline change:**\n- Maintain `timelineData: BeadsApiResponse | null` state (the \"current timeline snapshot\")\n- On each step change:\n 1. Get timestamp from events[step].time\n 2. Call filterDataAtTime(allNodes, allLinks, timestamp) to get visible nodes/links\n 3. Wrap as BeadsApiResponse-shaped object\n 4. Call diffBeadsData(prevTimelineData, newSnapshot) to get the diff\n 5. Call mergeBeadsData(prevTimelineData, newSnapshot, diff) to get positioned + animated data\n 6. Set timelineData = merged result\n- Pass timelineData.graphData.nodes/.links to BeadsGraph\n- Force simulation naturally reheats when the node/link arrays change\n\n### Files to edit\n\n**app/page.tsx** — The big one. Replace the entire timeline section (lines ~196-410):\n\nState changes:\n- REMOVE: timelineTime (unix ms)\n- REMOVE: prevTimelineNodeIdsRef\n- ADD: timelineStep (number, 0-based index into events array)\n- ADD: timelineData (BeadsApiResponse | null)\n\nRemove these memos/computations:\n- timelineFilteredData useMemo\n- timelineNodes useMemo\n- timelineLinks useMemo\n\nReplace rAF playback loop with setInterval:\n```typescript\nuseEffect(() => {\n if (!timelinePlaying || !timelineActive || !timelineRange) return;\n const intervalMs = 5000 / timelineSpeed;\n const interval = setInterval(() => {\n setTimelineStep(prev => {\n const next = prev + 1;\n if (next >= timelineRange.events.length) {\n setTimelinePlaying(false);\n return prev;\n }\n return next;\n });\n }, intervalMs);\n return () => clearInterval(interval);\n}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);\n```\n\nAdd effect to compute timelineData when step changes:\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange || timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n\n const filtered = filterDataAtTime(data.graphData.nodes, data.graphData.links, event.time);\n const newSnapshot: BeadsApiResponse = {\n ...data,\n graphData: { nodes: filtered.nodes, links: filtered.links },\n };\n\n setTimelineData(prev => {\n if (!prev) return newSnapshot; // first frame — no merge needed\n const diff = diffBeadsData(prev, newSnapshot);\n if (!diff.hasChanges) return prev;\n return mergeBeadsData(prev, newSnapshot, diff);\n });\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChange BeadsGraph props:\n```tsx\nnodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}\nlinks={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}\n```\n\nChange TimelineBar props:\n```tsx\n<TimelineBar\n totalSteps={timelineRange.events.length}\n currentStep={timelineStep}\n currentTime={timelineRange.events[timelineStep]?.time ?? timelineRange.minTime}\n isPlaying={timelinePlaying}\n speed={timelineSpeed}\n onStepChange={setTimelineStep}\n onPlayPause={() => setTimelinePlaying(prev => !prev)}\n onSpeedChange={setTimelineSpeed}\n/>\n```\n\nUpdate handleTimelineToggle:\n```typescript\nconst handleTimelineToggle = useCallback(() => {\n setTimelineActive(prev => {\n const next = !prev;\n if (next) {\n setTimelineStep(0);\n setTimelinePlaying(false);\n setTimelineData(null);\n } else {\n setTimelinePlaying(false);\n setTimelineData(null);\n }\n return next;\n });\n}, []);\n```\n\n**components/TimelineBar.tsx** — Change from time-based to step-based props:\n\nNew props:\n```typescript\ninterface TimelineBarProps {\n totalSteps: number; // events.length\n currentStep: number; // 0-based index\n currentTime: number; // unix ms of current event (for date display)\n isPlaying: boolean;\n speed: number; // 1, 2, 4\n onStepChange: (step: number) => void;\n onPlayPause: () => void;\n onSpeedChange: (speed: number) => void;\n}\n```\n\nSlider: min=0, max=totalSteps-1, value=currentStep, onChange calls onStepChange\nDate label: formatTimelineDate(currentTime)\nAdd step counter: \"3 / 47\" next to date\nhasRange = totalSteps > 1\n\n**lib/timeline.ts** — No changes needed. buildTimelineEvents and filterDataAtTime work as-is.\n\n**components/BeadsGraph.tsx** — No changes needed. Force simulation reheats naturally.\n\n### Depends on\nNothing new — this replaces parts of beads-map-21c.5\n\n### Acceptance criteria\n- Playing timeline advances one event at a time, 5 seconds between events at 1x\n- 2x = 2.5s between events, 4x = 1.25s between events\n- Nodes appear with pop-in animation and get properly positioned by force simulation\n- Links connect to their nodes correctly\n- Scrubbing the slider jumps between event steps\n- Graph layout matches the active layout mode (Force or DAG)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:00:49.845818+13:00","updatedAt":"2026-02-11T02:03:04.087128+13:00","closedAt":"2026-02-11T02:03:04.087128+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.8"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-21c.8","title":"Build verify and push timeline event-step rewrite","description":"## Build verify and push\n\nRun pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\npnpm build\ngit add -A\ngit commit -m \"Rewrite timeline to event-step playback with diff/merge pipeline (beads-map-21c.7)\"\nbd sync\ngit push\n```\n\n### Edge cases to check\n- Empty events array (no timestamps) — slider disabled, play disabled\n- Single event — slider shows one point\n- Scrubbing backward — nodes removed via diff/merge exit animation\n- Toggle off during playback — stops interval, clears timelineData\n- Speed change during playback — interval restarts with new timing\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:00:58.650469+13:00","updatedAt":"2026-02-11T02:03:27.972704+13:00","closedAt":"2026-02-11T02:03:27.972704+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-21c","beads-map-21c.7"]},{"id":"beads-map-21c.9","title":"Fix timeline: links appear with both nodes, empty preamble, 2s per event","description":"## Fix timeline: links appear with both nodes, empty preamble, 2s per event\n\n### Three issues to fix\n\n#### Issue 1: Links don't appear when both nodes are visible\n**Root cause:** filterDataAtTime() in lib/timeline.ts lines 148-151 checks link.createdAt independently:\n```typescript\nif (link.createdAt) {\n const linkMs = new Date(link.createdAt).getTime();\n if (!isNaN(linkMs) && linkMs > currentTime) continue;\n}\n```\nEven when both endpoints are on canvas, the link is hidden until its own timestamp.\n\n**Fix in lib/timeline.ts:** Remove the link.createdAt check entirely (lines 147-151). A link should appear the moment both endpoints are visible. The visibleNodeIds check on line 145 is sufficient:\n```typescript\n// Both endpoints must be visible\nif (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;\n// REMOVE the link.createdAt check below this\n```\n\nAlso remove link-created events from buildTimelineEvents() (lines 54-73) since link timing is now derived from node visibility, not link timestamps. This simplifies the event list to just node-created and node-closed.\n\n#### Issue 2: Zoom crash into first node on play\n**Root cause:** BeadsGraph.tsx line 432-440 has a zoomToFit effect:\n```typescript\nuseEffect(() => {\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n}, [nodes.length]);\n```\nWhen timeline starts at step 0 (1 node), this zooms to fit that single node = extreme zoom in.\n\n**Fix — two parts:**\n\n**Part A: Prevent zoomToFit during timeline mode.**\nThe timelineActive prop is already passed to BeadsGraph. Use it to skip the zoomToFit:\n```typescript\nuseEffect(() => {\n if (timelineActive) return; // skip during timeline replay\n if (graphRef.current && nodes.length > 0) {\n ...\n }\n}, [nodes.length, timelineActive]);\n```\n\n**Part B: Add 2-second empty preamble before first event.**\nIn the playback setInterval in page.tsx, when play starts and timelineStep is -1 (a new \"preamble\" step), show zero nodes for 2 seconds, then advance to step 0.\n\nImplementation approach: Use step index -1 as the preamble. When timeline is activated or play starts from the beginning, set step to -1. The effect that computes timelineData should check: if step === -1, set timelineData to an empty snapshot (no nodes, no links). The setInterval advances from -1 to 0, then 0 to 1, etc.\n\nChanges in app/page.tsx:\n- handleTimelineToggle: setTimelineStep(-1) instead of 0\n- The setInterval already does prev + 1, so -1 + 1 = 0 (first real event). Works naturally.\n- The timelineData effect: add check for timelineStep === -1 -> empty snapshot\n- TimelineBar: totalSteps stays as events.length (preamble is \"step -1\", not counted in steps)\n- TimelineBar slider: min stays 0, but current step shows as 0 when preamble is active\n\nChanges in page.tsx effect that computes timelineData (lines 367-389):\n```typescript\nuseEffect(() => {\n if (!timelineActive || !data || !timelineRange) return;\n \n // Preamble step: empty canvas\n if (timelineStep === -1) {\n setTimelineData({\n ...data,\n graphData: { nodes: [], links: [] },\n });\n return;\n }\n \n if (timelineRange.events.length === 0) return;\n const event = timelineRange.events[timelineStep];\n if (!event) return;\n // ... rest of diff/merge logic\n}, [timelineActive, data, timelineRange, timelineStep]);\n```\n\nChanges in page.tsx TimelineBar rendering:\n```tsx\ncurrentStep={Math.max(timelineStep, 0)}\ncurrentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}\n```\n\n#### Issue 3: 5 seconds per event is too slow\n**Fix in app/page.tsx line 353:** Change 5000 to 2000:\n```typescript\nconst intervalMs = 2000 / timelineSpeed;\n```\nThis gives 2s per event at 1x, 1s at 2x, 0.5s at 4x.\n\n### Files to edit\n- lib/timeline.ts — remove link.createdAt check in filterDataAtTime, remove link-created events from buildTimelineEvents\n- app/page.tsx — step -1 preamble, 2s interval, TimelineBar prop adjustments \n- components/BeadsGraph.tsx — skip zoomToFit during timeline mode\n\n### Also remove link-created from TimelineEventType\nSince links no longer have their own timeline events, simplify:\n- TimelineEventType becomes \"node-created\" | \"node-closed\" (remove \"link-created\")\n- buildTimelineEvents() removes the link loop (lines 54-73)\n\n### Acceptance criteria\n- Links appear the instant both connected nodes are on canvas\n- Pressing play shows empty canvas for 2 seconds (preamble), then first node appears\n- Each event takes 2 seconds at 1x speed\n- No zoom-crash into a single node when timeline starts\n- Scrubbing slider still works (slider min=0, preamble is before slider range)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T02:07:25.357205+13:00","updatedAt":"2026-02-11T02:09:24.013992+13:00","closedAt":"2026-02-11T02:09:24.013992+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-21c.10"],"dependentIds":["beads-map-21c"]},{"id":"beads-map-2fk","title":"Create lib/diff-beads.ts — diff engine for nodes and links","description":"Create a new file: lib/diff-beads.ts\n\nPURPOSE: Compare two BeadsApiResponse objects and identify what changed — which nodes/links were added, removed, or modified. The diff output drives animation metadata stamping in the merge logic (task .5).\n\nINTERFACE:\n\n```typescript\nimport type { BeadsApiResponse, GraphNode, GraphLink } from \"./types\";\n\nexport interface NodeChange {\n field: string; // e.g. \"status\", \"priority\", \"title\"\n from: string; // previous value (stringified)\n to: string; // new value (stringified)\n}\n\nexport interface BeadsDiff {\n addedNodeIds: Set<string>; // IDs of nodes not in old data\n removedNodeIds: Set<string>; // IDs of nodes not in new data\n changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes\n addedLinkKeys: Set<string>; // \"source->target:type\" keys\n removedLinkKeys: Set<string>; // \"source->target:type\" keys\n hasChanges: boolean; // true if anything changed at all\n}\n\n/**\n * Build a stable key for a link.\n * Links may have string or object source/target (after force-graph mutation).\n */\nexport function linkKey(link: GraphLink): string;\n\n/**\n * Compute the diff between old and new beads data.\n * Compares nodes by ID and links by source->target:type key.\n */\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff;\n```\n\nIMPLEMENTATION:\n\n```typescript\nexport function linkKey(link: GraphLink): string {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n return `${src}->${tgt}:${link.type}`;\n}\n\nexport function diffBeadsData(\n oldData: BeadsApiResponse | null,\n newData: BeadsApiResponse\n): BeadsDiff {\n // If no old data, everything is \"added\"\n if (!oldData) {\n return {\n addedNodeIds: new Set(newData.graphData.nodes.map(n => n.id)),\n removedNodeIds: new Set(),\n changedNodes: new Map(),\n addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),\n removedLinkKeys: new Set(),\n hasChanges: true,\n };\n }\n\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const newNodeMap = new Map(newData.graphData.nodes.map(n => [n.id, n]));\n\n // Node diffs\n const addedNodeIds = new Set<string>();\n const removedNodeIds = new Set<string>();\n const changedNodes = new Map<string, NodeChange[]>();\n\n for (const [id, node] of newNodeMap) {\n if (!oldNodeMap.has(id)) {\n addedNodeIds.add(id);\n } else {\n const old = oldNodeMap.get(id)!;\n const changes: NodeChange[] = [];\n if (old.status !== node.status) {\n changes.push({ field: \"status\", from: old.status, to: node.status });\n }\n if (old.priority !== node.priority) {\n changes.push({ field: \"priority\", from: String(old.priority), to: String(node.priority) });\n }\n if (old.title !== node.title) {\n changes.push({ field: \"title\", from: old.title, to: node.title });\n }\n if (changes.length > 0) {\n changedNodes.set(id, changes);\n }\n }\n }\n for (const id of oldNodeMap.keys()) {\n if (!newNodeMap.has(id)) {\n removedNodeIds.add(id);\n }\n }\n\n // Link diffs\n const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));\n const newLinkKeys = new Set(newData.graphData.links.map(linkKey));\n\n const addedLinkKeys = new Set<string>();\n const removedLinkKeys = new Set<string>();\n\n for (const key of newLinkKeys) {\n if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);\n }\n for (const key of oldLinkKeys) {\n if (!newLinkKeys.has(key)) removedLinkKeys.add(key);\n }\n\n const hasChanges =\n addedNodeIds.size > 0 ||\n removedNodeIds.size > 0 ||\n changedNodes.size > 0 ||\n addedLinkKeys.size > 0 ||\n removedLinkKeys.size > 0;\n\n return { addedNodeIds, removedNodeIds, changedNodes, addedLinkKeys, removedLinkKeys, hasChanges };\n}\n```\n\nWHY linkKey() HANDLES OBJECTS:\nreact-force-graph-2d mutates link.source and link.target from string IDs to node objects during simulation. When we compare old links (which have been mutated) against new links (which have string IDs from the server), we need to handle both cases.\n\nDEPENDS ON: task .1 (animation timestamp types in GraphNode/GraphLink)\n\nACCEPTANCE CRITERIA:\n- lib/diff-beads.ts exports diffBeadsData and linkKey\n- Correctly identifies added/removed/changed nodes\n- Correctly identifies added/removed links\n- Handles null oldData (initial load — everything is \"added\")\n- Handles object-form source/target in links (post-simulation mutation)\n- hasChanges is false when data is identical\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:16:20.792858+13:00","updatedAt":"2026-02-10T23:25:49.501958+13:00","closedAt":"2026-02-10T23:25:49.501958+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-ecl"],"dependentIds":["beads-map-3jy","beads-map-gjo"]},{"id":"beads-map-2qg","title":"Integration testing — live update end-to-end verification","description":"Final verification that the live update system works end-to-end with all animations.\n\nSETUP:\n Terminal 1: cd to any beads project (e.g. ~/Projects/gainforest/gainforest-beads)\n Terminal 2: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev (in beads-map)\n Browser: http://localhost:3000\n\nTEST MATRIX:\n\n1. NEW NODE (bd create):\n Terminal 1: bd create --title \"Live test node\" --priority 2\n Browser: Within ~300ms, a new node should POP IN with:\n - Scale animation from 0 to 1 (bouncy easeOutBack)\n - Brief green glow ring around it\n - Node placed near its connected neighbors (or at random if standalone)\n - Force simulation gently incorporates it into the layout\n VERIFY: No graph position reset, existing nodes stay where they are\n\n2. STATUS CHANGE (bd update):\n Terminal 1: bd update <id-from-step-1> --status in_progress\n Browser: The node should show:\n - Expanding ripple ring in amber (in_progress color)\n - Node body color transitions from emerald (open) to amber\n - Ripple fades out over ~800ms\n VERIFY: No position change, other nodes unaffected\n\n3. NEW LINK (bd link):\n Terminal 1: bd link <id1> blocks <id2>\n Browser: A new link should appear:\n - Fades in over 500ms\n - Brief emerald flash along the link path (300ms)\n - Starts thicker, settles to normal width\n - Flow particles appear on it\n VERIFY: Both endpoints stay in position\n\n4. CLOSE ISSUE (bd close):\n Terminal 1: bd close <id-from-step-1>\n Browser: The node should SHRINK OUT:\n - Scale animation from 1 to 0 (400ms)\n - Opacity fades to 0\n - Connected links also fade out\n - After ~600ms, the ghost node/links are removed from the array\n VERIFY: Stats update (total count decreases)\n\n5. RAPID CHANGES (debounce test):\n Terminal 1: for i in 1 2 3 4 5; do bd create --title \"Rapid $i\" --priority 3; done\n Browser: Nodes should NOT pop in one-by-one with 300ms delays. They should all appear in a single batch after the debounce settles (~300ms after the last command).\n VERIFY: All 5 nodes spawn simultaneously with pop-in animations\n\n6. MULTI-REPO (if using gainforest-beads hub):\n Terminal 1: cd ../audiogoat && bd create --title \"Cross-repo test\" --priority 3\n Browser: The new audiogoat node should appear in the graph\n VERIFY: Node has audiogoat prefix color ring\n\n7. RECONNECTION:\n Stop and restart the dev server.\n Browser: EventSource should auto-reconnect and load fresh data.\n VERIFY: No stale data, no duplicate nodes\n\n8. EPIC COLLAPSE VIEW:\n Switch to \"Epics\" view mode, then create a child task.\n Terminal 1: bd create --title \"Child of epic\" --priority 2 --parent <epic-id>\n Browser: In Epics mode, the parent epic node should update:\n - Child count badge increments\n - Epic node briefly flashes (change animation)\n - No child node appears (it's collapsed)\n Switch to Full mode: child node should be visible (already in data)\n\n9. BUILD CHECK:\n pnpm build — must pass with zero errors\n\n10. CLEANUP:\n Delete test issues: bd delete <id> for each test issue created\n Browser: Nodes shrink out on deletion\n\nFUNCTIONAL CHECKS:\n- Force/DAG layout toggle still works during/after animations\n- Full/Epics toggle still works\n- Search still finds nodes (including newly spawned ones)\n- Minimap updates with new nodes\n- Click node -> sidebar shows correct data (including newly added nodes)\n- Header stats update in real-time (issue count, dep count, project count)\n- No memory leaks (EventSource properly cleaned up on page navigation)\n- No console errors during any test\n\nPERFORMANCE CHECKS:\n- Animation frame rate stays smooth (60fps) during spawn/exit\n- No jitter or \"graph explosion\" when new data merges\n- File watcher doesn't cause excessive CPU usage during idle\n\nDEPENDS ON: All previous tasks (.1-.7) must be complete\n\nACCEPTANCE CRITERIA:\n- All 10 test scenarios pass\n- All functional checks pass\n- All performance checks pass\n- pnpm build clean\n- No console errors","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:18:51.905378+13:00","updatedAt":"2026-02-10T23:40:49.415522+13:00","closedAt":"2026-02-10T23:40:49.415522+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-3jy","beads-map-mq9"]},{"id":"beads-map-2u2","title":"Unify all prefix colors to Catppuccin palette (rings, clusters, tooltip)","description":"## What (retroactive — already done)\n\nUpdated all prefix-colored elements in the app to consistently use the Catppuccin accent palette instead of the old FNV-hash HSL colors. This ensures visual consistency: the node outer ring, cluster background circles (zoomed-out view), and hover tooltip accent bar all use the same deterministic Catppuccin color for a given prefix.\n\n## Commits\n- 31ae0c7 — Match cluster circle color to Catppuccin prefix palette when in prefix color mode\n- 13e5bc8 — Use Catppuccin prefix colors for node rings, cluster circles, and tooltip accent bar\n\n## Changes\n\n### components/BeadsGraph.tsx\n- **getPrefixColor → getPrefixRingColor**: Renamed the module-level function and changed it to call \\`getCatppuccinPrefixColor(node.prefix)\\` instead of looking up \\`PREFIX_COLORS[node.prefix]\\` (old FNV-hash HSL).\n- **Cluster circle color** (line ~1393 in paintClusterLabels): Changed from \\`PREFIX_COLORS[cluster.prefix]\\` to \\`getCatppuccinPrefixColor(cluster.prefix)\\`. Always uses Catppuccin regardless of color mode since clusters always represent projects.\n- **Removed PREFIX_COLORS import** from BeadsGraph.tsx (no longer needed there).\n\n### app/page.tsx\n- **Tooltip accent bar** (line ~1388): Changed from \\`getPrefixColor(nodeTooltip.node.prefix)\\` to \\`getCatppuccinPrefixColor(nodeTooltip.node.prefix)\\`.\n- Updated import: replaced \\`getPrefixColor\\` with \\`getCatppuccinPrefixColor\\` from \\`@/lib/types\\`.\n\n## Result\nAll prefix-colored elements now use the same 14-color Catppuccin palette via \\`getCatppuccinPrefixColor()\\`, ensuring a given project prefix always maps to the same color across rings, clusters, and tooltips.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T14:22:02.396229+13:00","updatedAt":"2026-02-12T14:22:50.425852+13:00","closedAt":"2026-02-12T14:22:50.425852+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":0,"blockerIds":["beads-map-f8f"],"dependentIds":[]},{"id":"beads-map-3jy","title":"Live updates via SSE with animated node/link transitions","description":"Add real-time live updates to beads-map so that when .beads/issues.jsonl changes on disk (via bd create, bd close, bd link, bd update, etc.), the graph automatically updates with smooth animations — new nodes pop in, removed nodes shrink out, status changes flash, and new links fade in.\n\nARCHITECTURE:\n- Server: New SSE endpoint (/api/beads/stream) watches all JSONL files with fs.watch()\n- Server: On file change, re-parses all data and pushes the full dataset over SSE\n- Client: EventSource in page.tsx receives updates, diffs against current state\n- Client: Diff metadata (added/removed/changed) drives animations in paintNode/paintLink\n- Animations: spawn pop-in (easeOutBack), exit shrink-out, status change ripple, link fade-in\n\nKEY DESIGN DECISIONS:\n1. SSE over polling: true push, instant updates, no wasted requests\n2. Full data push (not incremental diffs): simpler, avoids sync issues, JSONL files are small\n3. Debounce 300ms: bd often writes multiple times per command (flush + sync)\n4. Position preservation: merge new data while keeping existing node x/y/fx/fy positions\n5. Animation via timestamps: stamp _spawnTime/_removeTime/_changedAt on items, animate in paintNode/paintLink based on elapsed time\n\nFILES TO CREATE:\n- lib/watch-beads.ts — file watcher utility wrapping fs.watch with debounce\n- lib/diff-beads.ts — diff engine comparing old vs new BeadsApiResponse\n- app/api/beads/stream/route.ts — SSE endpoint\n\nFILES TO MODIFY:\n- lib/parse-beads.ts — export getAdditionalRepoPaths (currently private)\n- lib/types.ts — add animation timestamp fields to GraphNode/GraphLink\n- app/page.tsx — replace one-shot fetch with EventSource + merge logic\n- components/BeadsGraph.tsx — spawn/exit/change animations in paintNode + paintLink\n\nDEPENDENCY CHAIN:\n.1 (types + parse-beads exports) → .2 (watch-beads.ts) → .3 (SSE endpoint) → .5 (page.tsx EventSource)\n.1 → .4 (diff-beads.ts) → .5\n.5 → .6 (paintNode animations) → .7 (paintLink animations) → .8 (integration test)","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:14:54.798302+13:00","updatedAt":"2026-02-10T23:40:49.541566+13:00","closedAt":"2026-02-10T23:40:49.541566+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":10,"dependentCount":0,"blockerIds":["beads-map-21c","beads-map-2fk","beads-map-2qg","beads-map-7j2","beads-map-cvh","beads-map-ecl","beads-map-gjo","beads-map-iyn","beads-map-m1o","beads-map-mq9"],"dependentIds":[]},{"id":"beads-map-3pg","title":"v0.3.3: Auto-fit toggle and top-right layout reorganization","description":"## Overview\n\nAdd a toggle button that enables/disables automatic camera zoom-to-fit after graph data updates. Currently, every SSE data update and layout switch triggers `graphRef.current.zoomToFit(400, 60)`, which snaps the camera away from whatever the user was looking at. This is confusing when inspecting a specific node.\n\nThe toggle lives in the top-right corner of the graph canvas. The top-right area is also reorganized from a single row to a **stacked vertical layout** (flex-col) so the new auto-fit toggle and the existing ActivityOverlay sit in separate rows.\n\n## Problem\n\nTwo `zoomToFit` call sites in `components/BeadsGraph.tsx` fire automatically:\n\n1. **Line 838** — `useEffect([layoutMode, viewNodes, viewLinks])`: Fires on layout mode changes AND on every data update (because `viewNodes`/`viewLinks` change when SSE pushes new data). Waits 600ms (or 1000ms on first apply) then calls `graphRef.current.zoomToFit(400, 60)`.\n2. **Line 865** — `useEffect([nodes.length, timelineActive])`: Fires when `nodes.length` changes (new nodes arrive via SSE) or `timelineActive` toggles. Already skipped during timeline replay.\n\n**Both calls should be gated** by an `autoFit` boolean prop. When `autoFit` is false, the camera never moves automatically — the user has full manual control.\n\n## Design decisions\n\n- **Default: ON** — `autoFit` starts as `true` to preserve current behavior. Users who want to explore manually toggle it off.\n- **Gates ALL zoomToFit** — Even layout switches (Force→DAG→Radial) do NOT auto-zoom when toggle is off. User asked for full manual control.\n- **Button placement: top-right** with ActivityOverlay, not top-left with layout buttons. This separates \"graph shape controls\" (top-left) from \"view/camera controls\" (top-right).\n- **Button style: Pattern C** (cluster labels toggle from BeadsGraph.tsx lines 1985-2008). Frosted glass pill when inactive, emerald background when active. Uses a crosshairs/frame icon.\n- **Stacked layout**: The `<div className=\"absolute top-3 right-3 ...\">` wrapper in `page.tsx` (line 1346) changes from wrapping only ActivityOverlay to a `flex flex-col items-end gap-2` container holding both the auto-fit toggle (always visible) and ActivityOverlay (conditionally visible).\n\n## Files\n\n### Modified files\n- `components/BeadsGraph.tsx` — Accept `autoFit?: boolean` prop, gate both `zoomToFit` calls\n- `app/page.tsx` — Add `autoFit` state, pass prop, reorganize top-right layout, render toggle button\n\n### No new files needed\nThe toggle button is small enough to inline in `page.tsx` within the top-right container.\n\n## Current state (as of commit e834d07)\n\n- `BeadsGraphProps` interface: lines 29-55 of `BeadsGraph.tsx`\n- zoomToFit call #1: line 838 of `BeadsGraph.tsx`, deps `[layoutMode, viewNodes, viewLinks]`\n- zoomToFit call #2: line 865 of `BeadsGraph.tsx`, deps `[nodes.length, timelineActive]`\n- ActivityOverlay wrapper: lines 1344-1363 of `page.tsx`, `<div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10\">`\n- State declarations area: around lines 265-315 of `page.tsx`\n- BeadsGraph rendering: lines 1302-1323 of `page.tsx`\n- Best toggle pattern to follow: cluster labels toggle at lines 1985-2008 of `BeadsGraph.tsx` (emerald active, frosted glass inactive)","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:07:56.23929+13:00","updatedAt":"2026-02-12T16:19:07.97407+13:00","closedAt":"2026-02-12T16:19:07.97407+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":8,"dependentCount":0,"blockerIds":["beads-map-3pg.1","beads-map-3pg.2","beads-map-3pg.3","beads-map-3pg.4","beads-map-3pg.5","beads-map-3pg.6","beads-map-3pg.7","beads-map-3pg.8"],"dependentIds":[]},{"id":"beads-map-3pg.1","title":"Add autoFit prop to BeadsGraphProps interface","description":"## What\n\nAdd `autoFit?: boolean` to the `BeadsGraphProps` interface in `components/BeadsGraph.tsx`.\n\n## Where\n\nFile: `components/BeadsGraph.tsx`\nLines 29-55: `interface BeadsGraphProps { ... }`\n\n## Exact change\n\nAfter line 54 (`onColorModeChange?: (mode: ColorMode) => void;`), add:\n\n```typescript\n /** Whether to auto-zoom to fit all nodes after data updates and layout changes */\n autoFit?: boolean;\n```\n\nThen in the component destructuring (find `const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>((props, ref) => {` or similar), add `autoFit = true` to the destructured props with a default of `true`.\n\n## Acceptance criteria\n- `BeadsGraphProps` includes `autoFit?: boolean`\n- Component destructures it with default `true`\n- No other behavioral changes yet (that is task .2)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:08:05.402809+13:00","updatedAt":"2026-02-12T16:16:00.71156+13:00","closedAt":"2026-02-12T16:16:00.71156+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-3pg.2","beads-map-3pg.3","beads-map-3pg.7"],"dependentIds":["beads-map-3pg"]},{"id":"beads-map-3pg.2","title":"Gate both zoomToFit calls with autoFit prop","description":"## What\n\nWrap both `zoomToFit` calls in `components/BeadsGraph.tsx` with `if (autoFit)` guards so the camera stays fixed when the user has disabled auto-fit.\n\n## Where\n\nFile: `components/BeadsGraph.tsx`\n\n### Call site 1: Layout mode / data change effect (line 838)\n\nCurrent code (lines 835-844):\n```typescript\n // Fit to view after layout settles\n const delay = initialLayoutApplied.current ? 600 : 1000;\n const timer = setTimeout(() => {\n if (graphRef.current) graphRef.current.zoomToFit(400, 60);\n }, delay);\n\n initialLayoutApplied.current = true;\n\n return () => clearTimeout(timer);\n }, [layoutMode, viewNodes, viewLinks]);\n```\n\nChange to:\n```typescript\n // Fit to view after layout settles (only if auto-fit is enabled)\n let timer: ReturnType<typeof setTimeout> | undefined;\n if (autoFit) {\n const delay = initialLayoutApplied.current ? 600 : 1000;\n timer = setTimeout(() => {\n if (graphRef.current) graphRef.current.zoomToFit(400, 60);\n }, delay);\n }\n\n initialLayoutApplied.current = true;\n\n return () => { if (timer) clearTimeout(timer); };\n }, [layoutMode, viewNodes, viewLinks, autoFit]);\n```\n\n**IMPORTANT**: Add `autoFit` to the dependency array since the effect reads it.\n\n### Call site 2: Initial load / node count change effect (line 865)\n\nCurrent code (lines 860-869):\n```typescript\n // Fit to view on initial load (skip during timeline replay)\n useEffect(() => {\n if (timelineActive) return;\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n }, [nodes.length, timelineActive]);\n```\n\nChange to:\n```typescript\n // Fit to view on initial load (skip during timeline replay or when auto-fit disabled)\n useEffect(() => {\n if (timelineActive) return;\n if (!autoFit) return;\n if (graphRef.current && nodes.length > 0) {\n const timer = setTimeout(() => {\n graphRef.current.zoomToFit(400, 60);\n }, 800);\n return () => clearTimeout(timer);\n }\n }, [nodes.length, timelineActive, autoFit]);\n```\n\n**IMPORTANT**: Add `autoFit` to the dependency array.\n\n## Edge cases\n\n- When `autoFit` toggles from false→true, the effect will re-run. This is desirable — re-enabling auto-fit should immediately zoom to fit as feedback that it is working.\n- The bootstrap trick (lines 849-858) that switches from DAG→Force on initial load will still trigger the layout effect, but the `autoFit` guard will skip the zoom. The layout change itself (force configuration) still applies — only the camera zoom is suppressed.\n- `d3ReheatSimulation()` (line 833) still fires regardless of `autoFit` — the simulation reheat is separate from the camera zoom.\n\n## Acceptance criteria\n- With `autoFit={true}` (default): behavior is identical to current (both zoom calls fire)\n- With `autoFit={false}`: neither `zoomToFit` call fires; camera stays wherever user left it\n- Toggling `autoFit` from false→true triggers an immediate zoom-to-fit\n- `autoFit` is in both dependency arrays","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:08:31.186009+13:00","updatedAt":"2026-02-12T16:16:33.773936+13:00","closedAt":"2026-02-12T16:16:33.773936+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-3pg.5"],"dependentIds":["beads-map-3pg","beads-map-3pg.1"]},{"id":"beads-map-3pg.3","title":"Add autoFit state to page.tsx and pass to BeadsGraph","description":"## What\n\nAdd `autoFit` state in `app/page.tsx` and pass it as a prop to `<BeadsGraph>`.\n\n## Where\n\nFile: `app/page.tsx`\n\n### 1. Add state declaration\n\nNear lines 306-311 (after the timeline state block, before `graphRef`), add:\n\n```typescript\n // Auto-fit: when true, graph auto-zooms to fit after data updates and layout changes\n const [autoFit, setAutoFit] = useState(true);\n```\n\n### 2. Pass prop to BeadsGraph\n\nAt lines 1302-1323 where `<BeadsGraph>` is rendered, add the `autoFit` prop:\n\n```tsx\n <BeadsGraph\n ref={graphRef}\n nodes={...}\n links={...}\n ...\n colorMode={colorMode}\n onColorModeChange={setColorMode}\n autoFit={autoFit} // <-- ADD THIS\n />\n```\n\n## Acceptance criteria\n- `autoFit` state exists in page.tsx, defaults to `true`\n- `setAutoFit` setter is available for the toggle button (task .4)\n- `<BeadsGraph>` receives `autoFit` prop\n- No TypeScript errors (prop already declared in task .1)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:08:40.195056+13:00","updatedAt":"2026-02-12T16:16:33.902562+13:00","closedAt":"2026-02-12T16:16:33.902562+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-3pg.4","beads-map-3pg.7"],"dependentIds":["beads-map-3pg","beads-map-3pg.1"]},{"id":"beads-map-3pg.4","title":"Reorganize top-right layout into stacked flex-col with auto-fit toggle button","description":"## What\n\nReplace the single-row top-right layout in `app/page.tsx` with a stacked vertical layout (flex-col) containing:\n1. **Row 1**: Auto-fit toggle button (always visible when no sidebar is open)\n2. **Row 2**: ActivityOverlay (conditionally visible, same conditions as before)\n\nAlso add the auto-fit toggle button itself, styled following Pattern C (cluster labels toggle).\n\n## Where\n\nFile: `app/page.tsx`\n\n### Current code (lines 1344-1363):\n\n```tsx\n {/* Activity overlay — top-right of canvas */}\n {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n <div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10\">\n <ActivityOverlay\n events={activityFeed}\n collapsed={activityOverlayCollapsed}\n onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}\n onExpandPanel={() => {\n setActivityPanelOpen(true);\n setSelectedNode(null);\n setAllCommentsPanelOpen(false);\n setHelpPanelOpen(false);\n }}\n onNodeClick={(nodeId) => {\n const node = data?.graphData.nodes.find((n) => n.id === nodeId);\n if (node) focusNode(node);\n }}\n />\n </div>\n )}\n```\n\n### Replace with:\n\n```tsx\n {/* Top-right controls — stacked vertically */}\n {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n <div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10 flex flex-col items-end gap-2\">\n {/* Auto-fit toggle */}\n <button\n onClick={() => setAutoFit((v) => !v)}\n 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 ${\n autoFit\n ? \"bg-emerald-500 text-white border-emerald-500\"\n : \"bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n title={autoFit ? \"Auto-fit enabled: camera adjusts after updates\" : \"Auto-fit disabled: camera stays fixed\"}\n >\n {/* Crosshairs/frame icon */}\n <svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n <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\" />\n <circle cx=\"12\" cy=\"12\" r=\"3\" />\n </svg>\n <span className=\"hidden sm:inline\">Auto-fit</span>\n </button>\n\n {/* Activity overlay */}\n <ActivityOverlay\n events={activityFeed}\n collapsed={activityOverlayCollapsed}\n onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}\n onExpandPanel={() => {\n setActivityPanelOpen(true);\n setSelectedNode(null);\n setAllCommentsPanelOpen(false);\n setHelpPanelOpen(false);\n }}\n onNodeClick={(nodeId) => {\n const node = data?.graphData.nodes.find((n) => n.id === nodeId);\n if (node) focusNode(node);\n }}\n />\n </div>\n )}\n```\n\n## Design details\n\n### Button styling (matches Pattern C — cluster labels toggle)\n- **Active (autoFit=true)**: `bg-emerald-500 text-white border-emerald-500` — solid emerald pill\n- **Inactive (autoFit=false)**: `bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50` — frosted glass pill\n- **Shared**: `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`\n- **Icon**: Crosshairs/frame SVG (`w-3.5 h-3.5`) — four corner brackets with a small circle in the center, suggests \"framing\" or \"fitting to view\"\n- **Label**: `<span className=\"hidden sm:inline\">Auto-fit</span>` — hidden on mobile, visible on sm+\n- **Title tooltip**: Explains current state on hover\n\n### Layout structure\n- Outer container: `flex flex-col items-end gap-2` — items right-aligned, 8px vertical gap\n- Auto-fit button: top row, always rendered within the container\n- ActivityOverlay: bottom row, no longer wrapped in its own `<div>` since the parent provides positioning\n- Both share the same visibility condition (hidden when any sidebar/timeline is active)\n\n### Icon SVG\nThe icon is a \"viewfinder\" / \"frame\" symbol:\n- Four corner brackets (top-left, top-right, bottom-left, bottom-right) drawn as L-shaped paths\n- A small circle in the center (crosshair target)\n- This communicates \"zoom to fit frame\" intuitively\n\n## Acceptance criteria\n- Top-right area shows auto-fit toggle above ActivityOverlay in a vertical stack\n- Toggle button is emerald when active, frosted glass when inactive\n- Clicking toggles `autoFit` state\n- ActivityOverlay retains all existing behavior (collapse, expand panel, node click)\n- On mobile (< sm), only the icon shows (label hidden)\n- When any sidebar or timeline is active, both controls are hidden (same as before)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:09:08.244968+13:00","updatedAt":"2026-02-12T16:14:02.363721+13:00","closedAt":"2026-02-12T16:14:02.363721+13:00","closeReason":"Superseded: moving auto-fit toggle to top-left instead of top-right","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-3pg.5","beads-map-3pg.6"],"dependentIds":["beads-map-3pg","beads-map-3pg.3"]},{"id":"beads-map-3pg.5","title":"Build verify, bd sync, and push auto-fit toggle feature","description":"## What\n\nRun `pnpm build` to verify zero errors. If build fails:\n\n1. **Type errors**: Fix them in the relevant file\n2. **PageNotFoundError or Cannot find module**: Run `rm -rf .next node_modules/.cache && sleep 1 && pnpm build`\n3. Re-run build until clean\n\nThen commit and push:\n\n```bash\ngit add -A\ngit commit -m \"Add auto-fit toggle and reorganize top-right layout\"\nbd sync\ngit push\ngit status # MUST show \"up to date with origin\"\n```\n\n## Acceptance criteria\n- `pnpm build` passes with zero errors\n- All changes committed and pushed to origin/main\n- `git status` shows clean working tree, up to date with remote\n- `bd close beads-map-3pg` (epic auto-closes when all children closed)","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:09:17.522053+13:00","updatedAt":"2026-02-12T16:19:07.889301+13:00","closedAt":"2026-02-12T16:19:07.889301+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":5,"blockerIds":[],"dependentIds":["beads-map-3pg","beads-map-3pg.2","beads-map-3pg.4","beads-map-3pg.7","beads-map-3pg.8"]},{"id":"beads-map-3pg.6","title":"Add tutorial step for auto-fit toggle and update static help content","description":"## What\n\nAdd a new tutorial step (step 2, \"Camera Controls\") that spotlights the top-right controls area and explains the auto-fit toggle. Also add a bullet to the static HelpContent \"More\" section.\n\n## Challenge: visibility during tutorial\n\nThe top-right controls container is conditionally hidden when `helpPanelOpen` is true (line 1345 of `page.tsx`):\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n```\n\nSince the HelpPanel is open during the tutorial, the top-right controls will be hidden, and the spotlight target will not be found. **Fix**: Add a special exception for when the tutorial is on the camera-controls step:\n\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && (!helpPanelOpen || (tutorialStep !== null && TUTORIAL_STEPS[tutorialStep]?.target === \"top-right-controls\")) && !timelineActive && (\n```\n\nThis requires importing `TUTORIAL_STEPS` in `page.tsx` (already imported at line 22) and having access to `tutorialStep` state (already exists).\n\n## Files to modify\n\n### 1. `components/TutorialOverlay.tsx` — Add new step at index 2\n\nInsert after the \"Layout Modes\" step (current index 1), before \"Color Modes & Legend\" (current index 2):\n\n```typescript\n {\n target: \"top-right-controls\",\n title: \"Camera Controls\",\n description:\n \"The Auto-fit toggle in the top-right controls whether the camera re-centers after each update. Turn it off to stay focused on a specific area while data streams in.\",\n },\n```\n\nThis bumps all subsequent steps by 1 (now 8 total: indices 0-7).\n\n### 2. `app/page.tsx` — Two changes\n\n#### 2a. Add `data-tutorial` attribute to top-right container\n\nIn the reorganized top-right container (from task .4), add `data-tutorial=\"top-right-controls\"`:\n```tsx\n<div className=\"absolute top-3 right-3 sm:top-4 sm:right-4 z-10 flex flex-col items-end gap-2\" data-tutorial=\"top-right-controls\">\n```\n\n#### 2b. Fix visibility condition to show controls during this tutorial step\n\nChange the visibility condition from:\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (\n```\nTo:\n```tsx\n{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && (!helpPanelOpen || (tutorialStep !== null && TUTORIAL_STEPS[tutorialStep]?.target === \"top-right-controls\")) && !timelineActive && (\n```\n\n`TUTORIAL_STEPS` is already imported at line 22: `import { TutorialOverlay, TUTORIAL_STEPS } from \"@/components/TutorialOverlay\";`\n\n### 3. `components/HelpPanel.tsx` — Add bullet to static help content\n\nIn the \"More\" section (CAT.mauve, around line 300-308), add a new bullet:\n```tsx\n<Bullet color={CAT.mauve}><strong>Auto-fit</strong> &mdash; top-right toggle to lock/unlock automatic camera reframing</Bullet>\n```\n\nInsert it after the \"Minimap\" bullet (line 307) and before the \"Copy\" bullet (line 308).\n\n## Edge cases\n\n- When tutorial is on step 2 (camera controls), the top-right area must be visible even though HelpPanel is open. The exception in the visibility condition handles this.\n- The auto-fit button should be interactive during this tutorial step — user can try clicking it. Since HelpPanel is z-[60] and the overlay is z-[55], the top-right controls at z-10 would be behind the overlay. But the overlay has `pointer-events: auto` on the dark area only — the spotlight cutout lets clicks through visually. However, SVG masks are visual-only (discovery from previous session). The button will NOT be clickable through the overlay. This is fine — user just reads the description and clicks to advance.\n- Step count changes from 7 to 8. The step indicator dots and \"X of Y\" text in TutorialContent are computed dynamically from `TUTORIAL_STEPS.length`, so no hardcoded counts to update.\n- The `isLast` check (`step === TUTORIAL_STEPS.length - 1`) will automatically adjust.\n\n## Acceptance criteria\n- Tutorial has 8 steps (was 7)\n- Step 2 spotlights the top-right controls area with emerald pulsing ring\n- Top-right controls (auto-fit button + ActivityOverlay) are visible during step 2 even though HelpPanel is open\n- Static help \"More\" section includes an \"Auto-fit\" bullet\n- Step indicator shows \"3 / 8\" when on this step\n- All other tutorial steps still work correctly (targets found, descriptions unchanged)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:13:04.41715+13:00","updatedAt":"2026-02-12T16:13:58.69935+13:00","closedAt":"2026-02-12T16:13:58.69935+13:00","closeReason":"Superseded: no visibility hack needed since button moved to top-left (always visible)","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-3pg","beads-map-3pg.4"]},{"id":"beads-map-3pg.7","title":"Reorganize top-left controls into two rows with auto-fit toggle in BeadsGraph","description":"## What\n\nReorganize the top-left controls in `components/BeadsGraph.tsx` from a single horizontal row into **two rows** (stacked vertically). Add the auto-fit toggle button to the second row.\n\n**Supersedes beads-map-3pg.4** (which put the toggle in the top-right; cancelled because the top-right controls are hidden when HelpPanel is open, making tutorial spotlighting impossible).\n\n## Current layout (single row, lines 1793-2009)\n\n```\n<div className=\"absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex items-start gap-1.5 sm:gap-2\">\n [Force|DAG|Radial|Cluster|Spread] [Collapse all] [Clusters]\n</div>\n```\n\nAll 3 control groups are siblings in one `flex` row with `items-start gap-1.5`.\n\n## New layout (two rows)\n\n```\n<div className=\"absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2\">\n {/* Row 1: Layout shape controls */}\n <div className=\"flex items-start gap-1.5 sm:gap-2\" data-tutorial=\"layouts\">\n [Force|DAG|Radial|Cluster|Spread]\n </div>\n {/* Row 2: View toggles */}\n <div className=\"flex items-start gap-1.5 sm:gap-2\" data-tutorial=\"view-controls\">\n [Collapse all] [Clusters] [Auto-fit]\n </div>\n</div>\n```\n\n### Exact changes to the JSX (lines 1793-2009):\n\n1. **Outer container** (line 1793): Change `flex items-start gap-1.5 sm:gap-2` to `flex flex-col gap-1.5 sm:gap-2`\n\n2. **Row 1 wrapper**: Wrap the layout segmented button group (`data-tutorial=\"layouts\"` div, line 1795) in a new `<div className=\"flex items-start gap-1.5 sm:gap-2\">`. Move `data-tutorial=\"layouts\"` to this new wrapper (or keep it on the segmented group — either works since the spotlight will cover the same area).\n\n3. **Row 2 wrapper**: Wrap the Collapse/Expand button (lines 1941-1983) and Clusters toggle (lines 1985-2008) in a new `<div className=\"flex items-start gap-1.5 sm:gap-2\" data-tutorial=\"view-controls\">`. Add the auto-fit toggle button after Clusters.\n\n4. **Auto-fit toggle button** (new, at end of row 2):\n\n```tsx\n {/* Auto-fit: lock/unlock automatic camera reframing */}\n <button\n onClick={() => onAutoFitToggle?.()}\n 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 ${\n autoFit\n ? \"bg-emerald-500 text-white border-emerald-500\"\n : \"bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50\"\n }`}\n title={autoFit ? \"Auto-fit enabled: camera adjusts after updates\" : \"Auto-fit disabled: camera stays fixed\"}\n >\n <svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n <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\" />\n <circle cx=\"12\" cy=\"12\" r=\"3\" />\n </svg>\n <span className=\"hidden sm:inline\">Auto-fit</span>\n </button>\n```\n\n### Props changes\n\nThe auto-fit toggle needs to be controlled from `page.tsx` (where state lives). Two approaches:\n\n**Option A** (callback prop): Add `onAutoFitToggle?: () => void` to `BeadsGraphProps`. Page.tsx passes `onAutoFitToggle={() => setAutoFit(v => !v)}`. BeadsGraph reads `autoFit` for visual state and calls `onAutoFitToggle` on click.\n\n**Option B** (state+setter props): Add `onAutoFitChange?: (value: boolean) => void` to `BeadsGraphProps`. \n\nUse **Option A** (simpler). Add to `BeadsGraphProps` (line 29-55):\n```typescript\n /** Callback to toggle auto-fit */\n onAutoFitToggle?: () => void;\n```\n\nAnd in the destructuring (line 241, after `onColorModeChange`):\n```typescript\n onAutoFitToggle,\n```\n\n### Wiring in page.tsx (lines 1302-1323)\n\nAdd props to `<BeadsGraph>`:\n```tsx\n autoFit={autoFit}\n onAutoFitToggle={() => setAutoFit((v) => !v)}\n```\n\n### ActivityOverlay stays in top-right\n\nThe `page.tsx` top-right area (lines 1344-1363) is **unchanged**. ActivityOverlay stays where it is. No layout changes there.\n\n## Acceptance criteria\n- Top-left controls are two rows: layout buttons on top, view toggles below\n- Auto-fit toggle appears in row 2, after Clusters button\n- Button is emerald when active, frosted glass when inactive\n- Clicking calls `onAutoFitToggle` which flips `autoFit` state in page.tsx\n- `data-tutorial=\"layouts\"` covers row 1, `data-tutorial=\"view-controls\"` covers row 2\n- On mobile (<sm), only icons show (labels hidden)\n- ActivityOverlay remains in top-right, untouched","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:14:39.400843+13:00","updatedAt":"2026-02-12T16:17:55.781153+13:00","closedAt":"2026-02-12T16:17:55.781153+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":3,"blockerIds":["beads-map-3pg.5","beads-map-3pg.8"],"dependentIds":["beads-map-3pg","beads-map-3pg.1","beads-map-3pg.3"]},{"id":"beads-map-3pg.8","title":"Add tutorial step for view controls and update static help content","description":"## What\n\nAdd a new tutorial step that spotlights the second row of top-left controls (`data-tutorial=\"view-controls\"`) and explains the auto-fit toggle, collapse/expand, and cluster labels. Also add an auto-fit bullet to the static help \"More\" section.\n\nSince the controls are in `BeadsGraph.tsx` (always rendered), there is NO visibility issue — no hack needed. This is why we moved the button to top-left.\n\n## Files to modify\n\n### 1. `components/TutorialOverlay.tsx` — Add new step at index 2\n\nInsert after \"Layout Modes\" (current index 1), before \"Color Modes & Legend\" (current index 2):\n\n```typescript\n {\n target: \"view-controls\",\n title: \"View Controls\",\n description:\n \"Collapse or expand epic groups, toggle cluster label overlays, and control auto-fit. When auto-fit is on (green), the camera re-centers after every update. Turn it off to stay focused on a specific area while data streams in.\",\n },\n```\n\nThis bumps all subsequent steps by 1 (now 8 total: indices 0-7).\n\n**No other changes needed in this file.** The step indicator, navigation, and isLast logic are all computed from `TUTORIAL_STEPS.length` dynamically.\n\n### 2. `components/HelpPanel.tsx` — Add bullet to static help \"More\" section\n\nIn the \"More\" section (around line 300-308, `CAT.mauve` bullets), add after the \"Minimap\" bullet (line 307):\n\n```tsx\n <Bullet color={CAT.mauve}><strong>Auto-fit</strong> &mdash; top-left toggle to lock/unlock automatic camera reframing</Bullet>\n```\n\n## Why this is simple now\n\n- `data-tutorial=\"view-controls\"` is on row 2 of the top-left controls inside `BeadsGraph.tsx`\n- `BeadsGraph.tsx` is always rendered (no conditional visibility)\n- The tutorial overlay can always find the target element\n- No z-index hacks, no visibility exceptions, no conditional rendering changes\n\n## Acceptance criteria\n- Tutorial has 8 steps (was 7)\n- Step 2 (\"View Controls\") spotlights the second row of top-left controls with emerald pulsing ring\n- Step indicator shows \"3 / 8\" for this step\n- Static help \"More\" section includes \"Auto-fit\" bullet\n- All other tutorial steps unaffected (targets still found, descriptions unchanged)\n- No changes to visibility conditions in page.tsx","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T16:14:58.678602+13:00","updatedAt":"2026-02-12T16:18:23.584479+13:00","closedAt":"2026-02-12T16:18:23.584479+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-3pg.5"],"dependentIds":["beads-map-3pg","beads-map-3pg.7"]},{"id":"beads-map-3qb","title":"Filter out tombstoned issues from graph","status":"closed","priority":1,"issueType":"bug","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:48:26.859412+13:00","updatedAt":"2026-02-10T23:48:54.69868+13:00","closedAt":"2026-02-10T23:48:54.69868+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":0,"blockerIds":[],"dependentIds":[]},{"id":"beads-map-48c","title":"Show full description with markdown rendering in NodeDetail sidebar","description":"## Show full description with markdown rendering in NodeDetail sidebar\n\n### Summary\nTwo changes to the description section in `components/NodeDetail.tsx`:\n\n1. **Remove truncation** — Stop calling `truncateDescription()` so the full description text is shown. The scrollable container (`max-h-40 overflow-y-auto`) already handles long content elegantly — keep that.\n\n2. **Render markdown** — Descriptions are written in markdown (headings, code blocks, lists, links, bold/italic). Currently rendered as plain `<pre>` text. Install `react-markdown` + `remark-gfm` and render the description as formatted markdown inside the scrollable box, with appropriate typography styles for the small text size (text-xs base).\n\n### Tasks\n- .1 Install react-markdown and remark-gfm\n- .2 Remove truncation, add markdown rendering with styled prose in the scrollable box\n- .3 Build verification\n\n### Files to modify\n- `package.json` — add react-markdown, remark-gfm\n- `components/NodeDetail.tsx` — replace `<pre>{truncateDescription(...)}</pre>` with `<ReactMarkdown>` component, remove `truncateDescription` function\n- `app/globals.css` — possibly add small prose styling overrides for the description box\n\n### Key constraint\nKeep the scrollable container (`max-h-40 overflow-y-auto custom-scrollbar`) — that's good UX. Just show the full content inside it and render it as markdown instead of plain text.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:11:03.062054+13:00","updatedAt":"2026-02-11T01:12:31.404312+13:00","closedAt":"2026-02-11T01:12:31.404312+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":0,"blockerIds":[],"dependentIds":[]},{"id":"beads-map-7j2","title":"Create SSE endpoint /api/beads/stream","description":"Create a new file: app/api/beads/stream/route.ts\n\nPURPOSE: Server-Sent Events endpoint that streams beads data to the client. On initial connection, sends the full dataset. Then watches all JSONL files for changes and pushes updated data whenever files change. This replaces the one-shot GET /api/beads fetch for live use.\n\nIMPLEMENTATION:\n\n```typescript\nimport { discoverBeadsDir } from \"@/lib/discover\";\nimport { loadBeadsData } from \"@/lib/parse-beads\";\nimport { watchBeadsFiles } from \"@/lib/watch-beads\";\n\n// Prevent Next.js from statically optimizing this route\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(request: Request) {\n let cleanup: (() => void) | null = null;\n\n const stream = new ReadableStream({\n start(controller) {\n const encoder = new TextEncoder();\n\n function send(data: unknown) {\n try {\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`)\n );\n } catch {\n // Stream closed — cleanup will handle\n }\n }\n\n try {\n const { beadsDir } = discoverBeadsDir();\n\n // Send initial data\n const initialData = loadBeadsData(beadsDir);\n send(initialData);\n\n // Watch for changes and push updates\n cleanup = watchBeadsFiles(beadsDir, () => {\n try {\n const newData = loadBeadsData(beadsDir);\n send(newData);\n } catch (err) {\n console.error(\"Failed to reload beads data:\", err);\n }\n });\n\n // Heartbeat every 30s to keep connection alive through proxies/firewalls\n const heartbeat = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(\": heartbeat\\n\\n\"));\n } catch {\n clearInterval(heartbeat);\n }\n }, 30000);\n\n // Clean up when client disconnects\n request.signal.addEventListener(\"abort\", () => {\n clearInterval(heartbeat);\n if (cleanup) cleanup();\n try { controller.close(); } catch { /* already closed */ }\n });\n\n } catch (err: any) {\n // Discovery failed — send error and close\n send({ error: err.message });\n controller.close();\n }\n },\n\n cancel() {\n if (cleanup) cleanup();\n },\n });\n\n return new Response(stream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\", // Disable Nginx buffering\n },\n });\n}\n```\n\nKEY DESIGN DECISIONS:\n- export const dynamic = \"force-dynamic\": tells Next.js not to statically optimize this route\n- Full data push on each change: JSONL files are small (10-100 issues), so re-parsing is fast (<5ms). Sending full data avoids incremental diff sync complexity on the server.\n- Heartbeat every 30s: prevents proxies and load balancers from closing idle connections\n- request.signal.addEventListener(\"abort\"): proper cleanup when client disconnects (browser tab close, navigation away, EventSource reconnect)\n- TextEncoder for SSE format: controller.enqueue requires Uint8Array\n- X-Accel-Buffering: no: prevents Nginx from buffering SSE responses\n\nSSE MESSAGE FORMAT:\nEach message is a complete BeadsApiResponse JSON object:\n data: {\"issues\":[...],\"dependencies\":[...],\"graphData\":{\"nodes\":[...],\"links\":[...]},\"stats\":{...}}\n\nThe client (task .5) will parse this and diff against current state.\n\nERROR HANDLING:\n- Discovery failure: sends { error: \"...\" } then closes stream\n- Parse failure during watch: logs error, does NOT close stream (transient file write state)\n- Client disconnect: cleanup function closes all watchers\n\nTESTING:\n1. Start dev server: BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n2. Open in browser: http://localhost:3000/api/beads/stream\n3. You should see SSE data flowing (initial payload, then updates when JSONL changes)\n4. In another terminal: cd ~/Projects/gainforest/gainforest-beads && bd create --title \"test live\" --priority 3\n5. Within ~300ms, the SSE stream should push a new message with the updated data\n6. Ctrl+C the stream — check no watcher leaks in the server process\n\nDEPENDS ON: task .1 (types), task .2 (watch-beads.ts)\n\nACCEPTANCE CRITERIA:\n- GET /api/beads/stream returns Content-Type: text/event-stream\n- Initial data sent immediately on connection\n- Updates pushed when any watched JSONL file changes\n- Heartbeat keeps connection alive\n- Proper cleanup on client disconnect\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:15:56.921088+13:00","updatedAt":"2026-02-10T23:26:39.409649+13:00","closedAt":"2026-02-10T23:26:39.409649+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-ecl"],"dependentIds":["beads-map-3jy","beads-map-m1o"]},{"id":"beads-map-7r6","title":"Activity feed: real-time + historical event log with compact overlay and expandable panel","description":"Add a comprehensive activity feed to beads-map showing both historical events and real-time updates.\n\n**Features:**\n- Historical feed from existing data (node creation/closure, links, comments, claims, likes)\n- Real-time events from SSE diffs (status/priority/title/owner changes, new comments, etc.)\n- Compact overlay (top-right) showing latest 5 events\n- Full panel (slide-in sidebar) with search and category filters\n- 13 event types with color-coded icons\n- Mutual exclusivity with other sidebars\n\n**Components created:**\n- lib/activity.ts: Event types, builders, diff-to-events converter\n- ActivityItem.tsx: Reusable event row (compact + full variants)\n- ActivityOverlay.tsx: Always-visible card with collapsible state\n- ActivityPanel.tsx: Full sidebar with search/filters\n\n**Integration:**\n- Activity pill in header navbar\n- SSE handler pipes diffs into activity feed\n- Event deduplication and 200-event cap\n","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:13.943245+13:00","updatedAt":"2026-02-11T12:11:21.787516+13:00","closedAt":"2026-02-11T12:05:23.135577+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":8,"dependentCount":1,"blockerIds":["beads-map-7r6.1","beads-map-7r6.2","beads-map-7r6.3","beads-map-7r6.4","beads-map-7r6.5","beads-map-7r6.6","beads-map-7r6.7","beads-map-7r6.8"],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-7r6.1","title":"Create lib/activity.ts: ActivityEvent type and historical feed builder","description":"Create the core activity feed infrastructure in lib/activity.ts.\n\n**Implemented:**\n- ActivityEventType enum: 13 event types (node-created, node-closed, node-status-changed, node-priority-changed, node-title-changed, node-owner-changed, link-added, link-removed, comment-added, reply-added, task-claimed, task-unclaimed, like-added)\n- ActivityEvent interface: { id, type, time, nodeId, nodeTitle?, actor?, detail?, meta? }\n- ActivityActor interface: { handle, avatar?, did? }\n- ActivityFilterCategory type: \"issues\" | \"deps\" | \"comments\" | \"claims\" | \"likes\"\n- getEventCategory(): maps event types to filter categories\n- buildHistoricalFeed(nodes, links, allComments): extracts events from existing data\n- diffToActivityEvents(diff, nodes): converts real-time BeadsDiff into events\n- mergeFeedEvents(existing, incoming): deduplicates by event ID, sorts newest-first, caps at 200\n- Event ID format: \"${type}:${nodeId}:${time}\" for deduplication\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:21.793982+13:00","updatedAt":"2026-02-11T12:11:25.528565+13:00","closedAt":"2026-02-11T12:05:22.084119+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-7r6.2","beads-map-7r6.3","beads-map-7r6.7"],"dependentIds":["beads-map-7r6"]},{"id":"beads-map-7r6.2","title":"Wire activity feed state in page.tsx: accumulate historical + SSE events","description":"Wire activity feed state management in app/page.tsx to accumulate historical and real-time events.\n\n**Implemented:**\n- Added state: activityFeed (ActivityEvent[]), activityPanelOpen (boolean), activityOverlayCollapsed (boolean)\n- useEffect to rebuild historical feed when data or allComments change via buildHistoricalFeed()\n- SSE onmessage handler: after computing diff, calls diffToActivityEvents() and merges into feed via mergeFeedEvents()\n- Feed accumulation with deduplication by event ID\n- Max 200 events retained (newest first)\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:21.921951+13:00","updatedAt":"2026-02-11T12:11:29.04198+13:00","closedAt":"2026-02-11T12:05:22.216634+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-7r6.6"],"dependentIds":["beads-map-7r6","beads-map-7r6.1","beads-map-7r6.7"]},{"id":"beads-map-7r6.3","title":"Create components/ActivityItem.tsx: single event row (compact + full variants)","description":"Create ActivityItem.tsx component for rendering individual activity events.\n\n**Implemented:**\n- Two variants: \"compact\" (single-line for overlay) and \"full\" (rich with avatar for panel)\n- Per-type SVG icons with color coding:\n - Emerald: positive actions (created, claimed, liked)\n - Amber: changes (status, priority, title, owner)\n - Red: removals (closed, link removed, unclaimed)\n - Blue: comments and replies\n- describeEvent() function: maps event types to human-readable text\n- Clickable node ID pills calling onNodeClick prop\n- Displays actor handle and avatar in full variant\n- Timestamp formatting (relative time)\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.046526+13:00","updatedAt":"2026-02-11T12:11:32.556229+13:00","closedAt":"2026-02-11T12:05:22.350628+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-7r6.4","beads-map-7r6.5"],"dependentIds":["beads-map-7r6","beads-map-7r6.1"]},{"id":"beads-map-7r6.4","title":"Create components/ActivityOverlay.tsx: compact always-visible top-right card","description":"Create ActivityOverlay.tsx: compact always-visible card in the top-right of the graph area.\n\n**Implemented:**\n- Position: absolute top-3 right-3 z-10 (inside graph area div)\n- Frosted glass styling: bg-white/90 backdrop-blur-sm rounded-lg border shadow-sm\n- Width: w-64 (256px)\n- Shows latest 5 events in compact variant\n- Collapsible to small pill with recent event count badge (events in last 5 min)\n- \"See all activity\" link opens full ActivityPanel\n- Hidden when: NodeDetail sidebar open, ActivityPanel open, or timeline active\n- Smooth transitions between expanded/collapsed states\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.173537+13:00","updatedAt":"2026-02-11T12:11:35.718366+13:00","closedAt":"2026-02-11T12:05:22.484041+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7r6.6"],"dependentIds":["beads-map-7r6","beads-map-7r6.3"]},{"id":"beads-map-7r6.5","title":"Create components/ActivityPanel.tsx: full slide-in sidebar with search and filters","description":"Create ActivityPanel.tsx: full slide-in sidebar with search and category filters.\n\n**Implemented:**\n- Layout: desktop w-[360px] absolute top-0 right-0 z-30, mobile bottom drawer\n- Search bar: filters by nodeId, title, actor handle, detail (case-insensitive substring)\n- 5 filter chips: Issues, Deps, Comments, Claims, Likes\n - All active by default, toggleable (min 1 active required)\n - Active chip styling: bg-emerald-50 text-emerald-700 border-emerald-200\n- Scrollable event list with full-variant ActivityItems\n- Footer: shows filtered event count\n- Same slide-in pattern as AllCommentsPanel\n- Close button with X icon\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.301972+13:00","updatedAt":"2026-02-11T12:11:38.561844+13:00","closedAt":"2026-02-11T12:05:22.615127+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7r6.6"],"dependentIds":["beads-map-7r6","beads-map-7r6.3"]},{"id":"beads-map-7r6.6","title":"Add Activity pill to header and wire overlay + panel in page.tsx","description":"Wire Activity pill button in header navbar and render ActivityOverlay + ActivityPanel.\n\n**Implemented:**\n- Added \"Activity\" pill button in header (between Comments and Auth divider)\n- Active state styling when activityPanelOpen\n- Rendered ActivityOverlay inside graph area div\n- Rendered ActivityPanel after AllCommentsPanel\n- Mutual exclusivity: opening ActivityPanel closes NodeDetail and AllCommentsPanel\n- ActivityOverlay hides when any sidebar or timeline is active\n- Props wired: feed, onNodeClick, onOpenPanel, onToggleCollapse, visibility flags\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.426965+13:00","updatedAt":"2026-02-11T12:11:42.290477+13:00","closedAt":"2026-02-11T12:05:22.74963+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":4,"blockerIds":["beads-map-7r6.8"],"dependentIds":["beads-map-7r6","beads-map-7r6.2","beads-map-7r6.4","beads-map-7r6.5"]},{"id":"beads-map-7r6.7","title":"Extend diff engine to track owner and assignee changes","description":"Extend the diff engine in lib/diff-beads.ts to track owner and assignee field changes.\n\n**Implemented:**\n- Added owner field comparison at line 84: `if ((old.owner || \"\") !== (node.owner || \"\"))`\n- Generates node-owner-changed diff when owner field changes\n- Enables activity feed to show \"owner changed\" events in real-time\n","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.551848+13:00","updatedAt":"2026-02-11T12:11:45.586883+13:00","closedAt":"2026-02-11T12:05:22.877032+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7r6.2"],"dependentIds":["beads-map-7r6","beads-map-7r6.1"]},{"id":"beads-map-7r6.8","title":"Build, verify, and push activity feed feature","description":"Build, verify, and commit the complete activity feed feature.\n\n**Implemented:**\n- Ran pnpm build to verify no TypeScript errors\n- Tested activity overlay and panel in dev mode\n- Verified historical feed generation from existing data\n- Confirmed real-time event updates from SSE\n- Verified search and filter functionality in ActivityPanel\n- Committed changes with message: \"Activity feed: historical + real-time event log with compact overlay and expandable panel\"\n- Commit hash: ea51cb7\n","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T11:54:22.674715+13:00","updatedAt":"2026-02-11T12:11:48.579538+13:00","closedAt":"2026-02-11T12:05:23.005815+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-7r6","beads-map-7r6.6"]},{"id":"beads-map-8np","title":"Epic: Surface owner/assignee in tooltip and search","description":"Two enhancements: (1) Show owner and assignee in the node hover tooltip (BeadTooltip) when present. (2) Make the search bar match on owner and assignee names so typing 'daviddao' finds all nodes assigned to or owned by that person.","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:33:49.054947+13:00","updatedAt":"2026-02-12T10:35:36.891487+13:00","closedAt":"2026-02-12T10:35:36.891487+13:00","closeReason":"Completed: 0c7b4e1 — all tasks done","prefix":"beads-map","blockerCount":4,"dependentCount":1,"blockerIds":["beads-map-8np.1","beads-map-8np.2","beads-map-8np.3","beads-map-mfw"],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-8np.1","title":"Add assignee and createdBy to GraphNode and buildGraphData","description":"In lib/types.ts: add 'assignee?: string' and 'createdBy?: string' fields to GraphNode interface. In lib/parse-beads.ts buildGraphData() (line ~140): map 'assignee: issue.assignee' and 'createdBy: issue.created_by' into the GraphNode object. In lib/diff-beads.ts: if assignee/createdBy changes should trigger _changedAt, add them to the diff comparison (optional — they're display-only so probably not needed).","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:33:56.34265+13:00","updatedAt":"2026-02-12T10:35:36.639771+13:00","closedAt":"2026-02-12T10:35:36.639771+13:00","closeReason":"Completed: 0c7b4e1","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-8np.2","beads-map-8np.3"],"dependentIds":["beads-map-8np"]},{"id":"beads-map-8np.2","title":"Show owner and assignee in BeadTooltip","description":"In components/BeadTooltip.tsx: add two new metadata rows between 'Created' and 'Blocked by'. (1) 'Owner' row showing node.owner if present. (2) 'Assignee' row showing node.assignee if present. Both should be conditionally rendered — only show when the value exists. Style: same labelStyle/valueStyle as existing rows.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:34:01.699002+13:00","updatedAt":"2026-02-12T10:35:36.724586+13:00","closedAt":"2026-02-12T10:35:36.724586+13:00","closeReason":"Completed: 0c7b4e1","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-8np","beads-map-8np.1"]},{"id":"beads-map-8np.3","title":"Extend search bar to match on owner and assignee","description":"In app/page.tsx searchResults useMemo (line ~756): extend the searchable string from 'n.id n.title n.prefix' to include 'n.owner n.assignee n.createdBy' (with fallback to empty string for undefined values). This lets users type 'daviddao' and see all nodes owned by or assigned to that person. No UI changes to the result rendering needed — the existing display (id, title, prefix badge) is sufficient.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:34:07.488931+13:00","updatedAt":"2026-02-12T10:35:36.807758+13:00","closedAt":"2026-02-12T10:35:36.807758+13:00","closeReason":"Completed: 0c7b4e1","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-8np","beads-map-8np.1"]},{"id":"beads-map-8tp","title":"v0.3.1: Catppuccin color modes, help sidebar, and UX polish","description":"## Overview\n\nEpic covering all features added between v0.3.0 and v0.3.1. This release introduced a legend color mode selector (5 modes), the Catppuccin Latte accent palette for all prefix-colored elements, a cluster visibility toggle, copy-to-clipboard for descriptions, tooltip enhancements, a Help sidebar, and comprehensive README updates.\n\n## Commits (13 total, chronological)\n\n1. c93622d — Add legend color mode selector with Catppuccin Mocha palette\n2. 31ae0c7 — Match cluster circle color to Catppuccin prefix palette when in prefix color mode\n3. 13e5bc8 — Use Catppuccin prefix colors for node rings, cluster circles, and tooltip accent bar\n4. c2e815a — Switch from Catppuccin Mocha to Latte palette for better contrast on white background\n5. 6cfc26c — Add toggle to show/hide hierarchical cluster labels when zoomed out\n6. bfe2714 — v0.3.1: version bump\n7. b499aac — Add copy-to-clipboard button for descriptions in modal and detail panel\n8. 3381968 — Show prefix label and issue ID in hover tooltip\n9. 7d5f774 — Include prefix, ID, and repo URL header when copying descriptions\n10. b630c89 — Add priority color mode to legend selector\n11. d6e6391 — Update README with all new features\n12. 63d1c38 — Add Help sidebar with casual plain-English guide\n\n## Files created\n- components/HelpPanel.tsx — Help sidebar with casual feature guide\n\n## Files modified\n- lib/types.ts — ColorMode type, Catppuccin Latte palette, getPersonColor(), getCatppuccinPrefixColor()\n- app/page.tsx — colorMode state, helpPanelOpen state, navbar Help pill, mutual exclusivity wiring\n- components/BeadsGraph.tsx — color-mode-aware getNodeColor(), legend selector UI, legendItems useMemo, cluster toggle, priority mode\n- components/BeadTooltip.tsx — prefix label + issue ID row\n- components/DescriptionModal.tsx — copy button with repo URL header\n- components/NodeDetail.tsx — copy button with repo URL header\n- lib/utils.ts — buildDescriptionCopyText() helper\n- package.json — version 0.3.0 → 0.3.1\n- README.md — documented all new features\n\n## Release\n- npm: beads-map@0.3.1 published\n- GitHub: pushed to GainForest/beads-map main","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:12:09.279906+13:00","updatedAt":"2026-02-12T15:15:22.662961+13:00","closedAt":"2026-02-12T15:15:22.662961+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":10,"dependentCount":0,"blockerIds":["beads-map-8tp.1","beads-map-8tp.10","beads-map-8tp.2","beads-map-8tp.3","beads-map-8tp.4","beads-map-8tp.5","beads-map-8tp.6","beads-map-8tp.7","beads-map-8tp.8","beads-map-8tp.9"],"dependentIds":[]},{"id":"beads-map-8tp.1","title":"Legend color mode selector (Status, Owner, Assignee, Prefix)","description":"## What\n\nAdded a color mode selector to the bottom-right legend panel. Users can switch node body fill color between 4 modes (later extended to 5 with priority in task .8).\n\n## Commit\n- c93622d — Add legend color mode selector with Catppuccin Mocha palette\n\n## Files modified\n\n### lib/types.ts\n- Added \\`ColorMode\\` type: \\`\"status\" | \"owner\" | \"assignee\" | \"prefix\"\\`\n- Added \\`COLOR_MODE_LABELS\\` record with display names\n- Added \\`CATPPUCCIN_MOCHA_ACCENTS\\` array (14 hex colors, reordered for max contrast between adjacent indices — alternating warm/cool)\n- Added \\`CATPPUCCIN_ACCENT_NAMES\\` array for legend labels\n- Added \\`CATPPUCCIN_UNASSIGNED\\` constant (#585b70, Mocha Surface2)\n- Added \\`getPersonColor(person)\\` function: FNV-1a hash mod 14 into Catppuccin palette, returns CATPPUCCIN_UNASSIGNED for undefined/empty\n- Added \\`getCatppuccinPrefixColor(prefix)\\` function: delegates to getPersonColor\n\n### app/page.tsx\n- Added \\`colorMode\\` state (useState<ColorMode>(\"status\"))\n- Imported \\`ColorMode\\` type\n- Passed \\`colorMode\\` and \\`onColorModeChange={setColorMode}\\` props to \\`<BeadsGraph>\\`\n\n### components/BeadsGraph.tsx\n- Added \\`ColorMode\\` type import and new type imports (\\`COLOR_MODE_LABELS\\`, \\`getPersonColor\\`, \\`getCatppuccinPrefixColor\\`, \\`getPrefixLabel\\`)\n- Added \\`colorMode\\` and \\`onColorModeChange\\` to \\`BeadsGraphProps\\` interface\n- Added module-level \\`let _currentColorMode: ColorMode = \"status\"\\` variable\n- Converted \\`getNodeColor(node)\\` from simple status lookup to a switch on \\`_currentColorMode\\` (status/owner/assignee/prefix)\n- Added \\`colorModeRef\\` + sync useEffect that updates \\`_currentColorMode\\`, calls \\`refreshGraph()\\`, and redraws minimap\n- Added \\`legendItems\\` useMemo: computes dynamic legend entries from \\`viewNodes\\` based on color mode (Map<label, color>, sorted alphabetically with \"Unassigned\" last)\n- Replaced static legend panel with: segmented control (4 buttons, emerald-500 active), dynamic legend section (status dots or person/prefix dots), mode-aware hint text\n\n## Key design decisions\n- **Module-level \\`_currentColorMode\\`**: \\`paintNode\\` has \\`[]\\` deps so it can't read props. The module-level variable is synced from the useEffect and read synchronously by \\`getNodeColor\\` during canvas painting.\n- **legendItems depends on viewNodes**: Only shows people/prefixes present in currently visible nodes, not all possible values.\n- **Segmented control style**: Matches the existing layout mode buttons (zinc-100 bg, emerald-500 active, 10px font).","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:12:32.784315+13:00","updatedAt":"2026-02-12T15:15:22.355789+13:00","closedAt":"2026-02-12T15:15:22.355789+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-8tp.2","beads-map-8tp.8","beads-map-8tp.9"],"dependentIds":["beads-map-8tp"]},{"id":"beads-map-8tp.10","title":"Add Help sidebar with casual plain-English feature guide","description":"## What\n\nAdded a \"Help\" button in the navbar that opens a right sidebar with a casual, friendly guide explaining all of Heartbeads' features in plain English. Follows the same sidebar pattern as Comments and Activity (mutually exclusive, 360px slide-in, mobile bottom drawer).\n\n## Commit\n- 63d1c38 — Add Help sidebar with casual plain-English guide to all features\n\n## Files created\n\n### components/HelpPanel.tsx (NEW)\nTwo-part component:\n\n1. **HelpPanel** (exported): Renders the sidebar wrapper:\n - Desktop: \\`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30\\` with \\`translate-x-0\\` / \\`translate-x-full\\` animation\n - Mobile: \\`md:hidden fixed inset-x-0 bottom-0 z-20\\` bottom drawer with \\`rounded-t-2xl max-h-[70vh]\\`\n - Header: \"Welcome to Heartbeads\" + close X button\n - Content: scrollable \\`<HelpContent />\\`\n\n2. **HelpContent** (internal): The actual help text, structured as:\n - **Intro**: \"Your command center for AI coding tasks\" + what the graph shows\n - **The graph**: circles, arrows, rings, solid vs dashed\n - **Getting around**: click, hover, right-click, scroll, drag, Cmd+F\n - **Layouts**: Force, DAG, Radial, Cluster, Spread\n - **Color modes**: Status, Priority, Owner, Assignee, Prefix\n - **More cool stuff**: Collapse/Expand, Clusters, Replay, Comments, Claim tasks, Minimap, Copy descriptions\n - **Footer**: \"Built with beads\" link to GitHub\n\n Uses a \\`SectionTitle\\` helper component (\\`text-xs font-semibold text-zinc-500 uppercase tracking-wider\\`) and bullet lists with \\`--\\` dashes for a casual look.\n\n## Files modified\n\n### app/page.tsx\n\n1. **Import**: Added \\`import { HelpPanel } from \"@/components/HelpPanel\"\\`\n\n2. **State** (line ~251): \\`const [helpPanelOpen, setHelpPanelOpen] = useState(false)\\`\n\n3. **Navbar Help pill** (after Activity, before divider):\n - Same pill styling as Comments/Activity: \\`px-4 py-2 text-sm font-medium rounded-full\\`\n - Active: \\`text-emerald-700 bg-emerald-50\\`\n - Icon: question-mark-circle SVG (Heroicons outline)\n - Label: \"Help\" (hidden on mobile)\n\n4. **Mutual exclusivity** (6 locations updated):\n - \\`handleNodeClick\\`: added \\`setHelpPanelOpen(false)\\`\n - Comments toggle: added \\`setHelpPanelOpen(false)\\` when opening\n - Activity toggle: added \\`setHelpPanelOpen(false)\\` when opening\n - Activity \"See all\": added \\`setHelpPanelOpen(false)\\`\n - Help toggle: closes \\`selectedNode\\`, \\`allCommentsPanelOpen\\`, \\`activityPanelOpen\\` when opening\n\n5. **sidebarOpen prop**: Extended to \\`!!selectedNode || allCommentsPanelOpen || activityPanelOpen || helpPanelOpen\\`\n\n6. **ActivityOverlay guard**: Extended to include \\`!helpPanelOpen\\`\n\n7. **Rendering** (after ActivityPanel, before closing \\`</div>\\`):\n \\`\\`\\`tsx\n <HelpPanel isOpen={helpPanelOpen} onClose={() => setHelpPanelOpen(false)} />\n \\`\\`\\`\n\n## Navbar order\n\\`\\`\\`\n[Replay] [Comments] [Activity] [Help] | [Sign in]\n\\`\\`\\`","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:15:12.205912+13:00","updatedAt":"2026-02-12T15:15:22.631825+13:00","closedAt":"2026-02-12T15:15:22.631825+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-8tp"]},{"id":"beads-map-8tp.2","title":"Unify all prefix colors to Catppuccin palette (rings, clusters, tooltip)","description":"## What\n\nMade all prefix-colored elements use the Catppuccin accent palette consistently instead of the old FNV-hash HSL colors. Previously, the node ring, cluster circles, and tooltip accent bar each used PREFIX_COLORS (FNV-hash → HSL), while the new color mode used Catppuccin. This created a visual mismatch. Now everything is Catppuccin.\n\n## Commits\n- 31ae0c7 — Match cluster circle color to Catppuccin prefix palette when in prefix color mode\n- 13e5bc8 — Use Catppuccin prefix colors for node rings, cluster circles, and tooltip accent bar\n\n## Files modified\n\n### components/BeadsGraph.tsx\n- **getPrefixColor → getPrefixRingColor**: Renamed the module-level function (line ~91). Changed body from \\`PREFIX_COLORS[node.prefix] || \"#a1a1aa\"\\` to \\`getCatppuccinPrefixColor(node.prefix)\\`.\n- **Cluster circle color** (line ~1393 in paintClusterLabels): Changed from \\`PREFIX_COLORS[cluster.prefix] || \"hsl(0, 0%, 65%)\"\\` to \\`getCatppuccinPrefixColor(cluster.prefix)\\`. Always uses Catppuccin regardless of color mode since clusters always represent projects.\n- **Removed PREFIX_COLORS import**: No longer needed in BeadsGraph.tsx.\n- **paintNode call site** (line ~898): Updated from \\`getPrefixColor(graphNode)\\` to \\`getPrefixRingColor(graphNode)\\`.\n\n### app/page.tsx\n- **Tooltip accent bar** (line ~1388): Changed from \\`getPrefixColor(nodeTooltip.node.prefix)\\` to \\`getCatppuccinPrefixColor(nodeTooltip.node.prefix)\\`.\n- **Import**: Replaced \\`getPrefixColor\\` with \\`getCatppuccinPrefixColor\\` from \\`@/lib/types\\`.\n\n## Result\nAll prefix-colored elements now use \\`getCatppuccinPrefixColor()\\` via FNV-1a hash mod 14, ensuring a given project prefix always maps to the same Catppuccin color everywhere.","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:12:51.583264+13:00","updatedAt":"2026-02-12T15:15:22.387688+13:00","closedAt":"2026-02-12T15:15:22.387688+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-8tp.3","beads-map-8tp.9"],"dependentIds":["beads-map-8tp","beads-map-8tp.1"]},{"id":"beads-map-8tp.3","title":"Switch Catppuccin from Mocha to Latte for light backgrounds","description":"## What\n\nSwapped all 14 Catppuccin accent hex values from Mocha (pastel, designed for dark backgrounds) to Latte (saturated, designed for light backgrounds). The app has a white/zinc-50 background so Mocha colors were too washed out.\n\n## Commit\n- c2e815a — Switch from Catppuccin Mocha to Latte palette for better contrast on white background\n\n## File modified: lib/types.ts (single source of truth)\n\n- **Renamed** \\`CATPPUCCIN_MOCHA_ACCENTS\\` → \\`CATPPUCCIN_ACCENTS\\` (flavor-agnostic name)\n- **Swapped all 14 hex values** (same contrast-maximizing order):\n\n| Name | Mocha (old) | Latte (new) |\n|---|---|---|\n| Red | #f38ba8 | #d20f39 |\n| Teal | #94e2d5 | #179299 |\n| Peach | #fab387 | #fe640b |\n| Blue | #89b4fa | #1e66f5 |\n| Green | #a6e3a1 | #40a02b |\n| Mauve | #cba6f7 | #8839ef |\n| Yellow | #f9e2af | #df8e1d |\n| Sapphire | #74c7ec | #209fb5 |\n| Pink | #f5c2e7 | #ea76cb |\n| Sky | #89dceb | #04a5e5 |\n| Maroon | #eba0b3 | #e64553 |\n| Lavender | #b4befe | #7287fd |\n| Flamingo | #f2cdcd | #dd7878 |\n| Rosewater | #f5e0dc | #dc8a78 |\n\n- **Unassigned color**: Mocha Surface2 (#585b70) → Latte Surface2 (#acb0be)\n- **Updated** \\`getPersonColor()\\` to reference \\`CATPPUCCIN_ACCENTS\\` instead of \\`CATPPUCCIN_MOCHA_ACCENTS\\`\n- **Updated** all doc comments from \"Mocha\" to \"Latte\"\n\n## Why Latte\nCatppuccin Latte is the light-background flavor with saturated, high-contrast colors. Since the app renders on a white canvas with zinc-50 backgrounds, Latte colors are immediately distinguishable while Mocha pastels blended into the background.","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:13:08.949643+13:00","updatedAt":"2026-02-12T15:15:22.418953+13:00","closedAt":"2026-02-12T15:15:22.418953+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-8tp.9"],"dependentIds":["beads-map-8tp","beads-map-8tp.2"]},{"id":"beads-map-8tp.4","title":"Add show/hide toggle for hierarchical cluster labels","description":"## What\n\nAdded a \"Clusters\" toggle button in the top-left toolbar to show/hide the hierarchical cluster circles and labels that appear when zoomed out. Previously clusters were always visible; now users can hide them for a cleaner view.\n\n## Commit\n- 6cfc26c — Add toggle to show/hide hierarchical cluster labels when zoomed out\n\n## File modified: components/BeadsGraph.tsx\n\n### 1. New state (line ~278)\n\\`\\`\\`typescript\nconst [showClusters, setShowClusters] = useState(true);\n\\`\\`\\`\nDefaults to true (preserves existing behavior).\n\n### 2. Guard in paintClusterLabels (line ~1333)\nAdded \\`if (!showClusters) return;\\` at the top of the \\`paintClusterLabels\\` callback, before any computation. Added \\`showClusters\\` to the dependency array: \\`[viewNodes, nodes, showClusters]\\`.\n\n### 3. Toggle button in top-left controls\nPlaced after the Collapse/Expand button, as the rightmost control in the top-left toolbar row:\n\\`\\`\\`\n[Force][DAG][Radial][Cluster][Spread] [Collapse all] [Clusters]\n\\`\\`\\`\n\nButton styling:\n- **Active (green)**: \\`bg-emerald-500 text-white border-emerald-500\\` — clusters visible\n- **Inactive (white)**: \\`bg-white/90 text-zinc-500 border-zinc-200\\` — clusters hidden\n- SVG icon: dashed circle with horizontal lines (representing the cluster overlay)\n- Label: \"Clusters\" (hidden on mobile via \\`hidden sm:inline\\`)\n- Title tooltip: \"Hide cluster labels\" / \"Show cluster labels\"\n\n### Behavior\n- **ON (default)**: Cluster circles fade in when zoomed out past globalScale 0.8, fully visible below 0.4. Shows dashed prefix-colored circle, epic title, epic ID, and member count at each cluster centroid.\n- **OFF**: No cluster rendering at any zoom level. The early return in paintClusterLabels skips all computation.","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:13:26.551878+13:00","updatedAt":"2026-02-12T15:15:22.448574+13:00","closedAt":"2026-02-12T15:15:22.448574+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-8tp.9"],"dependentIds":["beads-map-8tp"]},{"id":"beads-map-8tp.5","title":"Add copy-to-clipboard button for descriptions (modal + sidebar)","description":"## What\n\nAdded a clipboard copy icon button to both the description modal (DescriptionModal) and the node detail sidebar (NodeDetail). Clicking copies the raw markdown text to clipboard with a brief checkmark feedback animation.\n\n## Commit\n- b499aac — Add copy-to-clipboard button for descriptions in modal and detail panel\n\n## Files modified\n\n### components/DescriptionModal.tsx\n- Added \\`useState\\` import and \\`copied\\` state for visual feedback\n- Added \\`handleCopy()\\` function: calls \\`navigator.clipboard.writeText(node.description)\\`, sets \\`copied=true\\` for 1.5 seconds via setTimeout\n- Added copy button in the modal header, between the title and close X button:\n - Default state: clipboard SVG icon (Heroicons clipboard-document outline, zinc-400, w-4 h-4)\n - Copied state: emerald-500 checkmark icon for 1.5 seconds\n - Both buttons wrapped in a \\`flex items-center gap-1\\` container\n\n### components/NodeDetail.tsx\n- Added \\`descCopied\\` state for feedback\n- Added copy button in the description section header, between the \"Description\" label and \"View in window\" link:\n - Same clipboard → checkmark pattern as the modal\n - Slightly smaller icons (w-3.5 h-3.5) to match the sidebar's compact design\n - Button and \"View in window\" wrapped in \\`flex items-center gap-2\\` container\n\n## UX details\n- Copies **raw markdown** (not rendered HTML) — useful for pasting into editors, chat, or issue trackers\n- 1.5-second checkmark feedback using \\`setTimeout(() => setCopied(false), 1500)\\`\n- \\`pointerEvents\\` not affected — the button is fully clickable\n- No toast/notification — the icon change itself is the feedback","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:13:41.073465+13:00","updatedAt":"2026-02-12T15:15:22.479169+13:00","closedAt":"2026-02-12T15:15:22.479169+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-8tp.7","beads-map-8tp.9"],"dependentIds":["beads-map-8tp"]},{"id":"beads-map-8tp.6","title":"Show prefix label and issue ID in hover tooltip","description":"## What\n\nAdded a row showing the project prefix label and issue ID to the hover tooltip (BeadTooltip), displayed between the accent bar and the title. This gives users immediate context about which project a node belongs to and its exact ID without clicking.\n\n## Commit\n- 3381968 — Show prefix label and issue ID in hover tooltip\n\n## File modified: components/BeadTooltip.tsx\n\n### Import\nAdded \\`getPrefixLabel\\` to the import from \\`@/lib/types\\`.\n\n### New row (between accent bar and title)\nInserted a \\`div\\` with \\`display: flex, alignItems: center, gap: 6, marginBottom: 6\\`:\n\n1. **Prefix label**: \\`<span>\\` with \\`fontSize: 10, fontWeight: 600, color: prefixColor, letterSpacing: 0.5, textTransform: uppercase\\`. Calls \\`getPrefixLabel(node.prefix)\\` which strips trailing hyphens and title-cases (e.g., \"beads-map\" → \"Beads Map\").\n\n2. **Issue ID**: \\`<span>\\` with \\`fontSize: 10, fontFamily: monospace, color: COLORS.textDim\\`. Shows \\`node.id\\` (e.g., \"beads-map-dwk.3\").\n\n### Layout adjustment\nReduced accent bar \\`marginBottom\\` from 10 to 8 to accommodate the new row without making the tooltip too tall.\n\n## Visual result\n```\n━━━━━━ (accent bar in prefix color)\nBEADS MAP beads-map-dwk.3\nUpdate BeadsGraph: color-mode-aware paintNode...\nCreated: 2h ago\n...\n```","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:13:55.420535+13:00","updatedAt":"2026-02-12T15:15:22.509818+13:00","closedAt":"2026-02-12T15:15:22.509818+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-8tp.9"],"dependentIds":["beads-map-8tp"]},{"id":"beads-map-8tp.7","title":"Include prefix, ID, and repo URL header in copied description text","description":"## What\n\nEnhanced the copy-to-clipboard feature (task .5) so that copied text includes a metadata header above the description: project prefix label, issue ID, and GitHub repo URL (if available). Previously it copied only the raw description markdown.\n\n## Commit\n- 7d5f774 — Include prefix, ID, and repo URL header when copying descriptions\n\n## Files modified\n\n### lib/utils.ts\nAdded \\`buildDescriptionCopyText(node, repoUrl?)\\` helper function:\n\\`\\`\\`typescript\nexport function buildDescriptionCopyText(\n node: GraphNode,\n repoUrl?: string,\n): string {\n const lines: string[] = [];\n lines.push(\\`[\\${getPrefixLabel(node.prefix)}] \\${node.id}\\`);\n if (repoUrl) lines.push(repoUrl);\n lines.push(\"\");\n lines.push(node.description || \"\");\n return lines.join(\"\\\\n\");\n}\n\\`\\`\\`\nAdded imports: \\`getPrefixLabel\\` and \\`GraphNode\\` from \\`@/lib/types\\`.\n\nOutput format:\n\\`\\`\\`\n[Beads Map] beads-map-dwk.3\nhttps://github.com/GainForest/beads-map\n\n## What\nThis is the core task...\n\\`\\`\\`\n\n### components/DescriptionModal.tsx\n- Added \\`repoUrl?: string\\` prop to \\`DescriptionModalProps\\`\n- Imported \\`buildDescriptionCopyText\\` from \\`@/lib/utils\\`\n- Updated \\`handleCopy()\\` to call \\`buildDescriptionCopyText(node, repoUrl)\\` instead of \\`node.description\\`\n\n### components/NodeDetail.tsx\n- Imported \\`buildDescriptionCopyText\\` from \\`@/lib/utils\\`\n- Updated inline copy handler to call \\`buildDescriptionCopyText(node, repoUrls?.[node.prefix])\\`\n- Updated \\`<DescriptionModal>\\` rendering to pass \\`repoUrl={repoUrls?.[node.prefix]}\\`\n\n### app/page.tsx\n- Updated \\`<DescriptionModal>\\` (from context menu) to pass \\`repoUrl={repoUrls[descriptionModalNode.prefix]}\\`\n\n## Data flow for repoUrl\n\\`repoUrls\\` is a \\`Record<string, string>\\` mapping project prefix → GitHub URL. Fetched from \\`/api/config\\` which reads git remote URLs via \\`getRepoUrls()\\` in \\`lib/discover.ts\\`. Available in \\`page.tsx\\` state, passed to \\`NodeDetail\\` as prop, and now also passed to \\`DescriptionModal\\`.","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:14:12.522908+13:00","updatedAt":"2026-02-12T15:15:22.540463+13:00","closedAt":"2026-02-12T15:15:22.540463+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-8tp.9"],"dependentIds":["beads-map-8tp","beads-map-8tp.5"]},{"id":"beads-map-8tp.8","title":"Add priority color mode to legend selector","description":"## What\n\nAdded \"Priority\" as a 5th color mode in the legend selector. Nodes are colored by their priority level (P0–P4) using the existing PRIORITY_COLORS palette.\n\n## Commit\n- b630c89 — Add priority color mode to legend selector (P0-P4 with red/orange/blue/zinc)\n\n## Files modified\n\n### lib/types.ts\n- Extended \\`ColorMode\\` type: \\`\"status\" | \"owner\" | \"assignee\" | \"prefix\"\\` → \\`\"status\" | \"priority\" | \"owner\" | \"assignee\" | \"prefix\"\\`\n- Added \\`priority: \"Priority\"\\` to \\`COLOR_MODE_LABELS\\` record\n\n### components/BeadsGraph.tsx\n\n1. **Imports** (line ~15): Added \\`PRIORITY_COLORS\\` and \\`PRIORITY_LABELS\\` to the import from \\`@/lib/types\\`.\n\n2. **getNodeColor** (line ~77): Added priority case to the switch:\n \\`\\`\\`typescript\n case \"priority\":\n return PRIORITY_COLORS[node.priority] || PRIORITY_COLORS[2];\n \\`\\`\\`\n Falls back to P2 (blue) for unknown priorities.\n\n3. **legendItems useMemo** (line ~466): Updated guard from \\`if (colorMode === \"status\") return []\\` to \\`if (colorMode === \"status\" || colorMode === \"priority\") return []\\` since priority uses static rendering (like status).\n\n4. **Segmented control** (line ~2028): Extended the modes array from 4 to 5:\n \\`\\`\\`typescript\n ([\"status\", \"priority\", \"owner\", \"assignee\", \"prefix\"] as ColorMode[])\n \\`\\`\\`\n\n5. **Legend dots** (line ~2043): Added a priority branch between status and the dynamic fallback:\n \\`\\`\\`typescript\n colorMode === \"priority\" ? (\n [0, 1, 2, 3, 4].map((p) => (\n <span ...>\n <span style={{ backgroundColor: PRIORITY_COLORS[p] }} />\n <span>{PRIORITY_LABELS[p]}</span>\n </span>\n ))\n )\n \\`\\`\\`\n\n## Priority colors (from PRIORITY_COLORS)\n- P0 Critical — red (#ef4444)\n- P1 High — orange (#f97316)\n- P2 Medium — blue (#3b82f6)\n- P3 Low — zinc (#a1a1aa)\n- P4 Backlog — zinc-300 (#d4d4d8)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:14:28.789276+13:00","updatedAt":"2026-02-12T15:15:22.570952+13:00","closedAt":"2026-02-12T15:15:22.570952+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-8tp.9"],"dependentIds":["beads-map-8tp","beads-map-8tp.1"]},{"id":"beads-map-8tp.9","title":"Update README with all v0.3.1 features","description":"## What\n\nUpdated README.md to document all new features from the v0.3.1 release.\n\n## Commit\n- d6e6391 — Update README with legend color modes, Catppuccin Latte palette, cluster toggle, copy button, priority mode\n\n## Changes to README.md sections\n\n### Graph Visualization section\n- **Visual encoding**: Updated from \"fill color = status\" to \"fill color = configurable via legend\" with link to Catppuccin Latte palette\n- **Legend color modes**: NEW subsection documenting all 5 modes (Status, Priority, Owner, Assignee, Prefix)\n- **Catppuccin Latte palette**: NEW subsection explaining the palette choice (14 saturated colors for light backgrounds, FNV-1a hash mapping)\n- **Semantic zoom**: Added mention of cluster visibility toggle (\"Clusters\" button)\n- **Hover tooltips**: Updated to include \"project prefix, issue ID\" in the description\n\n### Node Detail Sidebar section\n- Added copy-to-clipboard button documentation (\"copies raw markdown with header showing project prefix, issue ID, and GitHub repo URL\")\n- Added Description modal documentation as a separate bullet\n\n### Multi-Repo Support section\n- Updated \"Per-project colors\" from \"FNV-1a hash\" to \"Catppuccin Latte palette (14 accent colors)\"\n- Updated \"GitHub repo links\" to mention inclusion in copied description text\n\n### Info Panel section\n- Renamed to \"Info Panel & Legend\"\n- Updated to describe the color mode selector and dynamic legend behavior\n\n### Architecture tree\n- \\`BeadTooltip.tsx\\`: \"Hover tooltip: prefix, ID, title, date, blockers, priority, owner\"\n- \\`DescriptionModal.tsx\\`: \"Full-screen markdown description modal with copy button\"\n- \\`types.ts\\`: \"GraphNode, GraphLink, ColorMode, Catppuccin palette, color helpers\"\n- \\`utils.ts\\`: \"formatRelativeTime, buildDescriptionCopyText, shared utilities\"\n\n### Tech Stack section\n- Added Catppuccin Latte link: \\`[Catppuccin Latte](https://github.com/catppuccin/palette) accent palette for prefix/person coloring (14 colors)\\`","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:14:47.211918+13:00","updatedAt":"2026-02-12T15:15:22.601639+13:00","closedAt":"2026-02-12T15:15:22.601639+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":9,"blockerIds":[],"dependentIds":["beads-map-8tp","beads-map-8tp.1","beads-map-8tp.2","beads-map-8tp.3","beads-map-8tp.4","beads-map-8tp.5","beads-map-8tp.6","beads-map-8tp.7","beads-map-8tp.8"]},{"id":"beads-map-8z1","title":"Epic: Per-epic collapse/uncollapse via right-click context menu","description":"Add ability to collapse/uncollapse individual epics via right-click context menu while in Full view. A new collapsedEpicIds Set<string> state in page.tsx tracks which epics are individually collapsed. The viewNodes/viewLinks memo in BeadsGraph.tsx reads this set: in Full view, only children of collapsed epics are hidden; in Epics view, all children are hidden (existing behavior, unchanged). Context menu shows 'Collapse epic' on expanded epic nodes and 'Uncollapse epic' on collapsed ones. Collapse/uncollapse only available in Full view mode (Epics view forces all collapsed). collapsedEpicIds is independent of the Full/Epics toggle — switching modes preserves per-epic state.","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:08.902101+13:00","updatedAt":"2026-02-12T10:56:38.122465+13:00","closedAt":"2026-02-12T10:56:38.122465+13:00","closeReason":"All 4 subtasks completed in 74d70b0: per-epic collapse/uncollapse via right-click context menu","prefix":"beads-map","blockerCount":4,"dependentCount":0,"blockerIds":["beads-map-8z1.1","beads-map-8z1.2","beads-map-8z1.3","beads-map-8z1.4"],"dependentIds":[]},{"id":"beads-map-8z1.1","title":"Add collapsedEpicIds state and toggle handler in page.tsx","description":"In app/page.tsx: (1) Add state: const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set()). (2) Add handler: const handleToggleEpicCollapse = useCallback((epicId: string) => { setCollapsedEpicIds(prev => { const next = new Set(prev); if (next.has(epicId)) next.delete(epicId); else next.add(epicId); return next; }); }, []). (3) Pass collapsedEpicIds as prop to <BeadsGraph>. Place state near other graph-related state (around line 188 near hoveredNode). Acceptance: prop is passed, handler exists, pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:17.434106+13:00","updatedAt":"2026-02-12T10:56:30.552955+13:00","closedAt":"2026-02-12T10:56:30.552955+13:00","closeReason":"Completed in 74d70b0: collapsedEpicIds state + handleToggleEpicCollapse in page.tsx","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-8z1.2","beads-map-8z1.4"],"dependentIds":["beads-map-8z1"]},{"id":"beads-map-8z1.2","title":"Integrate collapsedEpicIds into viewNodes/viewLinks memo in BeadsGraph","description":"In components/BeadsGraph.tsx: (1) Add collapsedEpicIds?: Set<string> to BeadsGraphProps (line ~36). (2) Destructure from props (line ~204). (3) Modify viewNodes/viewLinks useMemo (line ~296): Change the early return at line 297 from 'if (viewMode === \"full\") return ...' to 'if (viewMode === \"full\" && (!collapsedEpicIds || collapsedEpicIds.size === 0)) return ...'. (4) In the collapse logic, add a shouldCollapse helper: when viewMode === \"epics\" collapse all children (existing); when viewMode === \"full\" only collapse children whose parent is in collapsedEpicIds. Replace the line 'const childIds = new Set(childToParent.keys())' with a filtered set using shouldCollapse. (5) Add collapsedEpicIds to the useMemo dependency array. The rest of the memo (aggregate stats, filter nodes, remap links) stays identical — it already operates on the childIds set. Acceptance: individually collapsed epics fold their children in Full view; Epics view unchanged.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:27.393645+13:00","updatedAt":"2026-02-12T10:56:31.606551+13:00","closedAt":"2026-02-12T10:56:31.606551+13:00","closeReason":"Completed in 74d70b0: viewNodes/viewLinks memo supports per-epic collapse","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-8z1.4"],"dependentIds":["beads-map-8z1","beads-map-8z1.1"]},{"id":"beads-map-8z1.3","title":"Add Collapse/Uncollapse epic menu items to ContextMenu","description":"In components/ContextMenu.tsx: (1) Add two new optional props to ContextMenuProps: onCollapseEpic?: () => void and onUncollapseEpic?: () => void. (2) Destructure them in the component. (3) Add 'Collapse epic' button after 'Add comment' (before Claim/Unclaim): conditionally rendered when onCollapseEpic is defined. Icon: inward-pointing chevrons/arrows (collapse visual). Style: same as other menu items (w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50). (4) Add 'Uncollapse epic' button: conditionally rendered when onUncollapseEpic is defined. Icon: outward-pointing chevrons/arrows (expand visual). (5) Adjust border-b logic on 'Add comment' button: it should show border-b when any of onCollapseEpic, onUncollapseEpic, onClaimTask, onUnclaimTask follow. Acceptance: menu items render correctly when props are provided, pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:36.641477+13:00","updatedAt":"2026-02-12T10:56:32.590331+13:00","closedAt":"2026-02-12T10:56:32.590331+13:00","closeReason":"Completed in 74d70b0: Collapse/Uncollapse epic menu items in ContextMenu","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-8z1.4"],"dependentIds":["beads-map-8z1"]},{"id":"beads-map-8z1.4","title":"Wire collapse/uncollapse props in ContextMenu JSX in page.tsx","description":"In app/page.tsx, in the <ContextMenu> JSX (line ~1249): (1) Pass onCollapseEpic: set when ALL of: viewMode is 'full' (need viewMode from BeadsGraph — see note), node.issueType === 'epic', and !collapsedEpicIds.has(node.id). Calls handleToggleEpicCollapse(contextMenu.node.id) then setContextMenu(null). (2) Pass onUncollapseEpic: set when ALL of: viewMode is 'full', node.issueType === 'epic', and collapsedEpicIds.has(node.id). Same handler. NOTE on viewMode: viewMode currently lives inside BeadsGraph as internal state. Options: (a) Lift viewMode to page.tsx (cleanest but larger refactor), (b) Expose viewMode from BeadsGraph via the imperative handle (BeadsGraphHandle), (c) Add an onViewModeChange callback + viewMode prop to sync it up. Recommended: option (b) — add viewMode to the existing BeadsGraphHandle ref. Then page.tsx reads graphRef.current?.viewMode to decide. Alternatively, simpler approach: always show collapse/uncollapse on epic nodes in Full view — since the user explicitly chose the action, it's fine. We can track whether we're in epics view by checking the viewMode ref. Acceptance: right-clicking an epic in Full view shows Collapse/Uncollapse; in Epics view these items don't appear.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:50:48.968686+13:00","updatedAt":"2026-02-12T10:56:33.727649+13:00","closedAt":"2026-02-12T10:56:33.727649+13:00","closeReason":"Completed in 74d70b0: viewMode exposed via BeadsGraphHandle, props wired in page.tsx","prefix":"beads-map","blockerCount":0,"dependentCount":4,"blockerIds":[],"dependentIds":["beads-map-8z1","beads-map-8z1.1","beads-map-8z1.2","beads-map-8z1.3"]},{"id":"beads-map-9d3","title":"Epic: Add hover tooltip to graph nodes showing title, creation date, blockers, and priority","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:25.175725+13:00","updatedAt":"2026-02-12T10:30:56.956317+13:00","closedAt":"2026-02-12T10:30:56.956317+13:00","closeReason":"Completed: 6d96fa3 — all tasks done","prefix":"beads-map","blockerCount":4,"dependentCount":0,"blockerIds":["beads-map-8np","beads-map-9d3.2","beads-map-9d3.3","beads-map-9d3.4"],"dependentIds":[]},{"id":"beads-map-9d3.2","title":"Create BeadTooltip component","description":"New file: components/BeadTooltip.tsx. React component inspired by plresearch.org DependencyGraph Tooltip design. White card, fade-in animation (0.2s translateY), colored accent bar (node prefix color), pointerEvents:none, position:fixed. Shows: (1) Title 14px semibold, (2) Created date via formatRelativeTime from lib/utils.ts, (3) Blocked by section listing dependentIds as short IDs or 'None', (4) Priority with PRIORITY_COLORS dot + PRIORITY_LABELS from lib/types.ts. Smart viewport clamping: prefer above cursor, flip below if no room. Width ~280px, border-radius 8px, shadow 0 8px 32px rgba(0,0,0,0.08). Props: node:GraphNode, x:number, y:number, prefixColor:string, allNodes:GraphNode[] (resolve blocker IDs to titles).","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:40.268918+13:00","updatedAt":"2026-02-12T10:30:56.779274+13:00","closedAt":"2026-02-12T10:30:56.779274+13:00","closeReason":"Completed: 6d96fa3","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-9d3.3","title":"Wire hover tooltip state in page.tsx","description":"In app/page.tsx: (1) Add nodeTooltip state: { node: GraphNode; x: number; y: number } | null. (2) Modify handleNodeHover to accept (node, x, y) from BeadsGraph. (3) Render <BeadTooltip> in the graph area (alongside existing avatar tooltip) when nodeTooltip is set. Pass allNodes from data.graphData.nodes so tooltip can resolve blocker IDs to titles. Use getPrefixColor(node.prefix) for the accent color.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:46.061095+13:00","updatedAt":"2026-02-12T10:30:56.86785+13:00","closedAt":"2026-02-12T10:30:56.86785+13:00","closeReason":"Completed: 6d96fa3","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-9d3.4","title":"Pass mouse position from BeadsGraph on hover","description":"In components/BeadsGraph.tsx: (1) Track last mouse position via mousemove listener on the container div (same pattern as avatar hover hit-testing). Store in a ref: lastMouseRef = useRef({x:0,y:0}). (2) Update onNodeHover prop type from (node: GraphNode | null) => void to (node: GraphNode | null, x: number, y: number) => void. (3) In the ForceGraph2D onNodeHover handler, read lastMouseRef.current and pass clientX/clientY along with the node. (4) Update BeadsGraphProps interface accordingly.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:26:51.724328+13:00","updatedAt":"2026-02-12T10:30:56.688848+13:00","closedAt":"2026-02-12T10:30:56.688848+13:00","closeReason":"Completed: 6d96fa3","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-9d3"]},{"id":"beads-map-9lm","title":"Epic: Add radial, cluster-by-prefix, and spread graph layouts inspired by beads_viewer reference project","description":"Add three new graph layout modes to the beads-map force graph, inspired by the beads_viewer_for_agentic_coding_flywheel_setup reference project. Currently we have Force (physics-based) and DAG (topological top-down). This epic adds: (1) Radial — arranges nodes in concentric rings by dependency depth using d3.forceRadial, centered on origin. Root nodes (no incoming blockers) sit at center, deeper nodes in outer rings. (2) Cluster — groups nodes spatially by their project prefix (e.g., beads-map, beads, etc.) using d3.forceX/forceY pulling nodes toward prefix-specific center points arranged in a circle. Useful for multi-repo graphs to see project boundaries. (3) Spread — same physics as Force but with much stronger repulsion (charge -300), larger link distances (180), and weaker center pull. Maximizes spacing for readability and screenshot exports. All three are implemented purely via d3-force configuration in the existing layout useEffect in BeadsGraph.tsx — no new components or files needed. The dagMode prop on ForceGraph2D is only 'td' for DAG; all other modes use undefined (physics-only). Reference: beads_viewer_for_agentic_coding_flywheel_setup/graph.js lines 2420-2465 (applyRadialLayout, applyClusterLayout, applyForceLayout functions).","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:23:43.98936+13:00","updatedAt":"2026-02-12T11:30:38.998185+13:00","closedAt":"2026-02-12T11:30:38.998185+13:00","closeReason":"All 5 subtasks completed: imports (0137bf2), radial+cluster+spread forces (cee4c87), UI buttons (8d08e1c)","prefix":"beads-map","blockerCount":5,"dependentCount":0,"blockerIds":["beads-map-9lm.1","beads-map-9lm.3","beads-map-9lm.4","beads-map-9lm.5","beads-map-9lm.6"],"dependentIds":[]},{"id":"beads-map-9lm.1","title":"Add d3-force imports and extend LayoutMode type","description":"In components/BeadsGraph.tsx, make two changes: (1) Line 12 — extend the d3-force import from 'import { forceCollide } from \"d3-force\"' to 'import { forceCollide, forceRadial, forceX, forceY } from \"d3-force\"'. All four are exported from d3-force (verified: node_modules/d3-force/src/ contains radial.js, x.js, y.js alongside collide.js). (2) Line 21 — extend the LayoutMode type from 'type LayoutMode = \"force\" | \"dag\"' to 'type LayoutMode = \"force\" | \"dag\" | \"radial\" | \"cluster\" | \"spread\"'. This is a prerequisite for all three layout tasks — the new imports are used by radial (forceRadial) and cluster (forceX, forceY) layouts, and the type extension lets all five layouts be assigned to layoutMode state. Note: the existing dagMode prop logic (layoutMode === \"dag\" ? \"td\" : undefined at ~line 1876) automatically handles the new modes correctly since none equal \"dag\". Acceptance: pnpm build passes with zero errors. The new LayoutMode values are usable in useState and the switch branches.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:23:50.369669+13:00","updatedAt":"2026-02-12T11:28:17.095657+13:00","closedAt":"2026-02-12T11:28:17.095657+13:00","closeReason":"Completed: 0137bf2","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-9lm.3","beads-map-9lm.4","beads-map-9lm.5"],"dependentIds":["beads-map-9lm"]},{"id":"beads-map-9lm.3","title":"Implement radial layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"radial\")' branch after the existing 'dag' and 'force' branches. Implementation steps: (1) COMPUTE BFS DEPTH: Build an incoming-edges map from viewLinks — only count 'blocks' edges (skip 'parent-child'). Find root nodes (those with no incoming blocks edges). BFS outward from roots: for each node reached, set depth = parent_depth + 1. Store depth transiently as node._depth on each viewNode object (underscore prefix = transient animation metadata convention per AGENTS.md). Nodes unreachable from any root get _depth = 0. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as the force branch ~line 642) — these may be left over from DAG mode which sets fixed positions. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-100).distanceMax(300); fg.d3Force('link')?.distance(80).strength(0.5); fg.d3Force('center')?.strength(0.01); fg.d3Force('radial', forceRadial((node: any) => ((node as any)._depth || 0) * 80, 0, 0).strength(0.5)); fg.d3Force('x', forceX(0).strength(0.05)); fg.d3Force('y', forceY(0).strength(0.05)); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 5).strength(0.6)). (4) CROSS-TASK CLEANUP: The existing 'dag' and 'force' branches must be updated to clear the new custom forces — add fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); at the start of each non-radial branch. This prevents stale radial/x/y forces from persisting when switching away from radial mode. (5) ADD viewLinks TO USEEFFECT DEPS: Currently the deps are [layoutMode, viewNodes.length]. The radial BFS reads viewLinks, so add viewLinks to the dependency array. Edge cases: (a) If graph has cycles, BFS may not reach all nodes — default _depth=0 is fine, they cluster at center. (b) If all nodes are roots (no blocks edges), all get _depth=0 and cluster at center ring — this is correct behavior for a flat graph. Acceptance: selecting Radial layout arranges nodes in concentric rings by dependency depth. Root nodes at center, leaf nodes on outer rings. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:08.116392+13:00","updatedAt":"2026-02-12T11:29:23.576116+13:00","closedAt":"2026-02-12T11:29:23.576116+13:00","closeReason":"Completed: cee4c87","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-9lm.6"],"dependentIds":["beads-map-9lm","beads-map-9lm.1"]},{"id":"beads-map-9lm.4","title":"Implement cluster-by-prefix layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"cluster\")' branch. Implementation steps: (1) COMPUTE PREFIX CENTERS: Get unique prefixes from viewNodes via new Set(viewNodes.map(n => n.prefix)). Arrange center positions in a circle: radius = Math.max(200, prefixes.length * 50). For each prefix at index i, center = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius } where angle = (2 * Math.PI * i / count) - Math.PI / 2 (start from top). Store in a local Map<string, {x: number, y: number}>. (2) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same as force/radial branches). (3) CLEAR STALE FORCES: fg.d3Force('radial', null) to remove any leftover radial force from a previous layout. (4) CONFIGURE FORCES: fg.d3Force('x', forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.3)); fg.d3Force('y', forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.3)); fg.d3Force('charge')?.strength(-40).distanceMax(250); fg.d3Force('link')?.distance(60).strength(0.3); fg.d3Force('center')?.strength(0.01); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 6).strength(0.7)). Design notes: Uses forceX/forceY per-node accessors to pull each node toward its prefix cluster center. Weaker charge (-40) keeps nodes within their cluster rather than repelling to distant positions. Cross-prefix links will stretch across clusters, making inter-project dependencies visually obvious. Edge cases: (a) Single-prefix graph — all nodes cluster at one position, which is fine (effectively same as force). (b) node.prefix is always set per lib/types.ts:51 so the Map lookup always succeeds. Acceptance: selecting Cluster layout spatially groups nodes by project prefix. Multi-repo graphs show distinct clusters. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:14.896035+13:00","updatedAt":"2026-02-12T11:29:23.709649+13:00","closedAt":"2026-02-12T11:29:23.709649+13:00","closeReason":"Completed: cee4c87","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-9lm.6"],"dependentIds":["beads-map-9lm","beads-map-9lm.1"]},{"id":"beads-map-9lm.5","title":"Implement spread layout force configuration","description":"In components/BeadsGraph.tsx, in the layout useEffect (~line 598), add an 'else if (layoutMode === \"spread\")' branch. This is the simplest layout — identical to the existing force branch but with tuned parameters for maximum spacing and readability. Implementation steps: (1) CLEAR FIXED POSITIONS: Delete fx/fy on all viewNodes (same pattern as force branch ~line 642). (2) CLEAR STALE FORCES: fg.d3Force('radial', null); fg.d3Force('x', null); fg.d3Force('y', null); — remove any custom forces from radial/cluster modes. (3) CONFIGURE FORCES: fg.d3Force('charge')?.strength(-300).distanceMax(500); fg.d3Force('link')?.distance(180).strength(0.4); fg.d3Force('center')?.strength(0.02); fg.d3Force('collision', forceCollide().radius((node: any) => getNodeSize(node as GraphNode) + 8).strength(0.8)). Key differences from force mode: charge is -300 vs -180 (much stronger repulsion), link distance is 180 vs 90-120 (wider gaps), center is 0.02 vs 0.03 (weaker pull so graph can spread), collision radius is +8 vs +6 (more buffer). Inspired by beads_viewer reference: LAYOUT_PRESETS.spread uses linkDistance 180, chargeStrength -300, centerStrength 0.02. No link distance per-connection intelligence needed (unlike force mode which varies by connection count) — uniform spacing is the point. Acceptance: selecting Spread layout produces a well-spaced graph suitable for screenshots and exports. Nodes should not overlap. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:20.51105+13:00","updatedAt":"2026-02-12T11:29:23.830149+13:00","closedAt":"2026-02-12T11:29:23.830149+13:00","closeReason":"Completed: cee4c87","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-9lm.6"],"dependentIds":["beads-map-9lm","beads-map-9lm.1"]},{"id":"beads-map-9lm.6","title":"Add layout toggle buttons for Radial, Cluster, Spread","description":"In components/BeadsGraph.tsx, expand the existing 2-button layout segmented control (Force/DAG) to 5 buttons: Force, DAG, Radial, Cluster, Spread. The existing buttons are at ~line 1609 inside a div with className 'flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden'. Each button follows the exact same pattern: (a) onClick={() => setLayoutMode('radial')} etc., (b) active state: bg-emerald-500 text-white, inactive: text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50, (c) inner span with SVG icon (16x16 viewBox) + hidden sm:inline text label, (d) w-px bg-zinc-200 divider between each button. Existing buttons to keep: Force (scattered dots with connections icon, ~line 1610-1638) and DAG (top-down tree icon, ~line 1641-1672). New buttons to add after DAG: (1) RADIAL: icon = concentric circles (e.g., circle cx=8 cy=8 r=2 filled + circle r=5 stroke-only + circle r=7 stroke-only opacity=0.4), label 'Radial'. (2) CLUSTER: icon = grouped dots (e.g., 3 dots upper-left clustered + 3 dots lower-right clustered, suggesting two groups), label 'Cluster'. (3) SPREAD: icon = scattered dots with ample spacing (e.g., 5 small dots spread across the 16x16 viewBox with no connections, suggesting maximum spacing), label 'Spread'. Each new button needs a divider (w-px bg-zinc-200) before it. The layoutMode state variable is at ~line 255: useState<LayoutMode>('dag'). The bootstrap trick (~line 666) auto-switches from DAG to force on initial load — this should remain unchanged (new layouts are only activated by user click). Acceptance: all 5 buttons render correctly, clicking each switches the layout. Active button is visually highlighted. pnpm build passes.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T11:24:29.190312+13:00","updatedAt":"2026-02-12T11:30:38.877928+13:00","closedAt":"2026-02-12T11:30:38.877928+13:00","closeReason":"Completed: 8d08e1c","prefix":"beads-map","blockerCount":0,"dependentCount":4,"blockerIds":[],"dependentIds":["beads-map-9lm","beads-map-9lm.3","beads-map-9lm.4","beads-map-9lm.5"]},{"id":"beads-map-a2e","title":"v0.3.3: Auto-fit toggle, pulse animation, and UX polish","description":"## Overview\n\nThis release adds several features and fixes:\n\n1. **Auto-fit toggle** — Button in top-left row 2 to enable/disable automatic camera zoom-to-fit after data updates. Both zoomToFit calls in BeadsGraph gated by autoFit prop. Top-left controls reorganized into two rows.\n2. **Pulse animation** — Continuous emerald ripple rings highlight the node with the most recent activity event. Toggle button next to auto-fit. Uses autoPauseRedraw for smooth animation without fighting zoom.\n3. **Remove semantic zoom fade** — Nodes and links stay visible at all zoom levels instead of fading out when zoomed out.\n4. **Heartbeads attribution** — Link to github.com/daviddao/heartbeads in help panel footer.\n5. **Tutorial update** — New step 2 \"View Controls\" spotlighting the second row of controls.\n\n## Commits\n\n- `0975960` — Add auto-fit toggle and reorganize top-left controls into two rows\n- `5eaabaa` — beads: close epic beads-map-3pg\n- `e4ada72` — Remove semantic zoom fade: nodes and links stay visible at all zoom levels\n- `91e338d` — Add Heartbeads attribution to help panel footer\n- `157fb02` — Fix broken zoom + add pulse ripple on most-recent-activity node\n\n## Files modified\n\n- `components/BeadsGraph.tsx` — autoFit/pulse props, gated zoomToFit, two-row layout, toggle buttons, ripple animation in paintNode, autoPauseRedraw, removed semantic zoom fade\n- `app/page.tsx` — autoFit/showPulse state, pulseNodeId from activityFeed, props wiring\n- `components/TutorialOverlay.tsx` — New step 2 \"View Controls\"\n- `components/HelpPanel.tsx` — Auto-fit bullet, Heartbeads attribution link","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T17:15:36.731173+13:00","updatedAt":"2026-02-12T17:16:16.604875+13:00","closedAt":"2026-02-12T17:16:16.604875+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":4,"dependentCount":0,"blockerIds":["beads-map-a2e.1","beads-map-a2e.2","beads-map-a2e.3","beads-map-a2e.4"],"dependentIds":[]},{"id":"beads-map-a2e.1","title":"Add auto-fit toggle and reorganize top-left controls into two rows","description":"Retroactive task for commit 0975960.\n\n- Added autoFit state (default: on) that gates both zoomToFit calls in BeadsGraph.tsx (lines 838 and 865)\n- Reorganized top-left controls from single row to two rows: row 1 = layout modes, row 2 = view toggles (Collapse/Clusters/Auto-fit)\n- Auto-fit button uses Pattern C styling (emerald active, frosted glass inactive)\n- Added tutorial step 2 \"View Controls\" spotlighting the new second row (data-tutorial=\"view-controls\")\n- Added Auto-fit bullet to static help content in HelpPanel.tsx\n\nFiles: components/BeadsGraph.tsx, app/page.tsx, components/TutorialOverlay.tsx, components/HelpPanel.tsx","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T17:15:45.736117+13:00","updatedAt":"2026-02-12T17:16:16.443982+13:00","closedAt":"2026-02-12T17:16:16.443982+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-a2e"]},{"id":"beads-map-a2e.2","title":"Remove semantic zoom fade from nodes and links","description":"Retroactive task for commit e4ada72.\n\nRemoved the semantic zoom fade logic from paintNode and paintLink in BeadsGraph.tsx. Previously, nodes and links faded to invisible when zoomed out past globalScale 0.1-0.2. Now they stay fully visible at all zoom levels.\n\nRemoved code:\n- paintNode: FADE_OUT_MIN/FADE_OUT_MAX constants and zoomFade multiplier (was lines 953-961)\n- paintLink: LINK_FADE_OUT_MIN/LINK_FADE_OUT_MAX and linkZoomFade multiplier (was lines 1238-1245)\n\nCluster labels (controlled by Clusters toggle) still fade in when zoomed out — that is a separate feature.\n\nFile: components/BeadsGraph.tsx","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T17:15:54.275929+13:00","updatedAt":"2026-02-12T17:16:16.48495+13:00","closedAt":"2026-02-12T17:16:16.48495+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-a2e"]},{"id":"beads-map-a2e.3","title":"Add Heartbeads attribution to help panel footer","description":"Retroactive task for commit 91e338d.\n\nAdded a sentence to the help panel footer in HelpPanel.tsx mentioning that Heartbeads is built for the GainForest agentic workflow, with a link to github.com/daviddao/heartbeads.\n\nFile: components/HelpPanel.tsx","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T17:16:00.090854+13:00","updatedAt":"2026-02-12T17:16:16.525792+13:00","closedAt":"2026-02-12T17:16:16.525792+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-a2e"]},{"id":"beads-map-a2e.4","title":"Add pulse ripple animation on most-recently-active node","description":"Retroactive task for commit 157fb02.\n\nInspired by the OccurrenceMap blinking animation in hyperscan, added a continuous emerald ripple animation on the node with the most recent activity event.\n\nImplementation:\n- page.tsx: Added showPulse state (default true), computed pulseNodeId from activityFeed[0].nodeId\n- BeadsGraph.tsx: Added pulseNodeId/showPulse/onShowPulseToggle props with refs synced via useEffect\n- paintNode: 3 staggered expanding/fading emerald rings (2s period, 500ms stagger) drawn after the node body\n- Ripple radius and line width scale with 1/globalScale for visibility at any zoom level (~30 screen pixels)\n- Uses autoPauseRedraw={false} on ForceGraph2D when pulse is active (instead of refreshGraph zoom nudge which broke user zoom)\n- Pulse toggle button added to row 2 of top-left controls, next to Auto-fit\n\nKey discovery: refreshGraph (zoom nudge trick) called at 60fps fights with user scroll-to-zoom input. The fix is to use autoPauseRedraw={false} which is the officially recommended approach for custom dynamic animations in react-force-graph-2d.\n\nFiles: components/BeadsGraph.tsx, app/page.tsx","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T17:16:11.442635+13:00","updatedAt":"2026-02-12T17:16:16.565473+13:00","closedAt":"2026-02-12T17:16:16.565473+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-a2e"]},{"id":"beads-map-cvh","title":"ATProto login with identity display and annotation support","description":"Add ATProto (Bluesky) OAuth login to beads-map, porting the auth infrastructure from Hyperscan. Users can sign in with their Bluesky handle, see their avatar/name in the header, and (in future work) leave annotations on issues in the graph.\n\nARCHITECTURE:\n- OAuth 2.0 Authorization Code flow with PKCE via @atproto/oauth-client-node\n- Encrypted cookie sessions via iron-session (no client-side token storage)\n- React Context (AuthProvider + useAuth hook) for client-side auth state\n- 6 API routes (login, callback, client-metadata, jwks, status, logout)\n- Sign-in modal + avatar dropdown in header top-right (next to stats)\n- Support both confidential (production) and public (dev) OAuth client modes\n\nWHY HYPERSCAN'S APPROACH:\nHyperscan already solved this for ATProto in a Next.js App Router context. Their implementation is production-grade, handles all edge cases (reconnection, network errors, session restoration), and follows OAuth best practices. We'll port the core auth infrastructure verbatim, then adapt the UI to match beads-map's design.\n\nDEPENDENCY ON PAST WORK:\nThis modifies app/page.tsx (header) and app/layout.tsx (AuthProvider wrapper), which were last touched by the live-update epic (beads-map-3jy). The two features are independent but touch the same files.\n\nSCOPE:\nThis epic covers ONLY the auth infrastructure and identity display (avatar in header). Annotation features (writing comments to ATProto) will be a separate follow-up epic that builds on the authenticated agent helper (task .7).\n\nTASK BREAKDOWN:\n.1 - Install deps + env setup\n.2 - Session management (iron-session)\n.3 - OAuth client factory (confidential + public modes)\n.4 - Auth API routes (login, callback, status, logout, metadata, jwks)\n.5 - AuthProvider + useAuth hook\n.6 - AuthButton component (sign-in modal + avatar dropdown)\n.7 - Authenticated agent helper (for future annotation writes)\n.8 - Build + integration test\n\nFILES TO CREATE (13 files):\n- .env.example\n- scripts/generate-jwk.js\n- lib/env.ts\n- lib/session.ts\n- lib/auth/client.ts\n- lib/auth.tsx\n- lib/agent.ts\n- app/api/login/route.ts\n- app/api/oauth/callback/route.ts\n- app/api/oauth/client-metadata.json/route.ts\n- app/api/oauth/jwks.json/route.ts\n- app/api/status/route.ts\n- app/api/logout/route.ts\n- components/AuthButton.tsx\n\nFILES TO MODIFY (2 files):\n- package.json (add 5 dependencies)\n- app/layout.tsx (wrap in AuthProvider)\n- app/page.tsx (add <AuthButton /> to header)","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:56:19.74299+13:00","updatedAt":"2026-02-11T00:06:12.831198+13:00","closedAt":"2026-02-11T00:06:12.831198+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":9,"dependentCount":1,"blockerIds":["beads-map-cvh.1","beads-map-cvh.2","beads-map-cvh.3","beads-map-cvh.4","beads-map-cvh.5","beads-map-cvh.6","beads-map-cvh.7","beads-map-cvh.8","beads-map-dyi"],"dependentIds":["beads-map-3jy"]},{"id":"beads-map-cvh.1","title":"Install ATProto auth dependencies and environment setup","description":"Foundation task: install npm packages, create environment variable template, add JWK generation script, and create env validation utility.\n\nPART 1: Install dependencies\n\nAdd to package.json:\n pnpm add @atproto/oauth-client-node@^0.3.15 @atproto/api@^0.18.16 @atproto/jwk-jose@^0.1.11 @atproto/syntax@^0.4.2 iron-session@^8.0.4\n\nPART 2: Create .env.example\n\nFile: /Users/david/Projects/gainforest/beads-map/.env.example\nContent:\n# ATProto OAuth Authentication\n# Copy this file to .env.local and fill in the values\n\n# Required for all modes: Session encryption key (32+ chars)\nCOOKIE_SECRET=development-secret-at-least-32-chars!!\n\n# Required for production: Your app's public URL\n# Leave empty for localhost dev mode (uses public OAuth client)\nPUBLIC_URL=\n\n# Optional: Dev server port (default 3000)\nPORT=3000\n\n# Required for production confidential client: ES256 JWK private key\n# Generate with: node scripts/generate-jwk.js\n# Leave empty for localhost dev mode\nATPROTO_JWK_PRIVATE=\n\nPART 3: Create scripts/generate-jwk.js\n\nCopy verbatim from Hyperscan: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\nMake executable: chmod +x scripts/generate-jwk.js\n\nPART 4: Create lib/env.ts\n\nFile: /Users/david/Projects/gainforest/beads-map/lib/env.ts\nPort from Hyperscan's /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n- Validates COOKIE_SECRET, PUBLIC_URL, PORT, ATPROTO_JWK_PRIVATE\n- Provides defaults for dev mode\n- Exports typed env object\n\nREFERENCE FILES:\n- Hyperscan package.json: /Users/david/Projects/gainforest/hyperscan/package.json\n- Hyperscan generate-jwk.js: /Users/david/Projects/gainforest/hyperscan/scripts/generate-jwk.js\n- Hyperscan env.ts: /Users/david/Projects/gainforest/hyperscan/src/lib/env.ts\n\nACCEPTANCE CRITERIA:\n- All 5 packages installed in package.json dependencies\n- .env.example exists with all 4 env vars documented\n- scripts/generate-jwk.js exists and is executable\n- lib/env.ts exists and validates env vars\n- pnpm build passes (env.ts may not be used yet, but must compile)\n- .gitignore already has .env* (from earlier work)\n\nNOTES:\n- Do NOT create .env.local -- user will do that manually\n- Do NOT commit any actual secrets\n- The env.ts validation will allow missing values for dev mode (defaults kick in)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:56:38.692755+13:00","updatedAt":"2026-02-11T00:06:08.670005+13:00","closedAt":"2026-02-11T00:06:08.670005+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-cvh.2"],"dependentIds":["beads-map-cvh"]},{"id":"beads-map-cvh.2","title":"Create session management with iron-session","description":"Port Hyperscan's iron-session setup for encrypted cookie-based authentication sessions.\n\nPURPOSE: iron-session encrypts session data into cookies (no database needed). The session stores user identity (did, handle, displayName, avatar) and OAuth session tokens for authenticated API calls.\n\nCREATE FILE: lib/session.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nKey changes when porting:\n1. Cookie name: 'impact_indexer_sid' -> 'beads_map_sid'\n2. Import env from our lib/env.ts (not Hyperscan's path)\n3. Keep the same session shape:\n interface Session {\n did?: string\n handle?: string\n displayName?: string\n avatar?: string\n returnTo?: string\n oauthSession?: string\n }\n4. Keep the same exports:\n - getSession(request/cookies)\n - getRawSession(request/cookies)\n - clearSession(request/cookies)\n\nIMPLEMENTATION NOTES:\n- Use env.COOKIE_SECRET for encryption\n- secure: true only when env.PUBLIC_URL is set (production)\n- maxAge: 30 days (same as Hyperscan)\n- Support both Next.js Request and cookies() from next/headers (for Server Components vs API routes)\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/session.ts\n\nACCEPTANCE CRITERIA:\n- lib/session.ts exists\n- Exports getSession, getRawSession, clearSession\n- Session type matches Hyperscan's shape\n- Cookie is secure in production (when PUBLIC_URL set), insecure in dev\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:01.110787+13:00","updatedAt":"2026-02-11T00:06:08.757647+13:00","closedAt":"2026-02-11T00:06:08.757647+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.3"],"dependentIds":["beads-map-cvh","beads-map-cvh.1"]},{"id":"beads-map-cvh.3","title":"Create OAuth client factory with dual mode support","description":"Port Hyperscan's OAuth client setup with support for both confidential (production) and public (localhost dev) client modes.\n\nPURPOSE: The OAuth client handles the full Authorization Code flow with PKCE. It needs two modes:\n- Public client (dev): loopback client_id, no secrets, works on localhost\n- Confidential client (prod): uses ES256 JWK for private_key_jwt auth, requires PUBLIC_URL\n\nCREATE FILE: lib/auth/client.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nKey adaptations:\n1. Import Session type from our lib/session.ts\n2. Import env from our lib/env.ts\n3. clientName: 'Beads Map' (not 'Impact Indexer')\n4. The sessionStore must sync OAuth session data between in-memory Map and iron-session cookies (critical for serverless)\n\nIMPLEMENTATION NOTES:\n- Export getGlobalOAuthClient() as the main API\n- If PUBLIC_URL is set: confidential mode (load JWK from env.ATPROTO_JWK_PRIVATE, publish client-metadata.json and jwks.json)\n- If PUBLIC_URL is empty: public mode (loopback client_id, use 127.0.0.1 not localhost, no JWK)\n- The client is cached globally per process (singleton pattern)\n- Session store serializes OAuth session (tokens, DPoP keys) to/from cookie.oauthSession field\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth/client.ts\n\nACCEPTANCE CRITERIA:\n- lib/auth/client.ts exists (create lib/auth/ dir)\n- Exports getGlobalOAuthClient()\n- Supports both confidential and public modes based on env.PUBLIC_URL\n- Session store syncs with iron-session cookie\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:16.261043+13:00","updatedAt":"2026-02-11T00:06:08.840672+13:00","closedAt":"2026-02-11T00:06:08.840672+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-cvh.4","beads-map-cvh.7"],"dependentIds":["beads-map-cvh","beads-map-cvh.2"]},{"id":"beads-map-cvh.4","title":"Create 6 authentication API routes","description":"Port all 6 auth-related API routes from Hyperscan.\n\nCREATE 6 ROUTE FILES:\n\n1. app/api/login/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n - POST handler\n - Validates handle with @atproto/syntax isValidHandle\n - Calls client.authorize(handle)\n - Stores returnTo in session cookie\n - Returns { redirectUrl }\n\n2. app/api/oauth/callback/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n - GET handler\n - Calls client.callback(params) to exchange code for tokens\n - Fetches profile via @atproto/api Agent\n - Saves { did, handle, displayName, avatar, oauthSession } to session cookie\n - Redirects to returnTo (303 redirect)\n - Has retry logic for network errors\n\n3. app/api/oauth/client-metadata.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n - GET handler (only in confidential mode)\n - Returns OAuth client metadata JSON per ATProto spec\n\n4. app/api/oauth/jwks.json/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n - GET handler (only in confidential mode)\n - Returns JWKS public keys\n\n5. app/api/status/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n - GET handler\n - Reads session cookie\n - Returns { authenticated, did, handle, displayName, avatar } or { authenticated: false }\n\n6. app/api/logout/route.ts\n Port from: /Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n - POST handler\n - Calls clearSession()\n - Returns { success: true }\n\nKEY NOTES:\n- All routes use export const dynamic = 'force-dynamic'\n- Import from our lib/ paths (not Hyperscan's src/lib/)\n- The callback route should handle both OAuth success and error states\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/app/api/login/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/callback/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/client-metadata.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/oauth/jwks.json/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/status/route.ts\n/Users/david/Projects/gainforest/hyperscan/src/app/api/logout/route.ts\n\nACCEPTANCE CRITERIA:\n- All 6 route files exist in correct paths\n- Each exports the correct HTTP method handler (GET or POST)\n- All routes compile and pnpm build passes\n- All routes use dynamic = 'force-dynamic'\n- Imports use beads-map paths (not Hyperscan's)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:32.923191+13:00","updatedAt":"2026-02-11T00:06:08.921439+13:00","closedAt":"2026-02-11T00:06:08.921439+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.5"],"dependentIds":["beads-map-cvh","beads-map-cvh.3"]},{"id":"beads-map-cvh.5","title":"Create AuthProvider and useAuth hook","description":"Port Hyperscan's client-side auth state management: React Context provider and useAuth hook. Wrap the app in AuthProvider.\n\nCREATE FILE: lib/auth.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n\nKey pieces:\n1. AuthContext with shape: state, login, logout\n2. AuthProvider component:\n - Manages auth status: idle, authorizing, authenticated, error\n - On mount: checks /api/status to restore session\n - Exposes login(handle) and logout() functions\n3. useAuth() hook:\n - Returns status, session, isLoading, isAuthenticated, login, logout\n - session shape: did, handle, displayName, avatar or null\n\nLOGIN FLOW client-side:\n1. User calls login(handle)\n2. POST to /api/login with handle and returnTo\n3. Server returns redirectUrl\n4. window.location.href = redirectUrl (redirect to PDS)\n5. After OAuth callback completes, browser redirects back to returnTo\n6. AuthProvider re-checks /api/status and updates context\n\nLOGOUT FLOW:\n1. User calls logout()\n2. POST to /api/logout\n3. Clear local state\n4. Optionally reload or redirect\n\nMODIFY FILE: app/layout.tsx\n\nWrap children in AuthProvider from lib/auth.tsx\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/lib/auth.tsx\n/Users/david/Projects/gainforest/hyperscan/src/app/layout.tsx (for wrapper example)\n\nACCEPTANCE CRITERIA:\n- lib/auth.tsx exists\n- Exports AuthProvider, useAuth\n- useAuth returns correct shape\n- app/layout.tsx wraps children in AuthProvider\n- pnpm build passes\n- No client-side token storage, all session state comes from /api/status reading cookies","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:57:56.261859+13:00","updatedAt":"2026-02-11T00:06:09.003714+13:00","closedAt":"2026-02-11T00:06:09.003714+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.6"],"dependentIds":["beads-map-cvh","beads-map-cvh.4"]},{"id":"beads-map-cvh.6","title":"Create AuthButton component and integrate into header","description":"Port Hyperscan's AuthButton component (sign-in modal + avatar dropdown) and add it to the page.tsx header top-right, next to the stats.\n\nCREATE FILE: components/AuthButton.tsx\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n\nKey UI elements:\n1. When logged out: Sign in text link\n2. Click opens modal with:\n - Title: Sign in with ATProto\n - Handle input field with placeholder alice.bsky.social\n - Helper text: Just a username? We will add .bsky.social for you\n - Cancel + Connect buttons (emerald-600 green for Connect)\n - Error display area\n - Backdrop blur overlay\n3. When logged in: Avatar (24x24 rounded) + display name/handle (truncated)\n4. Click avatar opens dropdown with:\n - Profile link (to /profile if we add that page, or just show did for now)\n - Divider\n - Sign out button\n\nThe component uses useAuth() hook from lib/auth.tsx for status, session, login, logout.\n\nADAPT STYLING:\n- Use beads-map's design tokens: emerald-500/600, zinc colors, same border-radius, same shadows\n- Match the existing header style (text-xs, simple, clean)\n- Modal should be centered with 20vh from top (same as Hyperscan)\n\nMODIFY FILE: app/page.tsx\n\nAdd AuthButton to header right section (line 644-662). Current structure:\n Left: Logo + title\n Center: Search\n Right: Stats (total issues, deps, projects)\n\nNew structure:\n Right: Stats + vertical divider + <AuthButton />\n\nThe stats div stays, just add:\n <span className=\"w-px h-4 bg-zinc-200\" />\n <AuthButton />\n\nREFERENCE FILES:\n/Users/david/Projects/gainforest/hyperscan/src/components/AuthButton.tsx\n/Users/david/Projects/gainforest/hyperscan/src/components/Header.tsx (for placement example)\n\nACCEPTANCE CRITERIA:\n- components/AuthButton.tsx exists\n- Shows sign-in text link when logged out\n- Shows avatar + name/handle when logged in\n- Modal opens on click when logged out, dropdown on click when logged in\n- Integrated into page.tsx header right section\n- Matches beads-map visual style (emerald, zinc, text-xs)\n- pnpm build passes\n- Dev server shows the button (no visual regression on existing header)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:58:15.698478+13:00","updatedAt":"2026-02-11T00:06:09.086644+13:00","closedAt":"2026-02-11T00:06:09.086644+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.8"],"dependentIds":["beads-map-cvh","beads-map-cvh.5"]},{"id":"beads-map-cvh.7","title":"Create authenticated agent helper for ATProto API calls","description":"Create a server-side utility that restores an authenticated ATProto Agent from the session cookie. This is the foundation for future annotation writes.\n\nCREATE FILE: lib/agent.ts\n\nPort from: /Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nPurpose:\n- Server-side only (API routes, Server Components, Server Actions)\n- Reads session cookie to get oauthSession data\n- Calls client.restore(did, oauthSession) to rebuild OAuth session\n- Returns @atproto/api Agent instance for making authenticated ATProto API calls\n\nKey function:\nexport async function getAuthenticatedAgent(request: Request): Promise<Agent>\n - Reads session via getSession(request)\n - If no session.did or session.oauthSession: throw Error(Unauthorized)\n - Deserialize oauthSession\n - Call client.restore(did, sessionData)\n - Return new Agent(restoredOAuthSession)\n\nUSAGE EXAMPLE (for future annotation API):\n// In app/api/annotations/route.ts\nimport { getAuthenticatedAgent } from '@/lib/agent'\n\nexport async function POST(request: Request) {\n const agent = await getAuthenticatedAgent(request)\n // agent.com.atproto.repo.createRecord(...)\n}\n\nREFERENCE FILE:\n/Users/david/Projects/gainforest/hyperscan/src/lib/agent.ts\n\nACCEPTANCE CRITERIA:\n- lib/agent.ts exists\n- Exports getAuthenticatedAgent(request)\n- Throws clear error if not authenticated\n- Returns Agent instance ready for ATProto API calls\n- pnpm build passes\n- NOT YET USED (will be used in future annotation epic), but must compile","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:58:28.649345+13:00","updatedAt":"2026-02-11T00:06:09.166246+13:00","closedAt":"2026-02-11T00:06:09.166246+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-cvh.8"],"dependentIds":["beads-map-cvh","beads-map-cvh.3"]},{"id":"beads-map-cvh.8","title":"Build verification and integration test","description":"Final verification that the ATProto login system works end-to-end in dev mode (public OAuth client).\n\nPART 1: Build check\n pnpm build -- must pass with zero errors\n\nPART 2: Dev server smoke test\n\nStart dev server with existing beads project:\n BEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev\n\nBrowser tests:\n1. Open http://localhost:3000\n2. Header should show: Logo | Search | Stats | Sign in (new!)\n3. Click Sign in -> modal opens\n4. Enter a Bluesky handle (e.g. alice.bsky.social)\n5. Click Connect -> redirects to bsky.social OAuth consent screen\n6. Approve -> redirects back to beads-map\n7. Header should now show: Logo | Search | Stats | Avatar + name (alice.bsky.social)\n8. Click avatar -> dropdown opens with Sign out\n9. Click Sign out -> back to unauthenticated state\n10. Refresh page -> session persists (avatar still shows)\n11. Open devtools Network tab -> no client-side token storage, only encrypted cookies\n\nServer logs should show:\n- Watching N files for changes (from file watcher, unrelated)\n- No OAuth client errors\n- If using localhost: should see public client mode log\n\nPART 3: Session persistence check\n- Login\n- Close browser tab\n- Reopen http://localhost:3000\n- Should still be logged in (session cookie persists)\n\nPART 4: Error handling check\n- Click Sign in\n- Enter invalid handle (e.g. test.invalid)\n- Should show error message in modal (not crash)\n\nFUNCTIONAL CHECKS:\n- Graph still works (nodes, links, search, layout toggle)\n- Stats still update in real-time\n- No console errors during login/logout flow\n- No memory leaks (EventSource from earlier work still cleans up)\n\nPERFORMANCE CHECKS:\n- Page load time not significantly affected by auth check\n- No jank during modal open/close animations\n\nKNOWN LIMITATIONS (OK for this epic):\n- /profile route does not exist yet (clicking Profile in dropdown would 404)\n- No annotation features yet (will be follow-up epic)\n- Confidential client mode not tested (requires PUBLIC_URL + JWK in production)\n\nACCEPTANCE CRITERIA:\n- pnpm build passes\n- Can log in via localhost OAuth in dev mode\n- Avatar + name display correctly when logged in\n- Session persists across page refresh\n- Logout works\n- No console errors\n- All existing features (graph, search, live updates) still work","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:58:49.014769+13:00","updatedAt":"2026-02-11T00:06:09.245646+13:00","closedAt":"2026-02-11T00:06:09.245646+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":3,"blockerIds":[],"dependentIds":["beads-map-cvh","beads-map-cvh.6","beads-map-cvh.7"]},{"id":"beads-map-dwk","title":"Legend color mode selector with Catppuccin Mocha palette","description":"## Goal\n\nAdd a **color mode selector** to the bottom-right legend panel in BeadsGraph. Users can switch between 4 modes that change the **node body fill color**:\n\n1. **Status** (default) — nodes colored by open/in_progress/blocked/deferred/closed using existing `STATUS_COLORS`\n2. **Owner** — nodes colored by `createdBy` field, using Catppuccin Mocha accent palette (14 colors)\n3. **Assignee** — nodes colored by `assignee` field, using Catppuccin Mocha accent palette\n4. **Prefix** — nodes colored by `prefix` field, using Catppuccin Mocha accent palette\n\nThe outer ring color (prefix-based via FNV hash) stays unchanged in all modes.\n\n## Catppuccin Mocha Accent Colors (14)\n\n| Name | Hex |\n|---|---|\n| Rosewater | #f5e0dc |\n| Flamingo | #f2cdcd |\n| Pink | #f5c2e7 |\n| Mauve | #cba6f7 |\n| Red | #f38ba8 |\n| Maroon | #eba0b3 |\n| Peach | #fab387 |\n| Yellow | #f9e2af |\n| Green | #a6e3a1 |\n| Teal | #94e2d5 |\n| Sky | #89dceb |\n| Sapphire | #74c7ec |\n| Blue | #89b4fa |\n| Lavender | #b4befe |\n\nUnassigned/unknown uses Mocha Surface2 (#585b70) as a neutral muted color.\n\n## Architecture\n\n- **State ownership:** `colorMode` state lives in `app/page.tsx`, passed as prop to BeadsGraph\n- **Ref pattern:** BeadsGraph stores `colorMode` in a ref (like selectedNodeRef, hoveredNodeRef) so paintNode (which has [] deps) can read it without re-creating the callback\n- **getNodeColor replacement:** The module-level `getNodeColor(node)` function at BeadsGraph.tsx:66-68 becomes color-mode-aware, reading from a ref\n- **Legend panel:** The existing inlined legend at BeadsGraph.tsx:1911-1943 gets a segmented control (4 buttons, same style as layout mode buttons) and a dynamic legend section that shows relevant color dots based on the active mode\n- **Minimap:** Uses the same getNodeColor function at BeadsGraph.tsx:1508, so it automatically reflects the color mode\n\n## Files to modify\n\n1. `lib/types.ts` — Add ColorMode type, CATPPUCCIN_MOCHA_ACCENTS array, getPersonColor() function\n2. `app/page.tsx` — Add colorMode state, pass as prop\n3. `components/BeadsGraph.tsx` — Accept colorMode prop, ref pattern, update getNodeColor, update legend panel\n\n## Acceptance criteria\n\n- [ ] 4 color modes: Status, Owner, Assignee, Prefix\n- [ ] Segmented control in legend panel to switch modes\n- [ ] Node body fill changes per mode; outer ring stays prefix-based\n- [ ] Dynamic legend shows relevant items (statuses, people, or prefixes) with colored dots\n- [ ] Only items present in visible nodes appear in legend\n- [ ] Catppuccin Mocha accent palette used for person/prefix coloring\n- [ ] Unassigned nodes shown with muted gray + \"Unassigned\" label\n- [ ] Minimap reflects current color mode\n- [ ] `pnpm build` passes with zero errors","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T13:58:33.624445+13:00","updatedAt":"2026-02-12T14:03:52.455754+13:00","closedAt":"2026-02-12T14:03:52.455754+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":4,"dependentCount":0,"blockerIds":["beads-map-dwk.1","beads-map-dwk.2","beads-map-dwk.3","beads-map-dwk.4"],"dependentIds":[]},{"id":"beads-map-dwk.1","title":"Add ColorMode type, Catppuccin palette, and getPersonColor to lib/types.ts","description":"## What\n\nAdd the color mode infrastructure to `lib/types.ts`. This is the foundation that all other tasks depend on.\n\n## File: `lib/types.ts` (currently 205 lines)\n\n### 1. Add ColorMode type (after line 70, after GraphNode interface)\n\n```typescript\nexport type ColorMode = \"status\" | \"owner\" | \"assignee\" | \"prefix\";\n```\n\n### 2. Add COLOR_MODE_LABELS (after STATUS_LABELS around line 122)\n\n```typescript\nexport const COLOR_MODE_LABELS: Record<ColorMode, string> = {\n status: \"Status\",\n owner: \"Owner\",\n assignee: \"Assignee\",\n prefix: \"Prefix\",\n};\n```\n\n### 3. Add CATPPUCCIN_MOCHA_ACCENTS (after PRIORITY_COLORS around line 138)\n\n```typescript\n/** Catppuccin Mocha accent colors — 14 visually distinct, ordered for max contrast between adjacent indices */\nexport const CATPPUCCIN_MOCHA_ACCENTS: string[] = [\n \"#f38ba8\", // Red\n \"#94e2d5\", // Teal\n \"#fab387\", // Peach\n \"#89b4fa\", // Blue\n \"#a6e3a1\", // Green\n \"#cba6f7\", // Mauve\n \"#f9e2af\", // Yellow\n \"#74c7ec\", // Sapphire\n \"#f5c2e7\", // Pink\n \"#89dceb\", // Sky\n \"#eba0b3\", // Maroon\n \"#b4befe\", // Lavender\n \"#f2cdcd\", // Flamingo\n \"#f5e0dc\", // Rosewater\n];\n\n/** Catppuccin Mocha Surface2 — used for \"unassigned\" / unknown values */\nexport const CATPPUCCIN_UNASSIGNED = \"#585b70\";\n```\n\nNOTE: The array is NOT in Catppuccin's native order. It's reordered so that adjacent indices have maximal visual contrast (alternating warm/cool). This matters because if two people hash to adjacent indices, they should still be visually distinguishable.\n\n### 4. Add CATPPUCCIN_ACCENT_NAMES (for legend labels)\n\n```typescript\nexport const CATPPUCCIN_ACCENT_NAMES: string[] = [\n \"Red\", \"Teal\", \"Peach\", \"Blue\", \"Green\", \"Mauve\", \"Yellow\",\n \"Sapphire\", \"Pink\", \"Sky\", \"Maroon\", \"Lavender\", \"Flamingo\", \"Rosewater\",\n];\n```\n\n### 5. Add getPersonColor function (after getPrefixColor around line 191)\n\n```typescript\n/**\n * Deterministically map a person handle/name to a Catppuccin Mocha accent color.\n * Uses FNV-1a hash (same as hashColor) modulo 14 to index into CATPPUCCIN_MOCHA_ACCENTS.\n * Returns CATPPUCCIN_UNASSIGNED for undefined/null/empty strings.\n */\nexport function getPersonColor(person: string | undefined): string {\n if (!person) return CATPPUCCIN_UNASSIGNED;\n let hash = 2166136261; // FNV offset basis\n for (let i = 0; i < person.length; i++) {\n hash ^= person.charCodeAt(i);\n hash = (hash * 16777619) >>> 0;\n }\n return CATPPUCCIN_MOCHA_ACCENTS[hash % CATPPUCCIN_MOCHA_ACCENTS.length];\n}\n\n/**\n * Deterministically map a prefix string to a Catppuccin Mocha accent color.\n * Same approach as getPersonColor but for project prefixes.\n */\nexport function getCatppuccinPrefixColor(prefix: string): string {\n return getPersonColor(prefix);\n}\n```\n\n### 6. Export the new ColorMode type from the file\n\nMake sure `ColorMode` is exported (it's a type export so it will be used in both page.tsx and BeadsGraph.tsx).\n\n## Acceptance criteria\n\n- [ ] `ColorMode` type exported\n- [ ] `COLOR_MODE_LABELS` record exported with 4 entries\n- [ ] `CATPPUCCIN_MOCHA_ACCENTS` array has exactly 14 hex strings, reordered for contrast\n- [ ] `CATPPUCCIN_UNASSIGNED` constant exported (#585b70)\n- [ ] `getPersonColor(person)` returns deterministic Catppuccin color, or CATPPUCCIN_UNASSIGNED for undefined/empty\n- [ ] `getCatppuccinPrefixColor(prefix)` works the same way for prefixes\n- [ ] Existing exports (STATUS_COLORS, PREFIX_COLORS, getPrefixColor, etc.) are NOT changed\n- [ ] `pnpm build` passes\n\n## Why this is task 1\n\nAll other tasks import these types/functions. This must be done first.","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T13:58:57.258003+13:00","updatedAt":"2026-02-12T14:01:20.100573+13:00","closedAt":"2026-02-12T14:01:20.100573+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-dwk.2","beads-map-dwk.3"],"dependentIds":["beads-map-dwk"]},{"id":"beads-map-dwk.2","title":"Add colorMode state to page.tsx and pass as prop","description":"## What\n\nAdd the `colorMode` state to `app/page.tsx` and wire it through to `<BeadsGraph>`.\n\n## File: `app/page.tsx`\n\n### 1. Import the new type\n\nAt the top of the file, add `ColorMode` to the existing import from `@/lib/types`:\n\n```typescript\nimport type { ..., ColorMode } from \"@/lib/types\";\n```\n\nFind the existing import line for types and extend it.\n\n### 2. Add state (near other state declarations)\n\nFind the area where other state like `collapsedEpicIds`, `selectedNode`, etc. are declared. Add:\n\n```typescript\nconst [colorMode, setColorMode] = useState<ColorMode>(\"status\");\n```\n\n### 3. Pass props to BeadsGraph\n\nIn the `<BeadsGraph>` JSX (around line 1232-1251), add two new props:\n\n```tsx\n<BeadsGraph\n // ... existing props ...\n colorMode={colorMode}\n onColorModeChange={setColorMode}\n/>\n```\n\n### 4. No changes to BeadTooltip\n\nThe `BeadTooltip` component uses `prefixColor` which is independent of color mode. No changes needed there.\n\n## Acceptance criteria\n\n- [ ] `colorMode` state initialized to `\"status\"`\n- [ ] `colorMode` and `onColorModeChange` props passed to BeadsGraph\n- [ ] No other behavior changes in page.tsx\n- [ ] `pnpm build` passes (it will warn about unused props in BeadsGraph until task 3 is done, but should still compile)\n\n## Why this is task 2\n\nThis wires the state from the parent. Task 3 (BeadsGraph) consumes it.","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T13:59:08.216098+13:00","updatedAt":"2026-02-12T14:01:53.897087+13:00","closedAt":"2026-02-12T14:01:53.897087+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-dwk.3"],"dependentIds":["beads-map-dwk","beads-map-dwk.1"]},{"id":"beads-map-dwk.3","title":"Update BeadsGraph: color-mode-aware paintNode, legend selector, and dynamic legend","description":"## What\n\nThis is the core task. Update `components/BeadsGraph.tsx` to:\n1. Accept `colorMode` + `onColorModeChange` props\n2. Make `getNodeColor()` color-mode-aware via a ref\n3. Replace the static legend panel with a mode selector + dynamic legend\n4. Minimap automatically picks up the new colors (it already calls `getNodeColor`)\n\n## File: `components/BeadsGraph.tsx`\n\n### Step 1: Update imports (line 13-14)\n\nAdd new imports from `@/lib/types`:\n\n```typescript\nimport type { GraphNode, GraphLink, ColorMode } from \"@/lib/types\";\nimport {\n STATUS_COLORS, STATUS_LABELS, PREFIX_COLORS,\n COLOR_MODE_LABELS, CATPPUCCIN_MOCHA_ACCENTS, CATPPUCCIN_UNASSIGNED,\n getPersonColor, getCatppuccinPrefixColor, getPrefixLabel,\n} from \"@/lib/types\";\n```\n\n### Step 2: Update BeadsGraphProps interface (line 26-48)\n\nAdd two new props:\n\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n /** Current color mode for node body fill */\n colorMode?: ColorMode;\n /** Callback to change color mode (from legend selector) */\n onColorModeChange?: (mode: ColorMode) => void;\n}\n```\n\n### Step 3: Destructure new props (line 200-218)\n\nAdd `colorMode = \"status\"` and `onColorModeChange` to the destructured props:\n\n```typescript\nconst BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsGraph({\n // ... existing ...\n collapsedEpicIds,\n onCollapseAll,\n onExpandAll,\n colorMode = \"status\",\n onColorModeChange,\n}, ref) {\n```\n\n### Step 4: Add colorMode ref (around line 262-268, near other refs)\n\n```typescript\nconst colorModeRef = useRef<ColorMode>(colorMode);\n```\n\nAnd add a sync effect (near the other ref sync effects around line 440-480):\n\n```typescript\nuseEffect(() => {\n colorModeRef.current = colorMode;\n refreshGraph(graphRef);\n // Also redraw minimap\n minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());\n}, [colorMode]);\n```\n\n### Step 5: Convert getNodeColor to color-mode-aware (lines 65-68)\n\nThe current code is a module-level function:\n```typescript\nfunction getNodeColor(node: GraphNode): string {\n return STATUS_COLORS[node.status] || STATUS_COLORS.open;\n}\n```\n\nThis CANNOT read from a ref because it's module-level. Two approaches:\n\n**Approach A (recommended):** Keep the module-level function but add a module-level variable that the ref syncs to:\n\n```typescript\n// Module-level color mode tracker (synced from ref in useEffect)\nlet _currentColorMode: ColorMode = \"status\";\n\nfunction getNodeColor(node: GraphNode): string {\n switch (_currentColorMode) {\n case \"owner\":\n return getPersonColor(node.createdBy);\n case \"assignee\":\n return getPersonColor(node.assignee);\n case \"prefix\":\n return getCatppuccinPrefixColor(node.prefix);\n case \"status\":\n default:\n return STATUS_COLORS[node.status] || STATUS_COLORS.open;\n }\n}\n```\n\nThen in the colorMode sync useEffect:\n```typescript\nuseEffect(() => {\n colorModeRef.current = colorMode;\n _currentColorMode = colorMode; // sync module-level variable\n refreshGraph(graphRef);\n minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());\n}, [colorMode]);\n```\n\nThis works because paintNode calls getNodeColor synchronously during rendering, and the module-level variable is always up to date when paintNode runs.\n\n### Step 6: Update the legend panel (lines 1911-1943)\n\nReplace the current legend content with:\n\n#### 6a. Stats row (keep as-is)\nThe stats row showing \"42 issues · 38 deps · 3 projects\" stays unchanged.\n\n#### 6b. Add color mode segmented control\nAfter the stats row, add a row of 4 small buttons (matching the layout mode button style from lines 1720-1908):\n\n```tsx\n{/* Color mode selector */}\n<div className=\"flex bg-zinc-100 rounded-md overflow-hidden mb-1.5\">\n {([\"status\", \"owner\", \"assignee\", \"prefix\"] as ColorMode[]).map((mode) => (\n <button\n key={mode}\n onClick={() => onColorModeChange?.(mode)}\n className={`flex-1 px-2 py-1 text-[10px] font-medium transition-colors ${\n colorMode === mode\n ? \"bg-emerald-500 text-white\"\n : \"text-zinc-500 hover:text-zinc-700 hover:bg-zinc-200/60\"\n }`}\n >\n {COLOR_MODE_LABELS[mode]}\n </button>\n ))}\n</div>\n```\n\n#### 6c. Dynamic legend section\nReplace the static status dots section (lines 1927-1936) with a dynamic section:\n\n```tsx\n<div className=\"hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5\">\n {colorMode === \"status\" && (\n <>\n {[\"open\", \"in_progress\", \"blocked\", \"deferred\", \"closed\"].map((status) => (\n <span key={status} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: STATUS_COLORS[status] }} />\n <span className=\"text-zinc-500\">{STATUS_LABELS[status]}</span>\n </span>\n ))}\n </>\n )}\n {colorMode === \"owner\" && (\n <>\n {legendItems.map(({ label, color }) => (\n <span key={label} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: color }} />\n <span className=\"text-zinc-500 truncate max-w-[80px]\">{label}</span>\n </span>\n ))}\n </>\n )}\n {colorMode === \"assignee\" && (\n <>\n {legendItems.map(({ label, color }) => (\n <span key={label} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: color }} />\n <span className=\"text-zinc-500 truncate max-w-[80px]\">{label}</span>\n </span>\n ))}\n </>\n )}\n {colorMode === \"prefix\" && (\n <>\n {legendItems.map(({ label, color }) => (\n <span key={label} className=\"flex items-center gap-1\">\n <span className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: color }} />\n <span className=\"text-zinc-500 truncate max-w-[80px]\">{label}</span>\n </span>\n ))}\n </>\n )}\n</div>\n```\n\nWhere `legendItems` is a useMemo computed from `viewNodes` + `colorMode` (see step 7).\n\n#### 6d. Update the hint text\nChange \"Size = importance · Ring = project\" to be mode-aware:\n\n```tsx\n<div className=\"hidden sm:flex flex-col gap-0.5 text-zinc-400\">\n <span>\n Size = importance · Ring = project\n {colorMode !== \"status\" && \" · Fill = \" + COLOR_MODE_LABELS[colorMode].toLowerCase()}\n </span>\n</div>\n```\n\n### Step 7: Add legendItems useMemo\n\nAdd a useMemo (before the return statement, near the other useMemos) that computes the legend items based on color mode and visible nodes:\n\n```typescript\nconst legendItems = useMemo(() => {\n if (colorMode === \"status\") return []; // handled by static STATUS_COLORS rendering\n\n const items = new Map<string, string>(); // label -> color\n\n for (const node of viewNodes) {\n let key: string | undefined;\n let color: string;\n\n switch (colorMode) {\n case \"owner\":\n key = node.createdBy || undefined;\n color = getPersonColor(key);\n items.set(key || \"Unassigned\", color);\n break;\n case \"assignee\":\n key = node.assignee || undefined;\n color = getPersonColor(key);\n items.set(key || \"Unassigned\", color);\n break;\n case \"prefix\":\n color = getCatppuccinPrefixColor(node.prefix);\n items.set(getPrefixLabel(node.prefix), color);\n break;\n }\n }\n\n // Sort: \"Unassigned\" last, others alphabetically\n return Array.from(items.entries())\n .sort(([a], [b]) => {\n if (a === \"Unassigned\") return 1;\n if (b === \"Unassigned\") return -1;\n return a.localeCompare(b);\n })\n .map(([label, color]) => ({ label, color }));\n}, [colorMode, viewNodes]);\n```\n\nIMPORTANT: `viewNodes` is declared earlier in the file as a useMemo around line 300-400. The `legendItems` memo must be declared AFTER `viewNodes` but BEFORE the JSX return. Put it near the other useMemos (there's a `clusters` useMemo around line 420).\n\n### Step 8: Cluster labels (optional consistency)\n\nThe cluster background circle at line 1324-1325 uses `PREFIX_COLORS[cluster.prefix]` which is the FNV hash HSL color. This is used for the layout cluster grouping visual, NOT for node fill. Leave it unchanged — it should stay the prefix ring color regardless of color mode.\n\n### Step 9: Verify minimap\n\nThe minimap at line 1508 already calls `getNodeColor(node)`. Since we updated the module-level `getNodeColor` to be mode-aware, the minimap will automatically reflect the correct colors. The `redrawMinimap` is triggered by the colorMode useEffect. No additional changes needed.\n\n## Key constraints\n\n- **paintNode has [] deps** — it reads from refs/module-level vars, NEVER from props directly\n- **getNodeColor is module-level** — it's called by both paintNode AND minimap. The module-level `_currentColorMode` variable is the bridge\n- **viewNodes ordering** — legendItems useMemo depends on viewNodes, so it must be declared after viewNodes\n- **refreshGraph + minimap redraw** — changing colorMode must trigger both (the useEffect handles this)\n\n## Acceptance criteria\n\n- [ ] Color mode segmented control renders in legend panel (4 buttons)\n- [ ] Clicking a button changes node body fill colors across the entire graph\n- [ ] Status mode shows the 5 status color dots (existing behavior)\n- [ ] Owner mode shows unique createdBy values with Catppuccin colors\n- [ ] Assignee mode shows unique assignee values with Catppuccin colors\n- [ ] Prefix mode shows unique prefixes with Catppuccin colors\n- [ ] \"Unassigned\" appears last in legend with muted gray (#585b70)\n- [ ] Only people/prefixes present in visible (viewNodes) nodes appear\n- [ ] Minimap reflects current color mode\n- [ ] Outer ring color is UNCHANGED (still prefix-based FNV hash)\n- [ ] No jitter or graph reset when switching modes\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T13:59:56.524903+13:00","updatedAt":"2026-02-12T14:03:29.438392+13:00","closedAt":"2026-02-12T14:03:29.438392+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-dwk.4"],"dependentIds":["beads-map-dwk","beads-map-dwk.1","beads-map-dwk.2"]},{"id":"beads-map-dwk.4","title":"Build verification and polish","description":"## What\n\nFinal verification pass after all code changes are in place.\n\n## Steps\n\n### 1. Run `pnpm build`\n\nMust pass with zero errors. Common issues to watch for:\n- Unused imports (if any old imports from types.ts were removed)\n- Type mismatches on the new props\n- Missing exports from types.ts\n\n### 2. Fix any build errors\n\nIf the build fails, fix the issues. Common pitfalls:\n- `_currentColorMode` module-level variable — make sure it's declared with `let` not `const`\n- `ColorMode` type must be imported in both `page.tsx` and `BeadsGraph.tsx`\n- `getPersonColor` and `getCatppuccinPrefixColor` must be exported from types.ts\n- `getPrefixLabel` must be imported in BeadsGraph.tsx (it's already exported from types.ts)\n- The `legendItems` useMemo must reference `viewNodes` which is declared earlier\n\n### 3. Visual verification checklist\n\nIf dev mode is available (optional):\n- [ ] Default view shows Status mode with familiar colors\n- [ ] Switching to Owner mode recolors nodes, legend shows people\n- [ ] Switching to Assignee mode recolors nodes, legend shows assignees\n- [ ] Switching to Prefix mode recolors nodes, legend shows project names\n- [ ] Switching back to Status restores original colors\n- [ ] Minimap dots match main graph colors\n- [ ] Outer rings unchanged in all modes\n- [ ] No graph jitter/reset when switching modes\n- [ ] Legend panel doesn't overflow with many unique owners\n- [ ] \"Unassigned\" label appears when nodes lack owner/assignee\n\n### 4. Stale cache\n\nIf `PageNotFoundError` or `Cannot find module` errors occur during build:\n```bash\nrm -rf .next node_modules/.cache && pnpm build\n```\n\n## Acceptance criteria\n\n- [ ] `pnpm build` exits with code 0\n- [ ] No TypeScript errors\n- [ ] No unused imports warnings that cause build failure","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T14:00:09.752736+13:00","updatedAt":"2026-02-12T14:03:52.280657+13:00","closedAt":"2026-02-12T14:03:52.280657+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-dwk","beads-map-dwk.3"]},{"id":"beads-map-dyi","title":"Right-click comment tooltip on graph nodes with ATProto annotations","description":"## Right-click comment tooltip on graph nodes with ATProto annotations\n\n### Summary\nAdd right-click context menu on graph nodes that opens a beautiful floating tooltip (inspired by plresearch.org dependency graph) with a text input to post ATProto comments using the `org.impactindexer.review.comment` lexicon. Fetch existing comments from the Hypergoat indexer, show comment icon badge on nodes with comments, and display a full comment section in the NodeDetail sidebar panel.\n\n### Subject URI Convention\nComments target beads issues using: `{ uri: 'beads:<issue-id>', type: 'record' }`\nExample: `{ uri: 'beads:beads-map-cvh', type: 'record' }`\n\n### Architecture\n```\n[User right-clicks node] → CommentTooltip appears (positioned near cursor)\n → [User types + clicks Send]\n → POST /api/records → getAuthenticatedAgent() → agent.com.atproto.repo.createRecord()\n → Record written to user's PDS as org.impactindexer.review.comment\n → refetch() → Hypergoat GraphQL indexer → updated commentsByNode Map\n → [Comment badge appears on node] + [Comments shown in NodeDetail sidebar]\n```\n\n### Task dependency chain\n- .1 (API route) and .2 (comments hook) are independent — can be done in parallel\n- .3 (right-click + tooltip) is independent but uses auth awareness\n- .4 (comment badge) depends on .2 (needs commentedNodeIds)\n- .5 (NodeDetail comments) depends on .2 (needs comments data)\n- .6 (wiring) depends on ALL of .1-.5\n\n### Key reference files\n- Hyperscan API route: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`\n- Hypergoat indexer: `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts`\n- Tooltip design: `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Subject ref defs: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/defs.json`\n\n### New files to create\n1. `app/api/records/route.ts` — generic ATProto record CRUD route\n2. `hooks/useBeadsComments.ts` — fetch + parse comments from indexer\n3. `components/CommentTooltip.tsx` — floating right-click comment tooltip\n\n### Files to modify\n1. `components/BeadsGraph.tsx` — add onNodeRightClick prop + comment badge in paintNode\n2. `components/NodeDetail.tsx` — add Comments section at bottom\n3. `app/page.tsx` — wire everything together\n\n### Build & test\n```bash\npnpm build # Must pass with zero errors\nBEADS_DIR=~/Projects/gainforest/gainforest-beads/.beads pnpm dev # Manual test\n```\n\n### Design specification (from plresearch.org)\n- White bg, border `1px solid #E5E7EB`, border-radius 8px\n- Shadow: `0 8px 32px rgba(0,0,0,0.08)`\n- Padding: 18px 20px\n- Colored accent bar (24px x 2px) using node prefix color\n- Fade-in animation: 0.2s ease from opacity:0 translateY(4px)\n- Comment badge on nodes: blue (#3b82f6) speech bubble at top-right","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:11.044718+13:00","updatedAt":"2026-02-11T00:47:32.504475+13:00","closedAt":"2026-02-11T00:47:32.504475+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":8,"dependentCount":1,"blockerIds":["beads-map-dyi.1","beads-map-dyi.2","beads-map-dyi.3","beads-map-dyi.4","beads-map-dyi.5","beads-map-dyi.6","beads-map-dyi.7","beads-map-vdg"],"dependentIds":["beads-map-cvh"]},{"id":"beads-map-dyi.1","title":"Create /api/records route for ATProto record CRUD","description":"## Create /api/records route for ATProto record CRUD\n\n### Goal\nCreate `app/api/records/route.ts` — a generic server-side API route that allows authenticated users to create, update, and delete ATProto records on their PDS (Personal Data Server). This is the foundational route that the comment feature will use to write `org.impactindexer.review.comment` records.\n\n### What to create\n**New file:** `app/api/records/route.ts`\n\n### Source to port from\nCopy almost verbatim from Hyperscan: `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts` (136 lines). The only changes needed are import paths (`@/lib/agent` → `@/lib/agent`, `@/lib/session` → `@/lib/session` — these are actually identical since beads-map uses the same structure).\n\n### Implementation details\n\nThe file exports three HTTP handlers:\n\n**POST /api/records** — Create a new record:\n```typescript\nimport { NextRequest, NextResponse } from 'next/server'\nimport { getAuthenticatedAgent } from '@/lib/agent'\nimport { getSession } from '@/lib/session'\n\nexport const dynamic = 'force-dynamic'\n\nexport async function POST(request: NextRequest) {\n // 1. Check session.did from iron-session cookie → 401 if missing\n // 2. Call getAuthenticatedAgent() → 401 if null\n // 3. Parse body: { collection: string, rkey?: string, record: object }\n // 4. Validate collection (required, string) and record (required, object)\n // 5. Call agent.com.atproto.repo.createRecord({ repo: session.did, collection, rkey: rkey || undefined, record })\n // 6. Return { success: true, uri: res.data.uri, cid: res.data.cid }\n}\n```\n\n**PUT /api/records** — Update an existing record:\n- Same auth checks\n- Body: { collection, rkey (required), record }\n- Calls agent.com.atproto.repo.putRecord(...)\n- Returns { success: true }\n\n**DELETE /api/records?collection=...&rkey=...** — Delete a record:\n- Same auth checks\n- Params from URL searchParams\n- Calls agent.com.atproto.repo.deleteRecord(...)\n- Returns { success: true }\n\nAll methods wrap in try/catch, returning { error: message } with status 500 on failure.\n\n### Dependencies already in place\n- `lib/agent.ts` — exports `getAuthenticatedAgent()` which returns an `Agent` from `@atproto/api` (already created in previous session)\n- `lib/session.ts` — exports `getSession()` returning `Session` with optional `did` field (already created)\n- `@atproto/api` — already installed in package.json\n\n### Testing\nAfter creating the file, run `pnpm build` to verify it compiles. The route should appear in the build output as `ƒ /api/records` (dynamic route).\n\n### Acceptance criteria\n- [ ] File exists at `app/api/records/route.ts`\n- [ ] Exports POST, PUT, DELETE handlers\n- [ ] Uses `export const dynamic = 'force-dynamic'`\n- [ ] All three methods check `session.did` and `getAuthenticatedAgent()`\n- [ ] `pnpm build` passes with no type errors","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:20.159813+13:00","updatedAt":"2026-02-11T00:44:02.816953+13:00","closedAt":"2026-02-11T00:44:02.816953+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-dyi.2","title":"Create useBeadsComments hook to fetch comments from Hypergoat indexer","description":"## Create useBeadsComments hook to fetch comments from Hypergoat indexer\n\n### Goal\nCreate `hooks/useBeadsComments.ts` — a React hook that fetches all `org.impactindexer.review.comment` records from the Hypergoat GraphQL indexer, filters them to only those whose subject URI starts with `beads:`, resolves commenter profiles, and returns structured data for the UI.\n\n### What to create\n**New file:** `hooks/useBeadsComments.ts`\n\n### Hypergoat GraphQL API\n- **Endpoint:** `https://hypergoat-app-production.up.railway.app/graphql`\n- **Query pattern** (from `/Users/david/Projects/gainforest/hyperscan/src/lib/indexer.ts` line 80-100):\n```graphql\nquery FetchRecords($collection: String!, $first: Int, $after: String) {\n records(collection: $collection, first: $first, after: $after) {\n edges {\n node {\n cid\n collection\n did\n rkey\n uri\n value\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n```\n- Call with `collection: 'org.impactindexer.review.comment'`, `first: 100`\n- The `value` field is a JSON object containing the record data including `subject`, `text`, `createdAt`\n\n### Comment record shape (from `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`):\n```typescript\n{\n subject: { uri: string, type: string }, // e.g. { uri: 'beads:beads-map-cvh', type: 'record' }\n text: string,\n createdAt: string, // ISO 8601\n replyTo?: string, // AT-URI of parent comment (not used yet but good to preserve)\n}\n```\n\n### Profile resolution\nFor each unique `did` in comments, resolve to display info via the Bluesky public API:\n```typescript\n// Resolve DID to profile (handle, displayName, avatar)\nconst res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`)\nconst profile = await res.json()\n// Returns: { did, handle, displayName?, avatar? }\n```\nCache results in a module-level Map<string, ResolvedProfile> to avoid redundant fetches. Deduplicate in-flight requests.\n\n### Hook interface\n```typescript\ninterface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string; // AT-URI of the comment record itself\n rkey: string;\n}\n\ninterface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // key = beads issue ID (e.g. 'beads-map-cvh')\n commentedNodeIds: Set<string>; // for quick badge lookup in paintNode\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n\nexport function useBeadsComments(): UseBeadsCommentsResult\n```\n\n### Implementation steps\n1. On mount, call the GraphQL endpoint to fetch comments\n2. Parse each record's `value.subject.uri` — only keep those starting with `beads:`\n3. Extract the beads issue ID by stripping the `beads:` prefix (e.g. `beads:beads-map-cvh` → `beads-map-cvh`)\n4. Group comments by issue ID into a Map\n5. Build `commentedNodeIds` Set from the Map keys\n6. Resolve all unique DIDs to profiles in parallel (with caching)\n7. Merge profile data into each BeadsComment object\n8. Return the result with a `refetch()` function that re-runs the whole pipeline\n9. Comments within each node should be sorted newest-first by `createdAt`\n\n### Error handling\n- Silent failure on profile resolution (show DID prefix as fallback)\n- Set error state on GraphQL fetch failure\n- Use `cancelled` flag pattern for cleanup (matches existing codebase convention)\n\n### No dependencies to add\nThis hook only uses `fetch()` and React hooks — no new npm packages needed.\n\n### Acceptance criteria\n- [ ] File exists at `hooks/useBeadsComments.ts`\n- [ ] Fetches from Hypergoat GraphQL endpoint\n- [ ] Filters comments to only `beads:*` subject URIs\n- [ ] Groups by issue ID, provides `commentedNodeIds` Set\n- [ ] Resolves commenter profiles (handle, avatar)\n- [ ] Provides `refetch()` method\n- [ ] `pnpm build` passes (even if hook isn't wired up yet — it should have no import errors)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:28.751791+13:00","updatedAt":"2026-02-11T00:44:02.935547+13:00","closedAt":"2026-02-11T00:44:02.935547+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-dyi.4","beads-map-dyi.5","beads-map-dyi.6"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-dyi.3","title":"Add right-click handler to BeadsGraph and context menu tooltip component","description":"## Add right-click handler to BeadsGraph and context menu tooltip component\n\n### Goal\nEnable right-clicking on graph nodes to open a beautiful floating comment tooltip. Create the `CommentTooltip` component and wire the right-click event through BeadsGraph to the parent page.\n\n### Part A: Modify `components/BeadsGraph.tsx`\n\n**1. Add `onNodeRightClick` to the props interface** (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n nodes: GraphNode[];\n links: GraphLink[];\n selectedNode: GraphNode | null;\n hoveredNode: GraphNode | null;\n onNodeClick: (node: GraphNode) => void;\n onNodeHover: (node: GraphNode | null) => void;\n onBackgroundClick: () => void;\n onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void; // NEW\n}\n```\n\n**2. Destructure the new prop** in the component function (around line 145):\n```typescript\nconst { nodes, links, selectedNode, hoveredNode, onNodeClick, onNodeHover, onBackgroundClick, onNodeRightClick } = props;\n```\nNote: BeadsGraph uses `forwardRef` — the props are the first argument.\n\n**3. Add `onNodeRightClick` to ForceGraph2D** (around line 1231-1235, after `onNodeClick`):\n```typescript\nonNodeRightClick={(node: any, event: MouseEvent) => {\n event.preventDefault();\n onNodeRightClick?.(node as GraphNode, event);\n}}\n```\nThe `react-force-graph-2d` library supports `onNodeRightClick` as a built-in prop. The `event.preventDefault()` prevents the browser's default context menu.\n\n### Part B: Create `components/CommentTooltip.tsx`\n\n**New file:** `components/CommentTooltip.tsx`\n\nThis is a `'use client'` component that renders an absolutely-positioned floating tooltip near the right-click location.\n\n**Design inspiration:** The tooltip from `/Users/david/Projects/gainforest/plresearch.org/src/app/areas/economies-governance/dependency-graph/DependencyGraph.tsx` (lines 237-274). Key design elements:\n- White background (`#FFFFFF`)\n- Subtle border: `1px solid #E5E7EB`\n- Border radius: `8px`\n- Padding: `18px 20px`\n- Box shadow: `0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08)`\n- Fade-in animation: `0.2s ease` from `opacity: 0; translateY(4px)` to `opacity: 1; translateY(0)`\n- Colored accent bar at top: `width: 24px, height: 2px` using the node's prefix color\n\n**Props:**\n```typescript\ninterface CommentTooltipProps {\n node: GraphNode;\n x: number; // screen X from MouseEvent.clientX\n y: number; // screen Y from MouseEvent.clientY\n onClose: () => void;\n onSubmit: (text: string) => Promise<void>;\n isAuthenticated: boolean;\n existingComments?: BeadsComment[]; // show recent comments in tooltip too\n}\n```\n\n**Layout (top to bottom):**\n1. **Colored accent bar** — 24px wide, 2px tall, using `PREFIX_COLORS[node.prefix]` from `@/lib/types`\n2. **Node info** — ID in mono font (`text-emerald-600`), title in `font-semibold text-sm text-zinc-800`\n3. **Existing comments preview** — if any, show count like '3 comments' as a subtle label, with the most recent 1-2 comments abbreviated\n4. **Textarea** — if authenticated: `<textarea>` with placeholder 'Leave a comment...', 3 rows, matching zinc style. If not authenticated: show `<p>Sign in to comment</p>` with a muted style.\n5. **Action row** — Send button (emerald bg, white text, rounded, small) + Cancel button (text-only, zinc). Send button disabled when textarea empty. Shows spinner during submission.\n\n**Positioning logic:**\n- Position at `(x + 14, y - tooltipHeight - 14)` relative to viewport\n- If overflows right: clamp to `window.innerWidth - tooltipWidth - 16`\n- If overflows left: clamp to `16`\n- If overflows top: flip below cursor at `(x + 14, y + 28)`\n- Use a `useRef` + `useEffect` to measure tooltip dimensions after first render and adjust position (same pattern as plresearch.org Tooltip component, lines 244-256)\n- Fixed positioning (`position: fixed`) since it's relative to the viewport, not a container\n\n**Interaction:**\n- Closes on Escape key (add keydown listener in useEffect)\n- Closes on click outside (add mousedown listener, check if event.target is outside tooltip ref)\n- Auto-focuses the textarea on mount\n- After successful submit: calls `onSubmit(text)`, clears textarea, calls `onClose()`\n\n**Tailwind animation:** Add a CSS class or inline style for the fade-in:\n```css\n@keyframes tooltipFadeIn {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: translateY(0); }\n}\n```\nUse inline style `animation: 'tooltipFadeIn 0.2s ease'` or a Tailwind animate class.\n\n### Acceptance criteria\n- [ ] `BeadsGraphProps` includes `onNodeRightClick`\n- [ ] ForceGraph2D has `onNodeRightClick` handler with `preventDefault()`\n- [ ] `components/CommentTooltip.tsx` exists with the design described above\n- [ ] Tooltip positions near cursor, clamped to viewport\n- [ ] Closes on Escape, click outside\n- [ ] Shows auth-gated textarea vs 'Sign in to comment' message\n- [ ] Send button calls `onSubmit` with text, shows loading state\n- [ ] `pnpm build` passes (component may not be rendered yet — that's task .6)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:39.225841+13:00","updatedAt":"2026-02-11T00:44:03.051623+13:00","closedAt":"2026-02-11T00:44:03.051623+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-dyi.4","title":"Add comment icon badge to nodes with comments in paintNode","description":"## Add comment icon badge to nodes with comments in paintNode\n\n### Goal\nDraw a small speech-bubble comment icon on graph nodes that have ATProto comments. This provides at-a-glance visual feedback about which issues have been discussed.\n\n### What to modify\n**File:** `components/BeadsGraph.tsx`\n\n### Step 1: Add `commentedNodeIds` prop\n\nAdd to `BeadsGraphProps` interface (line 28-36):\n```typescript\ninterface BeadsGraphProps {\n // ... existing props ...\n commentedNodeIds?: Set<string>; // NEW — node IDs that have comments\n}\n```\n\nDestructure in the component (around line 145 where other props are destructured):\n```typescript\nconst { ..., commentedNodeIds } = props;\n```\n\n### Step 2: Create a ref for commentedNodeIds\n\nFollowing the established ref pattern in BeadsGraph (lines 181-185 where `selectedNodeRef`, `hoveredNodeRef`, `connectedNodesRef` are declared):\n\n```typescript\nconst commentedNodeIdsRef = useRef<Set<string>>(commentedNodeIds || new Set());\n```\n\nAdd a sync effect (near lines 263-293 where selectedNode/hoveredNode ref syncs happen):\n```typescript\nuseEffect(() => {\n commentedNodeIdsRef.current = commentedNodeIds || new Set();\n // Trigger a canvas redraw so the badge appears/disappears\n refreshGraph(graphRef);\n}, [commentedNodeIds]);\n```\n\n**Why a ref?** The `paintNode` callback has an empty dependency array (`[]`) — it reads all visual state from refs, not props. This avoids recreating the callback and re-rendering ForceGraph2D. This is the same pattern used for `selectedNodeRef`, `hoveredNodeRef`, and `connectedNodesRef` (see lines 181-185, 263-293).\n\n### Step 3: Draw the comment badge in paintNode\n\nIn the `paintNode` callback (lines 456-631), add the badge drawing AFTER the label section (around line 625, before `ctx.restore()` on line 628):\n\n```typescript\n// Comment badge — small speech bubble at top-right of node\nif (commentedNodeIdsRef.current.has(graphNode.id) && globalScale > 0.5) {\n const badgeSize = Math.min(6, Math.max(3, 8 / globalScale));\n // Position at ~45 degrees from center, just outside the node circle\n const badgeX = node.x + animatedSize * 0.7;\n const badgeY = node.y - animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = opacity * 0.85;\n\n // Speech bubble body (rounded rect)\n const bw = badgeSize * 1.6; // bubble width\n const bh = badgeSize * 1.2; // bubble height\n const br = badgeSize * 0.3; // border radius\n ctx.beginPath();\n ctx.moveTo(badgeX - bw/2 + br, badgeY - bh/2);\n ctx.lineTo(badgeX + bw/2 - br, badgeY - bh/2);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY - bh/2, badgeX + bw/2, badgeY - bh/2 + br);\n ctx.lineTo(badgeX + bw/2, badgeY + bh/2 - br);\n ctx.quadraticCurveTo(badgeX + bw/2, badgeY + bh/2, badgeX + bw/2 - br, badgeY + bh/2);\n // Small triangle pointer at bottom-left\n ctx.lineTo(badgeX - bw/4, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/3, badgeY + bh/2 + badgeSize * 0.4);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.lineTo(badgeX - bw/2 + br, badgeY + bh/2);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY + bh/2, badgeX - bw/2, badgeY + bh/2 - br);\n ctx.lineTo(badgeX - bw/2, badgeY - bh/2 + br);\n ctx.quadraticCurveTo(badgeX - bw/2, badgeY - bh/2, badgeX - bw/2 + br, badgeY - bh/2);\n ctx.closePath();\n\n ctx.fillStyle = '#3b82f6'; // blue-500\n ctx.fill();\n\n ctx.restore();\n}\n```\n\nThe exact canvas drawing can be simplified/refined — the key requirements are:\n- Small speech-bubble shape (recognizable as a comment icon)\n- Positioned at top-right of the node circle\n- Blue fill (`#3b82f6`) at ~0.85 opacity\n- Only drawn when `globalScale > 0.5` (same threshold as labels on line 598)\n- Scales with zoom level like other indicators\n\n### Acceptance criteria\n- [ ] `commentedNodeIds` prop added to `BeadsGraphProps`\n- [ ] Ref created and synced with effect + `refreshGraph()` call\n- [ ] Speech bubble badge drawn in `paintNode` for nodes in the set\n- [ ] Badge scales with zoom, only visible at reasonable zoom levels\n- [ ] No ForceGraph re-render triggered (ref pattern maintained)\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:47.743964+13:00","updatedAt":"2026-02-11T00:44:03.169692+13:00","closedAt":"2026-02-11T00:44:03.169692+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi","beads-map-dyi.2"]},{"id":"beads-map-dyi.5","title":"Add comment section to NodeDetail panel","description":"## Add comment section to NodeDetail panel\n\n### Goal\nAdd a 'Comments' section at the bottom of the NodeDetail sidebar panel that shows existing ATProto comments for the selected node and provides an inline compose area for authenticated users.\n\n### What to modify\n**File:** `components/NodeDetail.tsx`\n\n### Current file structure (304 lines)\n- Line 1-18: imports and props interface\n- Line 20-247: main `NodeDetail` component\n - Line 25-46: null state (no node selected)\n - Line 48-245: node detail rendering\n - Lines 229-245: 'Blocked by' section (LAST section before closing div)\n - Line 246: closing `</div>`\n- Lines 250-298: helper components (`MetricCard`, `DependencyLink`)\n- Lines 300-303: `truncateDescription`\n\n### Changes needed\n\n**1. Expand the props interface** (line 14-18):\n```typescript\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // NEW import\n\ninterface NodeDetailProps {\n node: GraphNode | null;\n allNodes: GraphNode[];\n onNodeNavigate: (nodeId: string) => void;\n comments?: BeadsComment[]; // NEW — comments for selected node\n onPostComment?: (text: string) => Promise<void>; // NEW — submit callback\n isAuthenticated?: boolean; // NEW — auth state for compose area\n}\n```\n\n**2. Destructure new props** (line 20-24):\n```typescript\nexport default function NodeDetail({\n node, allNodes, onNodeNavigate, comments, onPostComment, isAuthenticated,\n}: NodeDetailProps) {\n```\n\n**3. Add Comments section** (after the 'Blocked by' section, around line 245, before the closing `</div>`):\n\n```tsx\n{/* Comments */}\n<div className=\"mb-4\">\n <h4 className=\"text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2\">\n Comments {comments && comments.length > 0 && (\n <span className=\"ml-1 px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full text-[10px] font-medium\">\n {comments.length}\n </span>\n )}\n </h4>\n\n {/* Comment list */}\n {comments && comments.length > 0 ? (\n <div className=\"space-y-3\">\n {comments.map((comment) => (\n <CommentItem key={comment.uri} comment={comment} />\n ))}\n </div>\n ) : (\n <p className=\"text-xs text-zinc-400 italic\">No comments yet</p>\n )}\n\n {/* Compose area */}\n {isAuthenticated && onPostComment ? (\n <CommentCompose onSubmit={onPostComment} />\n ) : !isAuthenticated ? (\n <p className=\"text-xs text-zinc-400 mt-2\">Sign in to leave a comment</p>\n ) : null}\n</div>\n```\n\n**4. Create helper sub-components** (after `DependencyLink`, before `truncateDescription`):\n\n**CommentItem** — displays a single comment:\n```tsx\nfunction CommentItem({ comment }: { comment: BeadsComment }) {\n return (\n <div className=\"flex gap-2\">\n {/* Avatar */}\n <div className=\"shrink-0 w-6 h-6 rounded-full bg-zinc-100 overflow-hidden\">\n {comment.avatar ? (\n <img src={comment.avatar} alt=\"\" className=\"w-full h-full object-cover\" />\n ) : (\n <div className=\"w-full h-full flex items-center justify-center text-[10px] font-medium text-zinc-400\">\n {(comment.handle || comment.did).charAt(0).toUpperCase()}\n </div>\n )}\n </div>\n {/* Content */}\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-xs font-medium text-zinc-600 truncate\">\n {comment.displayName || comment.handle || comment.did.slice(0, 16) + '...'}\n </span>\n <span className=\"text-[10px] text-zinc-300 shrink-0\">\n {formatRelativeTime(comment.createdAt)}\n </span>\n </div>\n <p className=\"text-xs text-zinc-500 mt-0.5 whitespace-pre-wrap break-words\">{comment.text}</p>\n </div>\n </div>\n );\n}\n```\n\n**CommentCompose** — inline textarea + send button:\n```tsx\nfunction CommentCompose({ onSubmit }: { onSubmit: (text: string) => Promise<void> }) {\n const [text, setText] = useState('');\n const [sending, setSending] = useState(false);\n\n const handleSubmit = async () => {\n if (!text.trim() || sending) return;\n setSending(true);\n try {\n await onSubmit(text.trim());\n setText('');\n } catch (err) {\n console.error('Failed to post comment:', err);\n } finally {\n setSending(false);\n }\n };\n\n return (\n <div className=\"mt-3 space-y-2\">\n <textarea\n value={text}\n onChange={(e) => setText(e.target.value)}\n placeholder=\"Leave a comment...\"\n rows={2}\n className=\"w-full px-2.5 py-1.5 text-xs border border-zinc-200 rounded-md bg-zinc-50 text-zinc-700 placeholder-zinc-400 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500\"\n />\n <button\n onClick={handleSubmit}\n disabled={!text.trim() || sending}\n className=\"px-3 py-1 text-xs font-medium text-white bg-emerald-500 rounded-md hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {sending ? 'Sending...' : 'Comment'}\n </button>\n </div>\n );\n}\n```\n\n**formatRelativeTime** helper:\n```typescript\nfunction formatRelativeTime(isoString: string): string {\n const date = new Date(isoString);\n const now = new Date();\n const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);\n if (seconds < 60) return 'just now';\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return minutes + 'm ago';\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return hours + 'h ago';\n const days = Math.floor(hours / 24);\n if (days < 7) return days + 'd ago';\n return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\n}\n```\n\n**5. Add `useState` import** — needed for `CommentCompose`. Add to the existing React import at line 1 or import separately.\n\n### Styling notes\n- Matches existing NodeDetail style: `text-xs`, zinc palette, `mb-4` section spacing\n- Avatar uses plain `<img>` (not `next/image`) — consistent with AuthButton.tsx pattern\n- Count badge uses blue accent to match the comment badge on the graph nodes\n- Compose area uses emerald accent for the submit button (matches the app's primary color)\n\n### Acceptance criteria\n- [ ] 'Comments' section appears after 'Blocked by' in NodeDetail\n- [ ] Shows comment count badge when comments exist\n- [ ] Each comment shows avatar, handle/name, relative time, text\n- [ ] Empty state shows 'No comments yet' placeholder\n- [ ] Authenticated users see compose textarea + submit button\n- [ ] Unauthenticated users see 'Sign in to leave a comment'\n- [ ] Submit clears textarea and calls `onPostComment`\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:31:54.777115+13:00","updatedAt":"2026-02-11T00:44:03.284193+13:00","closedAt":"2026-02-11T00:44:03.284193+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-dyi.6"],"dependentIds":["beads-map-dyi","beads-map-dyi.2"]},{"id":"beads-map-dyi.6","title":"Wire everything together in page.tsx and build verification","description":"## Wire everything together in page.tsx and build verification\n\n### Goal\nConnect all the pieces created in tasks .1-.5 in the main `app/page.tsx` orchestration file. This is the final integration task.\n\n### What to modify\n**File:** `app/page.tsx` (currently 811 lines)\n\n### Current file structure (key sections)\n- Line 1: `'use client'`\n- Lines 3-12: imports\n- Lines 14-67: helper functions (`findNeighborPosition`, etc.)\n- Lines 69-811: main `Home` component\n - Lines 73-90: state declarations\n - Lines 175-250: SSE/fetch data loading\n - Lines 280-320: event handlers (`handleNodeClick`, `handleNodeHover`, etc.)\n - Lines 680-695: BeadsGraph rendering\n - Lines 697-765: Desktop sidebar with NodeDetail\n - Lines 767-806: Mobile drawer with NodeDetail\n\n### Step 1: Add imports (near lines 3-12)\n\n```typescript\nimport { CommentTooltip } from '@/components/CommentTooltip'; // task .3\nimport { useBeadsComments } from '@/hooks/useBeadsComments'; // task .2\nimport type { BeadsComment } from '@/hooks/useBeadsComments'; // task .2\nimport { useAuth } from '@/lib/auth'; // already importable\n```\n\n### Step 2: Add state and hooks (near lines 73-90, after existing state declarations)\n\n```typescript\n// Auth state\nconst { isAuthenticated, session } = useAuth();\n\n// Comments from ATProto indexer\nconst { commentsByNode, commentedNodeIds, refetch: refetchComments } = useBeadsComments();\n\n// Context menu state for right-click tooltip\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n### Step 3: Create event handlers (near lines 280-320)\n\n**Right-click handler:**\n```typescript\nconst handleNodeRightClick = useCallback((node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n}, []);\n```\n\n**Post comment callback:**\n```typescript\nconst handlePostComment = useCallback(async (nodeId: string, text: string) => {\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n $type: 'org.impactindexer.review.comment',\n subject: {\n uri: `beads:${nodeId}`,\n type: 'record',\n },\n text,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n\n if (!response.ok) {\n const data = await response.json();\n throw new Error(data.error || 'Failed to post comment');\n }\n\n // Refetch comments to update the UI\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 4: Pass props to BeadsGraph (around lines 680-695)\n\nAdd the new props to the `<BeadsGraph>` component:\n```tsx\n<BeadsGraph\n ref={graphRef}\n nodes={data.graphData.nodes}\n links={data.graphData.links}\n selectedNode={selectedNode}\n hoveredNode={hoveredNode}\n onNodeClick={handleNodeClick}\n onNodeHover={handleNodeHover}\n onBackgroundClick={handleBackgroundClick}\n onNodeRightClick={handleNodeRightClick} // NEW\n commentedNodeIds={commentedNodeIds} // NEW\n/>\n```\n\n### Step 5: Pass props to NodeDetail (desktop sidebar, around line 733-737)\n\n```tsx\n<NodeDetail\n node={selectedNode}\n allNodes={data.graphData.nodes}\n onNodeNavigate={handleNodeNavigate}\n comments={selectedNode ? commentsByNode.get(selectedNode.id) : undefined} // NEW\n onPostComment={selectedNode ? (text: string) => handlePostComment(selectedNode.id, text) : undefined} // NEW\n isAuthenticated={isAuthenticated} // NEW\n/>\n```\n\nDo the same for the mobile drawer NodeDetail (around line 799-803).\n\n### Step 6: Render CommentTooltip (after BeadsGraph, before the sidebar, around line 695)\n\n```tsx\n{/* Right-click comment tooltip */}\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n### Step 7: Close tooltip on background click\n\nModify `handleBackgroundClick` to also close the context menu:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null); // NEW — close tooltip too\n}, []);\n```\n\n### Step 8: Build and fix errors\n\nRun `pnpm build` and fix any type errors. Common issues to watch for:\n- Import path typos\n- Missing exports (e.g., `BeadsComment` type not exported from hook)\n- `useAuth` must be called inside `AuthProvider` (it already is — layout.tsx wraps children)\n- The `CommentTooltip` component must be exported as named export (check consistency with import)\n\n### Acceptance criteria\n- [ ] `useBeadsComments` hook called at top level of Home component\n- [ ] `useAuth` provides `isAuthenticated` state\n- [ ] `contextMenu` state manages right-click tooltip position + node\n- [ ] `handleNodeRightClick` creates context menu state\n- [ ] `handlePostComment` POSTs to `/api/records` with correct record shape\n- [ ] BeadsGraph receives `onNodeRightClick` and `commentedNodeIds`\n- [ ] Both desktop and mobile NodeDetail receive comments + postComment + isAuthenticated\n- [ ] CommentTooltip renders when `contextMenu` is set\n- [ ] Background click closes both selection and context menu\n- [ ] `pnpm build` passes with zero errors\n- [ ] All auth API routes visible in build output (`ƒ /api/records`, etc.)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:32:01.724819+13:00","updatedAt":"2026-02-11T00:44:03.398953+13:00","closedAt":"2026-02-11T00:44:03.398953+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":6,"blockerIds":[],"dependentIds":["beads-map-dyi","beads-map-dyi.1","beads-map-dyi.2","beads-map-dyi.3","beads-map-dyi.4","beads-map-dyi.5"]},{"id":"beads-map-dyi.7","title":"Add delete button for own comments","description":"## Add delete button for own comments\n\n### Goal\nAllow authenticated users to delete comments they have authored. Show a small trash/X icon on comments where the logged-in user's DID matches the comment's DID. Clicking it calls DELETE /api/records to remove the record from their PDS, then refetches comments.\n\n### What to modify\n\n#### 1. `components/NodeDetail.tsx` — CommentItem sub-component\n\nAdd a delete button that appears only when the comment's `did` matches the current user's DID.\n\n**Props change for CommentItem:**\n```typescript\nfunction CommentItem({ comment, currentDid, onDelete }: {\n comment: BeadsComment;\n currentDid?: string;\n onDelete?: (comment: BeadsComment) => Promise<void>;\n})\n```\n\n**UI:** A small X or trash icon button, only visible when `currentDid === comment.did`. Positioned at the top-right of the comment row. On hover, it becomes visible (use `group` + `group-hover:opacity-100` pattern or always-visible is fine for simplicity). Shows a confirmation or just deletes immediately. While deleting, show a subtle spinner or disabled state.\n\n**Delete call pattern** (from Hyperscan `/Users/david/Projects/gainforest/hyperscan/src/app/api/records/route.ts`):\n```typescript\n// The rkey is extracted from the comment's AT-URI: at://did/collection/rkey\n// comment.rkey is already available in BeadsComment\nawait fetch(`/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`, {\n method: 'DELETE',\n});\n```\n\n#### 2. `components/NodeDetail.tsx` — NodeDetailProps\n\nAdd `currentDid` to props:\n```typescript\ninterface NodeDetailProps {\n // ... existing props ...\n currentDid?: string; // NEW — the authenticated user's DID for ownership checks\n}\n```\n\n#### 3. `components/CommentTooltip.tsx` — existing comments preview\n\nOptionally add delete to the tooltip preview too, or skip for simplicity (tooltip is compact). Recommended: skip delete in tooltip, only in NodeDetail.\n\n#### 4. `app/page.tsx` — pass currentDid and onDeleteComment\n\nPass `session?.did` as `currentDid` to both desktop and mobile `<NodeDetail>` instances.\n\nCreate a `handleDeleteComment` callback:\n```typescript\nconst handleDeleteComment = useCallback(async (comment: BeadsComment) => {\n const response = await fetch(\n `/api/records?collection=org.impactindexer.review.comment&rkey=${encodeURIComponent(comment.rkey)}`,\n { method: 'DELETE' }\n );\n if (!response.ok) {\n const errData = await response.json();\n throw new Error(errData.error || 'Failed to delete comment');\n }\n await refetchComments();\n}, [refetchComments]);\n```\n\nPass it to NodeDetail:\n```tsx\n<NodeDetail\n ...existing props...\n currentDid={session?.did}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n#### 5. `components/NodeDetail.tsx` — wire onDeleteComment\n\nAdd to NodeDetailProps:\n```typescript\nonDeleteComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nPass to CommentItem:\n```tsx\n<CommentItem\n key={comment.uri}\n comment={comment}\n currentDid={currentDid}\n onDelete={onDeleteComment}\n/>\n```\n\n### Existing infrastructure\n- `DELETE /api/records?collection=...&rkey=...` already exists (created in beads-map-dyi.1)\n- `BeadsComment` type has `rkey` and `did` fields (from useBeadsComments hook)\n- `useAuth()` provides `session.did` (from lib/auth.tsx)\n- `refetchComments()` from `useBeadsComments` hook refreshes the comment list\n\n### Acceptance criteria\n- [ ] Delete icon/button appears only on comments authored by the current user\n- [ ] Clicking delete calls `DELETE /api/records` with correct collection and rkey\n- [ ] After successful delete, comments list refreshes automatically\n- [ ] Shows loading/disabled state during deletion\n- [ ] `pnpm build` passes with zero errors","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T00:45:37.231167+13:00","updatedAt":"2026-02-11T00:47:32.38037+13:00","closedAt":"2026-02-11T00:47:32.38037+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-ecl","title":"Wire EventSource in page.tsx with merge logic","description":"Modify: app/page.tsx\n\nPURPOSE: Replace the one-shot fetch(\"/api/beads\") with an EventSource connected to /api/beads/stream. On each SSE message, diff the new data against current state, stamp animation metadata, and update React state. This is the central coordination point where server data meets client state.\n\nCHANGES TO page.tsx:\n\n1. ADD IMPORTS at top:\n import { diffBeadsData, linkKey } from \"@/lib/diff-beads\";\n import type { BeadsDiff } from \"@/lib/diff-beads\";\n\n2. ADD a ref to track the previous data for diffing:\n const prevDataRef = useRef<BeadsApiResponse | null>(null);\n\n3. REPLACE the existing fetch useEffect (lines 38-53) with EventSource logic:\n\n```typescript\n // Live-streaming beads data via SSE\n useEffect(() => {\n let eventSource: EventSource | null = null;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n function connect() {\n eventSource = new EventSource(\"/api/beads/stream\");\n\n eventSource.onmessage = (event) => {\n try {\n const newData = JSON.parse(event.data) as BeadsApiResponse;\n if ((newData as any).error) {\n setError((newData as any).error);\n setLoading(false);\n return;\n }\n\n const oldData = prevDataRef.current;\n const diff = diffBeadsData(oldData, newData);\n\n if (!oldData) {\n // Initial load — no animations, just set data\n prevDataRef.current = newData;\n setData(newData);\n setLoading(false);\n return;\n }\n\n if (!diff.hasChanges) return; // No-op if nothing changed\n\n // Merge: stamp animation metadata and preserve positions\n const mergedData = mergeBeadsData(oldData, newData, diff);\n prevDataRef.current = mergedData;\n setData(mergedData);\n } catch (err) {\n console.error(\"Failed to parse SSE message:\", err);\n }\n };\n\n eventSource.onerror = () => {\n // EventSource auto-reconnects, but we handle the gap\n if (eventSource?.readyState === EventSource.CLOSED) {\n // Permanent failure — try manual reconnect after delay\n reconnectTimer = setTimeout(connect, 5000);\n }\n };\n\n // If still loading after 5s, fall back to one-shot fetch\n setTimeout(() => {\n if (loading) {\n fetch(\"/api/beads\")\n .then(res => res.json())\n .then(data => {\n if (!prevDataRef.current) {\n prevDataRef.current = data;\n setData(data);\n setLoading(false);\n }\n })\n .catch(() => {});\n }\n }, 5000);\n }\n\n connect();\n\n return () => {\n eventSource?.close();\n if (reconnectTimer) clearTimeout(reconnectTimer);\n };\n }, []);\n```\n\n4. ADD the mergeBeadsData function (above the component or as a module-level function):\n\n```typescript\nfunction mergeBeadsData(\n oldData: BeadsApiResponse,\n newData: BeadsApiResponse,\n diff: BeadsDiff\n): BeadsApiResponse {\n const now = Date.now();\n\n // Build position map from old nodes (preserves x/y/fx/fy from simulation)\n const oldNodeMap = new Map(oldData.graphData.nodes.map(n => [n.id, n]));\n const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));\n\n // Merge nodes: carry over positions, stamp animation metadata\n const mergedNodes = newData.graphData.nodes.map(node => {\n const oldNode = oldNodeMap.get(node.id);\n\n if (!oldNode) {\n // NEW NODE — stamp spawn time, place near a connected neighbor\n const neighbor = findNeighborPosition(node.id, newData.graphData.links, oldNodeMap);\n return {\n ...node,\n _spawnTime: now,\n x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,\n y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,\n };\n }\n\n // EXISTING NODE — preserve position, check for changes\n const merged = {\n ...node,\n x: oldNode.x,\n y: oldNode.y,\n fx: oldNode.fx,\n fy: oldNode.fy,\n };\n\n // Stamp change metadata if status changed\n if (diff.changedNodes.has(node.id)) {\n const changes = diff.changedNodes.get(node.id)!;\n const statusChange = changes.find(c => c.field === \"status\");\n if (statusChange) {\n merged._changedAt = now;\n merged._prevStatus = statusChange.from;\n }\n }\n\n return merged;\n });\n\n // Handle removed nodes: keep them briefly for exit animation\n for (const removedId of diff.removedNodeIds) {\n const oldNode = oldNodeMap.get(removedId);\n if (oldNode) {\n mergedNodes.push({\n ...oldNode,\n _removeTime: now,\n });\n }\n }\n\n // Merge links: stamp spawn time on new links\n const mergedLinks = newData.graphData.links.map(link => {\n const key = linkKey(link);\n if (!oldLinkKeySet.has(key)) {\n return { ...link, _spawnTime: now };\n }\n return link;\n });\n\n // Handle removed links: keep briefly for exit animation\n for (const removedKey of diff.removedLinkKeys) {\n const oldLink = oldData.graphData.links.find(l => linkKey(l) === removedKey);\n if (oldLink) {\n mergedLinks.push({\n source: typeof oldLink.source === \"object\" ? (oldLink.source as any).id : oldLink.source,\n target: typeof oldLink.target === \"object\" ? (oldLink.target as any).id : oldLink.target,\n type: oldLink.type,\n _removeTime: now,\n });\n }\n }\n\n return {\n ...newData,\n graphData: {\n nodes: mergedNodes as any,\n links: mergedLinks as any,\n },\n };\n}\n\n// Find position of a neighbor node (for placing new nodes near connections)\nfunction findNeighborPosition(\n nodeId: string,\n links: GraphLink[],\n nodeMap: Map<string, GraphNode>\n): { x: number; y: number } | null {\n for (const link of links) {\n const src = typeof link.source === \"object\" ? (link.source as any).id : link.source;\n const tgt = typeof link.target === \"object\" ? (link.target as any).id : link.target;\n if (src === nodeId && nodeMap.has(tgt)) {\n const n = nodeMap.get(tgt)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n if (tgt === nodeId && nodeMap.has(src)) {\n const n = nodeMap.get(src)!;\n if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number };\n }\n }\n return null;\n}\n```\n\n5. ADD cleanup of expired animation items.\n After a timeout, remove nodes/links that have _removeTime older than 600ms:\n\n```typescript\n // Clean up expired exit animations\n useEffect(() => {\n if (!data) return;\n const timer = setTimeout(() => {\n const now = Date.now();\n const EXPIRE_MS = 600;\n const nodes = data.graphData.nodes.filter(\n n => !n._removeTime || now - n._removeTime < EXPIRE_MS\n );\n const links = data.graphData.links.filter(\n l => !(l as any)._removeTime || now - (l as any)._removeTime < EXPIRE_MS\n );\n if (nodes.length !== data.graphData.nodes.length || links.length !== data.graphData.links.length) {\n setData(prev => prev ? {\n ...prev,\n graphData: { nodes, links },\n } : prev);\n }\n }, 700); // slightly after animation duration\n return () => clearTimeout(timer);\n }, [data]);\n```\n\n6. KEEP the existing /api/config fetch useEffect unchanged.\n\n7. UPDATE the stats display in the header to exclude nodes/links with _removeTime (so counts reflect real data, not animated ghosts).\n\nWHY FULL MERGE IN page.tsx:\nThe merge logic lives here because it's where we have access to both the old React state (with simulation positions) and the new server data. BeadsGraph.tsx just receives nodes/links props and renders — it doesn't need to know about the merge.\n\nPOSITION PRESERVATION IS CRITICAL:\nreact-force-graph-2d mutates node objects in-place, setting x/y/vx/vy during simulation. If we replace nodes with fresh objects from the server (which have no x/y), the entire graph layout resets. The mergeBeadsData function copies x/y/fx/fy from old nodes to preserve positions.\n\nDEPENDS ON: task .3 (SSE endpoint), task .4 (diff-beads.ts)\n\nACCEPTANCE CRITERIA:\n- EventSource connects to /api/beads/stream on mount\n- Initial data loads correctly (same as before)\n- When JSONL changes, new data streams in and state updates\n- New nodes get _spawnTime stamped\n- Changed nodes get _changedAt + _prevStatus stamped\n- Removed nodes/links kept briefly with _removeTime for exit animation\n- Existing node positions preserved across updates\n- New nodes placed near their connected neighbors\n- Expired animation items cleaned up after 600ms\n- Fallback to one-shot fetch if SSE fails after 5s\n- EventSource cleaned up on unmount\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:17:01.615466+13:00","updatedAt":"2026-02-10T23:36:14.609896+13:00","closedAt":"2026-02-10T23:36:14.609896+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-iyn"],"dependentIds":["beads-map-3jy","beads-map-7j2","beads-map-2fk"]},{"id":"beads-map-f8f","title":"Switch Catppuccin palette from Mocha to Latte for light background","description":"## What (retroactive — already done)\n\nSwapped all 14 Catppuccin accent hex values from Mocha (pastel, designed for dark backgrounds) to Latte (saturated, designed for light backgrounds) for better contrast on the app's white/zinc-50 background.\n\n## Commit\n- c2e815a — Switch from Catppuccin Mocha to Latte palette for better contrast on white background\n\n## Changes\n\n### lib/types.ts (single file, single source of truth)\n- **Renamed** \\`CATPPUCCIN_MOCHA_ACCENTS\\` → \\`CATPPUCCIN_ACCENTS\\` (flavor-agnostic name)\n- **Swapped all 14 hex values** from Mocha → Latte, same contrast-maximizing order:\n | Name | Mocha (old) | Latte (new) |\n |---|---|---|\n | Red | #f38ba8 | #d20f39 |\n | Teal | #94e2d5 | #179299 |\n | Peach | #fab387 | #fe640b |\n | Blue | #89b4fa | #1e66f5 |\n | Green | #a6e3a1 | #40a02b |\n | Mauve | #cba6f7 | #8839ef |\n | Yellow | #f9e2af | #df8e1d |\n | Sapphire | #74c7ec | #209fb5 |\n | Pink | #f5c2e7 | #ea76cb |\n | Sky | #89dceb | #04a5e5 |\n | Maroon | #eba0b3 | #e64553 |\n | Lavender | #b4befe | #7287fd |\n | Flamingo | #f2cdcd | #dd7878 |\n | Rosewater | #f5e0dc | #dc8a78 |\n- **Unassigned color**: Changed from Mocha Surface2 (#585b70) to Latte Surface2 (#acb0be)\n- Updated \\`getPersonColor()\\` to reference \\`CATPPUCCIN_ACCENTS\\` instead of \\`CATPPUCCIN_MOCHA_ACCENTS\\`\n- Updated doc comments from \"Mocha\" to \"Latte\"\n\n## Result\nAll Catppuccin-colored elements (node fill in owner/assignee/prefix modes, outer rings, cluster circles, tooltip accent bars) now use the Latte flavor with much better contrast against the white background.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T14:22:17.911078+13:00","updatedAt":"2026-02-12T14:22:50.456364+13:00","closedAt":"2026-02-12T14:22:50.456364+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-2u2"]},{"id":"beads-map-gjo","title":"Add animation timestamp fields to types + export getAdditionalRepoPaths","description":"Foundation task: add animation metadata fields to GraphNode/GraphLink types and export a currently-private function from parse-beads.ts.\n\nFILE 1: lib/types.ts\n\nAdd optional animation timestamp fields to GraphNode interface (after the fx/fy fields, around line 61):\n\n // Animation metadata (set by live-update merge logic, consumed by paintNode)\n _spawnTime?: number; // Date.now() when this node first appeared (for pop-in animation)\n _removeTime?: number; // Date.now() when this node was marked for removal (for shrink-out)\n _changedAt?: number; // Date.now() when status/priority changed (for ripple animation)\n _prevStatus?: string; // Previous status value before the change (for color transition)\n\nAdd optional animation timestamp fields to GraphLink interface (after the type field, around line 67):\n\n // Animation metadata (set by live-update merge logic, consumed by paintLink)\n _spawnTime?: number; // Date.now() when this link first appeared (for fade-in animation)\n _removeTime?: number; // Date.now() when this link was marked for removal (for fade-out)\n\nIMPORTANT: These fields use the underscore prefix convention to signal they are transient metadata not persisted to JSONL. They are set by the merge logic in page.tsx and consumed by paintNode/paintLink in BeadsGraph.tsx.\n\nIMPORTANT: GraphNode has an index signature [key: string]: unknown at line 37. The new fields must be declared as optional properties within the interface body (not via the index signature) so TypeScript knows their types.\n\nFILE 2: lib/parse-beads.ts\n\nThe function getAdditionalRepoPaths(beadsDir: string): string[] at line 26 is currently private (no export keyword). Change it to:\n\n export function getAdditionalRepoPaths(beadsDir: string): string[]\n\nThis is needed by lib/watch-beads.ts (task .2) to discover which JSONL files to watch.\n\nNo other changes to parse-beads.ts.\n\nACCEPTANCE CRITERIA:\n- GraphNode has _spawnTime, _removeTime, _changedAt, _prevStatus optional fields\n- GraphLink has _spawnTime, _removeTime optional fields\n- getAdditionalRepoPaths is exported from parse-beads.ts\n- pnpm build passes with zero errors\n- No runtime behavior changes (animation fields are just type declarations, unused until task .5/.6/.7)","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:15:11.332936+13:00","updatedAt":"2026-02-10T23:24:57.300177+13:00","closedAt":"2026-02-10T23:24:57.300177+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":1,"blockerIds":["beads-map-2fk","beads-map-m1o"],"dependentIds":["beads-map-3jy"]},{"id":"beads-map-hrt","title":"Copy-to-clipboard button for node descriptions","description":"## What (retroactive — already done)\n\nAdded a clipboard copy icon button to both the description modal and the node detail sidebar panel, allowing users to copy the raw markdown text of a task description with one click.\n\n## Commit\n- b499aac — Add copy-to-clipboard button for descriptions in modal and detail panel\n\n## Changes\n\n### components/DescriptionModal.tsx\n- Added \\`useState\\` import and \\`copied\\` state for feedback\n- Added \\`handleCopy()\\` function: calls \\`navigator.clipboard.writeText(node.description)\\`, sets \\`copied=true\\` for 1.5s\n- Added copy button in the modal header (between title and close X button):\n - Default: clipboard SVG icon (Heroicons clipboard-document, zinc-400)\n - After click: emerald-500 checkmark icon for 1.5 seconds\n - Wrapped both buttons in a flex container with gap-1\n\n### components/NodeDetail.tsx\n- Added \\`descCopied\\` state for feedback\n- Added copy button in the description section header (between \"Description\" label and \"View in window\" link):\n - Same clipboard → checkmark icon pattern as the modal\n - Slightly smaller (w-3.5 h-3.5) to match the sidebar's compact design\n - Wrapped \"View in window\" and copy button in a flex container with gap-2\n\n## UX\n- Copies raw markdown (not rendered HTML) — useful for pasting into editors, chat, or other tools\n- Brief visual feedback (checkmark) confirms the copy succeeded\n- Non-intrusive: small icon that doesn't compete with other controls","status":"closed","priority":2,"issueType":"feature","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T14:22:43.908091+13:00","updatedAt":"2026-02-12T14:22:50.515043+13:00","closedAt":"2026-02-12T14:22:50.515043+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":0,"blockerIds":[],"dependentIds":[]},{"id":"beads-map-iyn","title":"Add spawn/exit/change animations to paintNode","description":"Modify: components/BeadsGraph.tsx — paintNode() callback\n\nPURPOSE: Animate nodes based on the _spawnTime, _removeTime, and _changedAt timestamps set by the merge logic (task .5). New nodes pop in with a bouncy scale-up, removed nodes shrink out, and status-changed nodes flash a ripple effect.\n\nCHANGES TO paintNode (currently at line ~435, inside the useCallback):\n\n1. ADD EASING FUNCTIONS (above the component, near the helper functions around line 50):\n\n```typescript\n// Animation duration constants\nconst SPAWN_DURATION = 500; // ms for pop-in animation\nconst REMOVE_DURATION = 400; // ms for shrink-out animation\nconst CHANGE_DURATION = 800; // ms for status change ripple\n\n/**\n * easeOutBack: overshoots slightly then settles — gives \"pop\" feel.\n * t is 0..1, returns 0..~1.05 (overshoots before settling at 1)\n */\nfunction easeOutBack(t: number): number {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n}\n\n/**\n * easeOutQuad: smooth deceleration\n */\nfunction easeOutQuad(t: number): number {\n return 1 - (1 - t) * (1 - t);\n}\n```\n\n2. MODIFY paintNode() to add animation effects:\n\nAt the BEGINNING of paintNode, before any drawing, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (pop-in) ---\n let spawnScale = 1;\n const spawnTime = (graphNode as any)._spawnTime as number | undefined;\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n spawnScale = easeOutBack(elapsed / SPAWN_DURATION);\n }\n // After animation completes, _spawnTime is ignored (scale stays 1)\n }\n\n // --- Remove animation (shrink-out) ---\n let removeScale = 1;\n let removeOpacity = 1;\n const removeTime = (graphNode as any)._removeTime as number | undefined;\n if (removeTime) {\n const elapsed = now - removeTime;\n if (elapsed < REMOVE_DURATION) {\n const progress = elapsed / REMOVE_DURATION;\n removeScale = 1 - easeOutQuad(progress);\n removeOpacity = 1 - progress;\n } else {\n removeScale = 0; // fully gone\n removeOpacity = 0;\n }\n }\n\n const animScale = spawnScale * removeScale;\n if (animScale <= 0.01) return; // skip drawing invisible nodes\n\n const animatedSize = size * animScale;\n```\n\nREPLACE all references to `size` in the drawing code with `animatedSize`:\n- ctx.arc(node.x, node.y, size + 2, ...) → ctx.arc(node.x, node.y, animatedSize + 2, ...)\n- ctx.arc(node.x, node.y, size, ...) → ctx.arc(node.x, node.y, animatedSize, ...)\n- node.y + size + 3 → node.y + animatedSize + 3\n- node.y - size - 2 → node.y - animatedSize - 2\n\nAlso multiply the base opacity by removeOpacity:\n- ctx.globalAlpha = opacity → ctx.globalAlpha = opacity * removeOpacity\n\n3. ADD STATUS CHANGE RIPPLE after drawing the node body but before the label:\n\n```typescript\n // --- Status change ripple animation ---\n const changedAt = (graphNode as any)._changedAt as number | undefined;\n if (changedAt) {\n const elapsed = now - changedAt;\n if (elapsed < CHANGE_DURATION) {\n const progress = elapsed / CHANGE_DURATION;\n const rippleRadius = animatedSize + 4 + progress * 20;\n const rippleOpacity = (1 - progress) * 0.6;\n const newStatusColor = STATUS_COLORS[graphNode.status] || \"#a1a1aa\";\n\n ctx.beginPath();\n ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);\n ctx.strokeStyle = newStatusColor;\n ctx.lineWidth = 2 * (1 - progress);\n ctx.globalAlpha = rippleOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n4. ADD SPAWN GLOW: during the spawn animation, add a brief emerald glow ring:\n\n```typescript\n // --- Spawn glow ---\n if (spawnTime) {\n const elapsed = now - spawnTime;\n if (elapsed < SPAWN_DURATION) {\n const glowProgress = elapsed / SPAWN_DURATION;\n const glowOpacity = (1 - glowProgress) * 0.4;\n const glowRadius = animatedSize + 6 + glowProgress * 8;\n ctx.beginPath();\n ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);\n ctx.strokeStyle = \"#10b981\";\n ctx.lineWidth = 3 * (1 - glowProgress);\n ctx.globalAlpha = glowOpacity;\n ctx.stroke();\n ctx.globalAlpha = opacity * removeOpacity; // reset\n }\n }\n```\n\n5. ADD CONTINUOUS REDRAW during active animations.\n\nThe canvas only redraws when the force simulation is active or when React state changes. During animations, we need continuous redraws. Add a useEffect that requests animation frames while animations are active:\n\n```typescript\n // Drive continuous canvas redraws during active animations\n useEffect(() => {\n let rafId: number;\n let active = true;\n\n function tick() {\n if (!active) return;\n const now = Date.now();\n const hasActiveAnimations = viewNodes.some((n: any) => {\n if (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) return true;\n if (n._removeTime && now - n._removeTime < REMOVE_DURATION) return true;\n if (n._changedAt && now - n._changedAt < CHANGE_DURATION) return true;\n return false;\n }) || viewLinks.some((l: any) => {\n if (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) return true;\n if (l._removeTime && now - l._removeTime < REMOVE_DURATION) return true;\n return false;\n });\n\n if (hasActiveAnimations) {\n refreshGraph(graphRef);\n }\n rafId = requestAnimationFrame(tick);\n }\n\n tick();\n return () => { active = false; cancelAnimationFrame(rafId); };\n }, [viewNodes, viewLinks]);\n```\n\nIMPORTANT: refreshGraph() already exists at line ~105 — it does an imperceptible zoom jitter to force canvas redraw. This is the exact right mechanism for animation frames.\n\nIMPORTANT: The paintNode callback has [] (empty) dependency array. This is correct and must NOT change — it reads from refs, not props. The animation timestamps are on the node objects themselves (passed as the first argument to paintNode by react-force-graph), so they're always current.\n\nDEPENDS ON: task .5 (page.tsx must stamp _spawnTime/_removeTime/_changedAt on nodes)\n\nACCEPTANCE CRITERIA:\n- New nodes pop in with easeOutBack scale animation (500ms)\n- New nodes show brief emerald glow ring during spawn\n- Removed nodes shrink to zero with fade-out (400ms)\n- Status-changed nodes show expanding ripple ring in new status color (800ms)\n- Animations are smooth (requestAnimationFrame drives redraws)\n- No visual glitches when multiple animations overlap\n- Non-animated nodes render identically to before (no regression)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:17:37.790522+13:00","updatedAt":"2026-02-10T23:39:22.776735+13:00","closedAt":"2026-02-10T23:39:22.776735+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-mq9"],"dependentIds":["beads-map-3jy","beads-map-ecl"]},{"id":"beads-map-m1o","title":"Create lib/watch-beads.ts — file watcher with debounce","description":"Create a new file: lib/watch-beads.ts\n\nPURPOSE: Watch all issues.jsonl files (primary + additional repos from config.yaml) for changes using Node.js fs.watch(). When any file changes, fire a debounced callback. This is the server-side foundation for the SSE endpoint (task .3).\n\nINTERFACE:\n```typescript\n/**\n * Watch all issues.jsonl files for a beads project.\n * Discovers files from the primary .beads dir and config.yaml repos.additional.\n * Debounces rapid changes (bd often writes multiple times per command).\n *\n * @param beadsDir - Absolute path to the primary .beads/ directory\n * @param onChange - Callback fired when any watched file changes (after debounce)\n * @param debounceMs - Debounce interval in milliseconds (default: 300)\n * @returns Cleanup function that closes all watchers\n */\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs?: number\n): () => void;\n\n/**\n * Get all issues.jsonl file paths that should be watched.\n * Returns the primary path plus any additional repo paths from config.yaml.\n */\nexport function getWatchPaths(beadsDir: string): string[];\n```\n\nIMPLEMENTATION:\n\n```typescript\nimport { watch, existsSync } from \"fs\";\nimport { join } from \"path\";\nimport { getAdditionalRepoPaths } from \"./parse-beads\";\n\nexport function getWatchPaths(beadsDir: string): string[] {\n const paths: string[] = [];\n\n // Primary JSONL\n const primary = join(beadsDir, \"issues.jsonl\");\n if (existsSync(primary)) paths.push(primary);\n\n // Additional repo JSONLs\n const additionalRepos = getAdditionalRepoPaths(beadsDir);\n for (const repoPath of additionalRepos) {\n const jsonlPath = join(repoPath, \".beads\", \"issues.jsonl\");\n if (existsSync(jsonlPath)) paths.push(jsonlPath);\n }\n\n return paths;\n}\n\nexport function watchBeadsFiles(\n beadsDir: string,\n onChange: () => void,\n debounceMs = 300\n): () => void {\n const paths = getWatchPaths(beadsDir);\n let timer: ReturnType<typeof setTimeout> | null = null;\n const watchers: ReturnType<typeof watch>[] = [];\n\n const debouncedOnChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(onChange, debounceMs);\n };\n\n for (const filePath of paths) {\n try {\n const watcher = watch(filePath, { persistent: false }, (eventType) => {\n if (eventType === \"change\") {\n debouncedOnChange();\n }\n });\n watchers.push(watcher);\n } catch (err) {\n console.warn(`Failed to watch ${filePath}:`, err);\n }\n }\n\n if (paths.length === 0) {\n console.warn(\"No issues.jsonl files found to watch\");\n }\n\n // Return cleanup function\n return () => {\n if (timer) clearTimeout(timer);\n for (const w of watchers) {\n w.close();\n }\n };\n}\n```\n\nKEY DESIGN DECISIONS:\n- persistent: false — so the watcher doesn't prevent Node.js from exiting\n- Only watches for \"change\" events (not \"rename\") since bd writes in-place\n- 300ms debounce: bd typically does flush→sync→write in rapid succession\n- If a watched file disappears (repo deleted), the watcher silently dies — acceptable\n\nEDGE CASES:\n- No additional repos: only watches primary issues.jsonl\n- Empty project (no issues.jsonl yet): returns empty paths array, logs warning\n- File deleted while watching: fs.watch fires an event, but next re-parse returns empty — handled gracefully by parse-beads.ts\n\nDEPENDS ON: task .1 (getAdditionalRepoPaths must be exported from parse-beads.ts)\n\nACCEPTANCE CRITERIA:\n- lib/watch-beads.ts exports watchBeadsFiles and getWatchPaths\n- Debounces rapid changes correctly (only one onChange call per burst)\n- Watches all JSONL files (primary + additional repos)\n- Cleanup function closes all watchers\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:15:32.448347+13:00","updatedAt":"2026-02-10T23:25:49.410672+13:00","closedAt":"2026-02-10T23:25:49.410672+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-7j2"],"dependentIds":["beads-map-3jy","beads-map-gjo"]},{"id":"beads-map-mfw","title":"Epic: Search comments by commenter username","description":"Allow searching for nodes by commenter username. Typing a Bluesky handle (e.g. 'daviddao') in the search bar should also surface nodes where that person has left comments. This extends the existing node-field search to include comment author handles.","status":"closed","priority":2,"issueType":"epic","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:37:59.835891+13:00","updatedAt":"2026-02-12T10:39:16.820832+13:00","closedAt":"2026-02-12T10:39:16.820832+13:00","closeReason":"Completed: e2a49e1 — all tasks done","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-mfw.1"],"dependentIds":["beads-map-8np","beads-map-vdg"]},{"id":"beads-map-mfw.1","title":"Include comment author handles in search matching","description":"In app/page.tsx: (1) Add a useMemo that builds a Map<string, string> from allComments — maps each nodeId to a space-joined string of unique commenter handles for that node. (2) In the searchResults useMemo, append the commenter handles string to the existing searchable string. This way typing 'daviddao.bsky.social' or just 'daviddao' surfaces nodes where that user commented. The searchResults useMemo needs allComments (or the derived map) in its dependency array.","status":"closed","priority":2,"issueType":"task","owner":"david@gainforest.net","assignee":"daviddao","createdBy":"daviddao","createdAt":"2026-02-12T10:38:08.454443+13:00","updatedAt":"2026-02-12T10:39:16.735264+13:00","closedAt":"2026-02-12T10:39:16.735264+13:00","closeReason":"Completed: e2a49e1","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-mfw"]},{"id":"beads-map-mq9","title":"Add spawn/exit animations to paintLink","description":"Modify: components/BeadsGraph.tsx — paintLink() callback\n\nPURPOSE: Animate links based on _spawnTime and _removeTime timestamps. New links fade in smoothly, removed links fade out. This complements the node animations (task .6).\n\nCHANGES TO paintLink (currently at line ~544, inside the useCallback):\n\n1. At the BEGINNING of paintLink, compute animation state:\n\n```typescript\n const now = Date.now();\n\n // --- Spawn animation (fade-in + thickness) ---\n let linkSpawnAlpha = 1;\n let linkSpawnWidth = 1;\n const linkSpawnTime = (link as any)._spawnTime as number | undefined;\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < SPAWN_DURATION) {\n const progress = elapsed / SPAWN_DURATION;\n linkSpawnAlpha = easeOutQuad(progress);\n linkSpawnWidth = 1 + (1 - progress) * 1.5; // starts 2.5x thick, settles to 1x\n }\n }\n\n // --- Remove animation (fade-out) ---\n let linkRemoveAlpha = 1;\n const linkRemoveTime = (link as any)._removeTime as number | undefined;\n if (linkRemoveTime) {\n const elapsed = now - linkRemoveTime;\n if (elapsed < REMOVE_DURATION) {\n linkRemoveAlpha = 1 - easeOutQuad(elapsed / REMOVE_DURATION);\n } else {\n return; // fully gone, skip drawing\n }\n }\n\n const linkAnimAlpha = linkSpawnAlpha * linkRemoveAlpha;\n if (linkAnimAlpha <= 0.01) return; // skip invisible links\n```\n\n2. MULTIPLY the existing opacity by linkAnimAlpha:\n\nCurrently (line ~574-580), the opacity is computed as:\n```typescript\n const opacity = isParentChild\n ? hasHighlight\n ? isConnectedLink ? 0.5 : 0.05\n : 0.2\n : hasHighlight\n ? isConnectedLink ? 0.8 : 0.08\n : 0.35;\n```\n\nAfter this, multiply:\n```typescript\n ctx.globalAlpha = opacity * linkAnimAlpha;\n```\n\n3. MULTIPLY the line width by linkSpawnWidth:\n\nCurrently the line width is set separately for parent-child and blocks links. Multiply each by linkSpawnWidth:\n```typescript\n // For parent-child:\n ctx.lineWidth = Math.max(0.6, 1.5 / globalScale) * linkSpawnWidth;\n // For blocks:\n ctx.lineWidth = (isConnectedLink\n ? Math.max(2, 2.5 / globalScale)\n : Math.max(0.8, 1.2 / globalScale)) * linkSpawnWidth;\n```\n\n4. ADD SPAWN FLASH for new links (optional but nice):\n\nAfter drawing the link curve, if it's spawning, draw a brief bright flash along the path:\n\n```typescript\n // Brief bright flash for new links\n if (linkSpawnTime) {\n const elapsed = now - linkSpawnTime;\n if (elapsed < 300) {\n const flashProgress = elapsed / 300;\n const flashAlpha = (1 - flashProgress) * 0.5;\n ctx.save();\n ctx.globalAlpha = flashAlpha;\n ctx.strokeStyle = \"#10b981\"; // emerald\n ctx.lineWidth = (isParentChild ? 3 : 4) / globalScale;\n ctx.beginPath();\n ctx.moveTo(start.x, start.y);\n ctx.quadraticCurveTo(cx, cy, end.x, end.y);\n ctx.stroke();\n ctx.restore();\n }\n }\n```\n\nThis creates a bright emerald line that fades out over 300ms, overlaid on the normal link.\n\nIMPORTANT: The paintLink callback has [] (empty) dependency array. Keep it that way. Animation timestamps are on the link objects themselves.\n\nIMPORTANT: The SPAWN_DURATION, REMOVE_DURATION, easeOutQuad constants are shared with paintNode (task .6). They should be declared at module level (above the component), not inside the callbacks. If task .6 is implemented first, they'll already exist.\n\nDEPENDS ON: task .5 (links must have _spawnTime/_removeTime), task .6 (shared animation constants + easing functions)\n\nACCEPTANCE CRITERIA:\n- New links fade in over 500ms with initial thickness burst\n- New links show brief emerald flash (300ms)\n- Removed links fade out over 400ms\n- Flow particles on new links are also affected by spawn alpha (not critical, nice-to-have)\n- No visual regression for non-animated links\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-10T23:18:20.715649+13:00","updatedAt":"2026-02-10T23:39:22.858151+13:00","closedAt":"2026-02-10T23:39:22.858151+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-2qg"],"dependentIds":["beads-map-3jy","beads-map-iyn"]},{"id":"beads-map-s0c","title":"v0.3.2: Fix help descriptions and add interactive tutorial","description":"## Overview\n\nThis epic covers two features:\n\n1. **Fix help page description** — Change \"arrows\" to \"flowing particles\" in the help text to accurately describe the link animation (particles flow along dependency lines, not static arrows).\n2. **Interactive tutorial** — A \"Start Tutorial\" button in the HelpPanel that launches a multi-step guided tour. Each step highlights a UI element with a spotlight cutout overlay (dark semi-transparent backdrop with a rounded hole) and shows a description in the HelpPanel sidebar with Next/Back/step-indicator navigation.\n\n## Tutorial Steps (7 total)\n\n| Step | Target `data-tutorial` | Highlight element | Description |\n|------|----------------------|-------------------|-------------|\n| 0 | `graph` | Graph canvas container | Welcome — circles are tasks, flowing particles show dependency direction |\n| 1 | `layouts` | Layout button group (top-left) | Switch between Force, DAG, Radial, Cluster, Spread |\n| 2 | `legend` | Legend panel (bottom-right) | Color nodes by Status, Priority, Owner, Assignee, or Prefix |\n| 3 | `minimap` | Minimap (bottom-left) | Click to navigate, drag edges to resize |\n| 4 | `search` | Search bar (header center) | Cmd/Ctrl+F to search by name, ID, owner, commenter |\n| 5 | `graph` | Graph canvas again | Click for details, hover for summary, right-click for actions |\n| 6 | `nav-pills` | Nav pill buttons (header right) | Replay, Comments, Activity, Help |\n\n## Design decisions\n\n- **Spotlight cutout style**: Semi-transparent dark overlay (`bg-black/50`) with an SVG or CSS clip-path rounded rectangle cutout around the target element. The cutout position is computed via `document.querySelector(\"[data-tutorial=X]\").getBoundingClientRect()`.\n- **Tutorial text in HelpPanel sidebar**: When `tutorialStep !== null`, HelpPanel switches from static help content to a step-by-step view with title, description, step indicator (dots or \"2 of 7\"), and Next/Back buttons.\n- **State ownership**: `page.tsx` owns `tutorialStep: number | null`. `null` = inactive. `0–6` = active step. Passed to HelpPanel and TutorialOverlay as props.\n- **Sidebar auto-open**: Starting the tutorial auto-opens HelpPanel and closes other sidebars. Ending the tutorial keeps HelpPanel open showing normal content.\n- **z-index**: TutorialOverlay uses z-40 (above z-30 sidebars, below z-50 header). The header elements (search, nav pills) need the spotlight to cover them, so the overlay portal may need z-[45] or z-[55] depending on which step.\n\n## Files\n\n### New files\n- `components/TutorialOverlay.tsx` — Spotlight overlay with step config, DOM rect computation, dark backdrop with cutout\n\n### Modified files\n- `components/HelpPanel.tsx` — Fix \"arrows\" text, add \"Start Tutorial\" button, add tutorial step content mode\n- `components/BeadsGraph.tsx` — Add `data-tutorial` attributes to: graph container, layout buttons, legend panel, minimap\n- `app/page.tsx` — Add `tutorialStep` state, add `data-tutorial` attrs to nav-pills and search, wire callbacks, render TutorialOverlay\n\n## Acceptance criteria\n\n- [ ] Help page says \"flowing particles\" not \"arrows\"\n- [ ] \"Start Tutorial\" button visible in HelpPanel\n- [ ] Tutorial walks through all 7 steps with spotlight highlighting\n- [ ] Next/Back navigation works, step indicator shows progress\n- [ ] Ending tutorial returns to normal help content\n- [ ] `pnpm build` passes with zero errors\n- [ ] All 7 highlighted elements are visible and correctly spotlighted","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:24:59.311936+13:00","updatedAt":"2026-02-12T15:51:47.512892+13:00","closedAt":"2026-02-12T15:51:47.512892+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":10,"dependentCount":0,"blockerIds":["beads-map-s0c.1","beads-map-s0c.10","beads-map-s0c.2","beads-map-s0c.3","beads-map-s0c.4","beads-map-s0c.5","beads-map-s0c.6","beads-map-s0c.7","beads-map-s0c.8","beads-map-s0c.9"],"dependentIds":[]},{"id":"beads-map-s0c.1","title":"Fix help page description: arrows → flowing particles","description":"## What\n\nThe current HelpPanel describes dependency links as \"arrows\" — but the actual visual is **flowing particles** (animated dots that move along the link line to show direction). Fix the text to match reality.\n\n## File: `components/HelpPanel.tsx`\n\n### Change 1: Line 90-92\n**Current text:**\n```tsx\nEach <strong>circle</strong> is a task or issue. The <strong>arrows</strong>{\" \"}\nbetween them show dependencies &mdash; what needs to happen before\nsomething else can start.\n```\n\n**Replace with:**\n```tsx\nEach <strong>circle</strong> is a task or issue. The <strong>flowing particles</strong>{\" \"}\nbetween them show dependencies &mdash; they stream in the direction things\nneed to happen.\n```\n\n### Change 2: Line 97-99 (the \"Solid arrows\" bullet)\n**Current text:**\n```tsx\n<span><strong>Solid arrows</strong> = &ldquo;blocks&rdquo; (A must finish before B)</span>\n```\n\n**Replace with:**\n```tsx\n<span><strong>Solid lines with flowing particles</strong> = &ldquo;blocks&rdquo; (A must finish before B)</span>\n```\n\n### No change needed for the \"Dashed lines\" bullet (line 102-104)\nDashed lines (parent-child) do NOT have particles, so the current description is correct.\n\n## Acceptance criteria\n\n- [ ] \"arrows\" no longer appears in HelpPanel text\n- [ ] \"flowing particles\" describes dependency links\n- [ ] \"Solid lines with flowing particles\" describes blocks links\n- [ ] \"Dashed lines\" description unchanged\n- [ ] `pnpm build` passes","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:25:11.056603+13:00","updatedAt":"2026-02-12T15:28:22.694325+13:00","closedAt":"2026-02-12T15:28:22.694325+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-s0c.2"],"dependentIds":["beads-map-s0c"]},{"id":"beads-map-s0c.10","title":"Use emerald accent for Next/Done buttons, unify bullet dot colors per section","description":"## What\n\nFinal polish pass on tutorial and help panel styling. Two changes:\n\n1. **Next/Done buttons** — Changed from Catppuccin Green (`#40a02b` via inline style) to the app's emerald accent (`bg-emerald-500 hover:bg-emerald-600` via Tailwind classes) to match the Start Tutorial and other primary buttons.\n\n2. **Bullet dot colors** — Each section's bullet dots now use one consistent color matching their section title, instead of each bullet having a different Catppuccin color.\n\n## Commit\n- 288ce6c — Use emerald accent for tutorial Next/Done buttons, unify bullet colors per section\n\n## File modified\n\n### `components/HelpPanel.tsx`\n\n**Tutorial nav buttons (lines 179-195):**\n```diff\n- className=\"... text-white rounded-lg transition-colors hover:opacity-90\"\n- style={{ backgroundColor: CAT.green }}\n+ className=\"... text-white rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors\"\n```\nApplied to both the \"Done\" (last step) and \"Next\" (other steps) buttons.\n\n**Bullet colors — before (rainbow per section):**\n```tsx\n// Graph section had: CAT.red, CAT.peach, CAT.teal, CAT.blue\n// Navigation had: CAT.blue, CAT.sapphire, CAT.teal, CAT.green, CAT.mauve\n```\n\n**Bullet colors — after (uniform per section):**\n```tsx\n// Graph section: all CAT.red (matches SectionTitle color={CAT.red})\n// Navigation: all CAT.blue (matches SectionTitle color={CAT.blue})\n// Layouts: all CAT.teal (matches SectionTitle color={CAT.teal})\n// Color modes: all CAT.peach (matches SectionTitle color={CAT.peach})\n// More: all CAT.mauve (matches SectionTitle color={CAT.mauve})\n```\n\nAlso removed inline `style={{ color: CAT.xxx }}` from layout name `<strong>` tags — they no longer need individual colors since the dots handle the visual distinction.\n\n## Design rationale\n- Emerald-500 is the app's primary accent used everywhere (active nav pills, layout mode buttons, color mode selector, auth button highlight). Using it for tutorial nav buttons keeps things consistent.\n- Uniform dot colors per section creates a cleaner visual rhythm. The section title color provides the identity; individual bullet colors were noise.\n\n## Acceptance criteria (all met)\n- [x] Next button is emerald-500\n- [x] Done button is emerald-500\n- [x] All bullets in \"The graph\" section use red dots\n- [x] All bullets in \"Navigation\" section use blue dots\n- [x] All bullets in \"Layouts\" section use teal dots\n- [x] All bullets in \"Color modes\" section use peach dots\n- [x] All bullets in \"More\" section use mauve dots\n- [x] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:51:17.69638+13:00","updatedAt":"2026-02-12T15:51:24.554543+13:00","closedAt":"2026-02-12T15:51:24.554543+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-s0c"]},{"id":"beads-map-s0c.2","title":"Add data-tutorial attributes to existing UI elements for spotlight targeting","description":"## What\n\nAdd `data-tutorial=\"<step-name>\"` attributes to 6 key UI elements so the TutorialOverlay can find them via `document.querySelector(\"[data-tutorial=X]\")` and compute their bounding rect for the spotlight cutout.\n\n## Attributes to add\n\n| Attribute value | Element | File | Approx line |\n|----------------|---------|------|-------------|\n| `graph` | Graph container div | `components/BeadsGraph.tsx:1791` | `<div ref={containerRef} className=\"w-full h-full relative\">` |\n| `layouts` | Layout button group | `components/BeadsGraph.tsx:1795` | `<div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg ...\">` |\n| `legend` | Legend info panel | `components/BeadsGraph.tsx:2013-2014` | `<div className=\"absolute bottom-4 z-10 bg-white/90 ...\">` |\n| `minimap` | Minimap wrapper div | `components/BeadsGraph.tsx:2094` | `<div className=\"hidden sm:block absolute bottom-4 left-4 z-10\" ...>` |\n| `search` | Search bar wrapper | `app/page.tsx:982` | `<div className=\"relative w-full max-w-md\">` |\n| `nav-pills` | Nav pills group div | `app/page.tsx:1137` | `<div className=\"hidden md:flex items-center gap-1 shrink-0\">` |\n\n## Exact changes\n\n### `components/BeadsGraph.tsx`\n\n**Line 1791** — graph container:\n```diff\n- <div ref={containerRef} className=\"w-full h-full relative\">\n+ <div ref={containerRef} className=\"w-full h-full relative\" data-tutorial=\"graph\">\n```\n\n**Line 1795** — layout button group:\n```diff\n- <div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\">\n+ <div className=\"flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden\" data-tutorial=\"layouts\">\n```\n\n**Line 2013-2014** — legend panel:\n```diff\n- <div\n- 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\"\n+ <div\n+ 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\"\n+ data-tutorial=\"legend\"\n```\n\n**Line 2094** — minimap wrapper:\n```diff\n- <div\n- className=\"hidden sm:block absolute bottom-4 left-4 z-10\"\n+ <div\n+ className=\"hidden sm:block absolute bottom-4 left-4 z-10\"\n+ data-tutorial=\"minimap\"\n```\n\n### `app/page.tsx`\n\n**Line 982** — search bar wrapper:\n```diff\n- <div className=\"relative w-full max-w-md\">\n+ <div className=\"relative w-full max-w-md\" data-tutorial=\"search\">\n```\n\n**Line 1137** — nav pills group:\n```diff\n- <div className=\"hidden md:flex items-center gap-1 shrink-0\">\n+ <div className=\"hidden md:flex items-center gap-1 shrink-0\" data-tutorial=\"nav-pills\">\n```\n\n## Key constraint\n\nThese are attribute-only additions — no logic changes. The attributes are inert until TutorialOverlay reads them via `querySelector`.\n\n## Acceptance criteria\n\n- [ ] All 6 `data-tutorial` attributes present in the DOM when the page renders\n- [ ] No visual or behavioral changes to any existing UI\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:25:28.383794+13:00","updatedAt":"2026-02-12T15:28:56.171439+13:00","closedAt":"2026-02-12T15:28:56.171439+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-s0c.3"],"dependentIds":["beads-map-s0c","beads-map-s0c.1"]},{"id":"beads-map-s0c.3","title":"Create TutorialOverlay component: spotlight cutout with dark backdrop","description":"## What\n\nNew component `components/TutorialOverlay.tsx` that renders a semi-transparent dark overlay covering the entire viewport with a rounded rectangular \"cutout\" (transparent hole) around the currently highlighted UI element.\n\n## Props interface\n\n```typescript\ninterface TutorialOverlayProps {\n step: number | null; // null = hidden, 0-6 = active step\n onNext: () => void; // advance to next step\n onPrev: () => void; // go to previous step\n onEnd: () => void; // end tutorial\n}\n```\n\n## Step configuration\n\nDefine a `TUTORIAL_STEPS` array inside the component (or as a shared constant):\n\n```typescript\ninterface TutorialStep {\n target: string; // data-tutorial attribute value\n title: string; // step title for sidebar\n description: string; // step description for sidebar\n padding?: number; // extra px around the cutout (default 8)\n}\n\nconst TUTORIAL_STEPS: TutorialStep[] = [\n {\n target: \"graph\",\n title: \"The Dependency Graph\",\n description: \"Welcome to Heartbeads! Each circle is a task or issue. The flowing particles between them show the direction of dependencies — what needs to happen before something else can start. Bigger circles are more connected and more important.\",\n },\n {\n target: \"layouts\",\n title: \"Layout Modes\",\n description: \"Switch how the graph is arranged. Force is organic and physics-based. DAG gives you a clean top-down tree. Radial spreads nodes in rings. Cluster groups by project. Spread spaces everything out for screenshots.\",\n },\n {\n target: \"legend\",\n title: \"Color Modes & Legend\",\n description: \"Color nodes by different dimensions. Status shows open/in-progress/blocked/closed. Priority goes from P0 (critical) to P4 (backlog). Owner and Assignee color by person. Prefix colors by project. The ring around each node always shows the project.\",\n },\n {\n target: \"minimap\",\n title: \"Minimap\",\n description: \"A bird's-eye view of your entire graph. Click anywhere on it to jump to that area. Drag the edges to resize it. The highlighted rectangle shows what's currently visible on screen.\",\n },\n {\n target: \"search\",\n title: \"Search\",\n description: \"Press Cmd+F (or Ctrl+F on Windows/Linux) to search by issue name, ID, owner, assignee, or even commenter handle. Use arrow keys to navigate results and Enter to focus on an issue.\",\n },\n {\n target: \"graph\",\n title: \"Interacting with Nodes\",\n description: \"Click any node to open its details in the sidebar. Hover for a quick summary tooltip. Right-click for actions: view the full description, add a comment, claim the task as yours, or collapse/expand an epic.\",\n },\n {\n target: \"nav-pills\",\n title: \"Navigation Bar\",\n description: \"Replay lets you step through your project's history like a movie. Comments shows all conversations across issues. Activity shows a real-time feed of what's happening. Help brings you back here anytime.\",\n },\n];\n```\n\n**Export `TUTORIAL_STEPS` so HelpPanel can import it for rendering step content.**\n\n## Rendering approach\n\n1. **When `step === null`**: render nothing (return null).\n2. **When `step` is a number**:\n a. Query `document.querySelector(\\`[data-tutorial=\"${TUTORIAL_STEPS[step].target}\"]\\`)` to find the target element.\n b. Call `getBoundingClientRect()` on it.\n c. Render a full-viewport overlay (`fixed inset-0 z-[45]`) with a dark semi-transparent background.\n d. Use an SVG with a `<rect>` fill covering the whole viewport and a `<rect>` cutout (via `<mask>` or `clipPath`) creating a transparent rounded rectangle at the target element's position + padding.\n e. Add a subtle animated border/glow around the cutout (optional, via a separate absolutely-positioned `<div>` with `ring-2 ring-emerald-400 animate-pulse` or similar).\n\n## SVG mask approach (recommended)\n\n```tsx\n<svg className=\"fixed inset-0 w-full h-full z-[45] pointer-events-none\" style={{ mixBlendMode: \"normal\" }}>\n <defs>\n <mask id=\"tutorial-mask\">\n <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n <rect\n x={rect.left - padding}\n y={rect.top - padding}\n width={rect.width + padding * 2}\n height={rect.height + padding * 2}\n rx={8}\n fill=\"black\"\n />\n </mask>\n </defs>\n <rect\n width=\"100%\"\n height=\"100%\"\n fill=\"rgba(0,0,0,0.5)\"\n mask=\"url(#tutorial-mask)\"\n />\n</svg>\n```\n\nThen separately render a clickable overlay div (`fixed inset-0 z-[44]`) that captures clicks outside the cutout to advance to the next step (or end the tutorial on the last step).\n\n## Resize handling\n\nUse a `useEffect` + `ResizeObserver` or `window.addEventListener(\"resize\")` to recalculate the target rect when the viewport changes. Also recalculate on step change.\n\n## Positioning edge cases\n\n- If `querySelector` returns null (element not rendered, e.g., minimap on mobile), skip the spotlight and just show the overlay without a cutout, or auto-advance to the next step.\n- For step 0 and 5 (both target `graph`), the cutout will be very large (the whole graph canvas). That's fine — it draws attention to the whole graph area.\n\n## Z-index considerations\n\n- The overlay SVG: `z-[45]` — above sidebars (z-30), below header (z-50)\n- For steps 4 (search) and 6 (nav-pills), the targets are IN the z-50 header. Two options:\n a. Use `z-[55]` for the overlay on those steps (above the header)\n b. Or keep z-[45] and accept that the header shows above the overlay (the spotlight still highlights the right area visually)\n **Recommended: Use `z-[55]` for ALL steps** to ensure the spotlight is always above everything. The cutout hole will still allow the underlying element to be visible through it.\n\n## Acceptance criteria\n\n- [ ] `TutorialOverlay` renders nothing when `step === null`\n- [ ] Dark backdrop covers viewport when step is active\n- [ ] Rounded cutout hole appears around the correct target element for each step\n- [ ] Cutout position updates on window resize\n- [ ] `TUTORIAL_STEPS` array exported for use by HelpPanel\n- [ ] Graceful fallback when target element is not found (no crash)\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:26:04.16039+13:00","updatedAt":"2026-02-12T15:29:35.95281+13:00","closedAt":"2026-02-12T15:29:35.95281+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":2,"dependentCount":2,"blockerIds":["beads-map-s0c.4","beads-map-s0c.5"],"dependentIds":["beads-map-s0c","beads-map-s0c.2"]},{"id":"beads-map-s0c.4","title":"Update HelpPanel: Start Tutorial button and step-by-step tutorial content","description":"## What\n\nModify `components/HelpPanel.tsx` to:\n1. Add a \"Start Tutorial\" button at the top of the help content\n2. When a tutorial is active (`tutorialStep !== null`), replace the static help content with a step-by-step tutorial view showing the current step's title, description, step indicator, and Next/Back buttons\n\n## New props\n\n```typescript\ninterface HelpPanelProps {\n isOpen: boolean;\n onClose: () => void;\n // New tutorial props:\n tutorialStep: number | null; // null = normal mode, 0-6 = tutorial mode\n onStartTutorial: () => void; // callback when user clicks \"Start Tutorial\"\n onNextStep: () => void; // advance tutorial\n onPrevStep: () => void; // go back\n onEndTutorial: () => void; // end tutorial (returns to normal help)\n}\n```\n\n## Changes to HelpContent\n\n### Add \"Start Tutorial\" button\n\nAt the top of `HelpContent` (before the first `<SectionTitle>`), add a prominent button:\n\n```tsx\n<button\n onClick={onStartTutorial}\n className=\"w-full flex items-center justify-center gap-2 px-4 py-2.5 mb-4 bg-emerald-500 text-white text-sm font-medium rounded-lg hover:bg-emerald-600 transition-colors\"\n>\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5\" />\n </svg>\n Start Tutorial\n</button>\n```\n\n### Tutorial mode content\n\nWhen `tutorialStep !== null`, instead of rendering `<HelpContent>`, render a `<TutorialContent>` component:\n\n```tsx\nfunction TutorialContent({\n step,\n onNext,\n onPrev,\n onEnd,\n}: {\n step: number;\n onNext: () => void;\n onPrev: () => void;\n onEnd: () => void;\n}) {\n const currentStep = TUTORIAL_STEPS[step]; // imported from TutorialOverlay\n const totalSteps = TUTORIAL_STEPS.length;\n const isFirst = step === 0;\n const isLast = step === totalSteps - 1;\n\n return (\n <div className=\"px-5 py-4 flex flex-col h-full\">\n {/* Step indicator: \"Step 1 of 7\" + dots */}\n <div className=\"flex items-center justify-between mb-4\">\n <span className=\"text-xs text-zinc-400 font-medium\">\n Step {step + 1} of {totalSteps}\n </span>\n <div className=\"flex gap-1\">\n {Array.from({ length: totalSteps }).map((_, i) => (\n <div\n key={i}\n className={`w-1.5 h-1.5 rounded-full transition-colors ${\n i === step ? \"bg-emerald-500\" : i < step ? \"bg-emerald-300\" : \"bg-zinc-200\"\n }`}\n />\n ))}\n </div>\n </div>\n\n {/* Step title */}\n <h3 className=\"text-base font-semibold text-zinc-900 mb-2\">\n {currentStep.title}\n </h3>\n\n {/* Step description */}\n <p className=\"text-[13px] text-zinc-600 leading-relaxed mb-6\">\n {currentStep.description}\n </p>\n\n {/* Spacer */}\n <div className=\"flex-1\" />\n\n {/* Navigation buttons */}\n <div className=\"flex items-center gap-2 pt-4 border-t border-zinc-100\">\n {!isFirst && (\n <button\n onClick={onPrev}\n className=\"px-4 py-2 text-sm font-medium text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-50 transition-colors\"\n >\n Back\n </button>\n )}\n <div className=\"flex-1\" />\n {isLast ? (\n <button\n onClick={onEnd}\n className=\"px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors\"\n >\n Done\n </button>\n ) : (\n <button\n onClick={onNext}\n className=\"px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors\"\n >\n Next\n </button>\n )}\n </div>\n </div>\n );\n}\n```\n\n### Header changes\n\nWhen tutorial is active, change the header text from \"Welcome to Heartbeads\" to \"Tutorial\" and add an X button that calls `onEndTutorial` (the existing close button can double as this).\n\n### Conditional rendering\n\nIn the main `HelpPanel` component, replace:\n```tsx\n<div className=\"flex-1 overflow-y-auto custom-scrollbar\">\n <HelpContent />\n</div>\n```\n\nWith:\n```tsx\n<div className=\"flex-1 overflow-y-auto custom-scrollbar\">\n {tutorialStep !== null ? (\n <TutorialContent\n step={tutorialStep}\n onNext={onNextStep}\n onPrev={onPrevStep}\n onEnd={onEndTutorial}\n />\n ) : (\n <HelpContent onStartTutorial={onStartTutorial} />\n )}\n</div>\n```\n\nNote: `HelpContent` now receives `onStartTutorial` as a prop to render the button.\n\n### Both desktop and mobile\n\nApply the same conditional rendering to both the desktop sidebar `<aside>` and the mobile bottom drawer `<div>`. Both currently render `<HelpContent />` — both need the conditional.\n\n### Also fix the typo on line 48\n\nThe mobile drawer header says \"Welcome to Heartbeats\" (missing the \"d\") — should be \"Welcome to Heartbeads\" to match the desktop header on line 20.\n\n## Acceptance criteria\n\n- [ ] \"Start Tutorial\" button renders at top of help content in both desktop and mobile\n- [ ] Clicking \"Start Tutorial\" calls `onStartTutorial`\n- [ ] When `tutorialStep !== null`, tutorial content replaces normal help content\n- [ ] Step indicator shows current step and total with dots\n- [ ] Title and description match the current TUTORIAL_STEPS entry\n- [ ] Back button hidden on first step, shows on steps 1-6\n- [ ] Last step shows \"Done\" instead of \"Next\"\n- [ ] Mobile drawer typo \"Heartbeats\" → \"Heartbeads\" fixed\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:26:35.75358+13:00","updatedAt":"2026-02-12T15:30:37.487173+13:00","closedAt":"2026-02-12T15:30:37.487173+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-s0c.5"],"dependentIds":["beads-map-s0c","beads-map-s0c.3"]},{"id":"beads-map-s0c.5","title":"Wire tutorial state in page.tsx: state, callbacks, TutorialOverlay, HelpPanel props","description":"## What\n\nAdd tutorial state management in `app/page.tsx` and wire TutorialOverlay + HelpPanel together.\n\n## State\n\nAdd near line 251 (after `helpPanelOpen` state):\n\n```typescript\nconst [tutorialStep, setTutorialStep] = useState<number | null>(null);\n```\n\n## Callbacks\n\nAdd after the `helpPanelOpen` state:\n\n```typescript\nconst handleStartTutorial = useCallback(() => {\n // Open help panel and close other sidebars\n setHelpPanelOpen(true);\n setSelectedNode(null);\n setAllCommentsPanelOpen(false);\n setActivityPanelOpen(false);\n // Start at step 0\n setTutorialStep(0);\n}, []);\n\nconst handleNextTutorialStep = useCallback(() => {\n setTutorialStep((prev) => {\n if (prev === null) return null;\n // Import TUTORIAL_STEPS.length or use 7 directly\n if (prev >= 6) return prev; // already at last step\n return prev + 1;\n });\n}, []);\n\nconst handlePrevTutorialStep = useCallback(() => {\n setTutorialStep((prev) => {\n if (prev === null || prev <= 0) return prev;\n return prev - 1;\n });\n}, []);\n\nconst handleEndTutorial = useCallback(() => {\n setTutorialStep(null);\n // Keep help panel open showing normal content\n}, []);\n```\n\n## Import TutorialOverlay\n\n```typescript\nimport { TutorialOverlay } from \"@/components/TutorialOverlay\";\n```\n\n## Render TutorialOverlay\n\nAdd the TutorialOverlay right before the closing `</div>` of the root container (around line 1637), or inside the main content area. It should be a sibling of the main content, not nested inside the graph area:\n\n```tsx\n{/* Tutorial spotlight overlay */}\n<TutorialOverlay\n step={tutorialStep}\n onNext={handleNextTutorialStep}\n onPrev={handlePrevTutorialStep}\n onEnd={handleEndTutorial}\n/>\n```\n\nPlace this between the `</div>` that closes the `flex-1 flex overflow-hidden relative` (line 1636) and the final `</div>` that closes the root `h-screen` (line 1637). This ensures it is portaled above the main content.\n\n## Update HelpPanel props\n\nChange the `<HelpPanel>` JSX (around line 1632-1635) from:\n\n```tsx\n<HelpPanel\n isOpen={helpPanelOpen}\n onClose={() => setHelpPanelOpen(false)}\n/>\n```\n\nTo:\n\n```tsx\n<HelpPanel\n isOpen={helpPanelOpen}\n onClose={() => {\n setHelpPanelOpen(false);\n setTutorialStep(null); // end tutorial when closing help panel\n }}\n tutorialStep={tutorialStep}\n onStartTutorial={handleStartTutorial}\n onNextStep={handleNextTutorialStep}\n onPrevStep={handlePrevTutorialStep}\n onEndTutorial={handleEndTutorial}\n/>\n```\n\n## Sidebar mutual exclusivity update\n\nWhen the tutorial is active and user clicks a different sidebar button (Comments, Activity, Node), the tutorial should end:\n\n- Update `handleNodeClick` (line 526-531): add `setTutorialStep(null)` alongside the existing `setHelpPanelOpen(false)`\n- Update the Comments pill `onClick` (line 1161-1167): add `setTutorialStep(null)` if opening\n- Update the Activity pill `onClick` (line 1192-1198): add `setTutorialStep(null)` if opening\n\n## sidebarOpen prop update\n\nThe `sidebarOpen` prop passed to `BeadsGraph` (line 1285) already includes `helpPanelOpen`, so no change needed there. The legend panel will correctly slide inward when the help panel is open during the tutorial.\n\n## Acceptance criteria\n\n- [ ] `tutorialStep` state managed in page.tsx\n- [ ] \"Start Tutorial\" button in HelpPanel triggers step 0 + opens help panel + closes other sidebars\n- [ ] Next/Back/Done navigation works through all 7 steps\n- [ ] Ending tutorial (Done or close) resets to null and shows normal help content\n- [ ] Opening another sidebar during tutorial ends the tutorial\n- [ ] TutorialOverlay rendered with correct props\n- [ ] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:26:57.639244+13:00","updatedAt":"2026-02-12T15:31:52.386289+13:00","closedAt":"2026-02-12T15:31:52.386289+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-s0c.6"],"dependentIds":["beads-map-s0c","beads-map-s0c.3","beads-map-s0c.4"]},{"id":"beads-map-s0c.6","title":"Build verify, bd sync, and push interactive tutorial feature","description":"## What\n\nFinal quality gate for the interactive tutorial feature. Run the build, fix any errors, sync beads, commit, and push.\n\n## Steps\n\n1. **Run `pnpm build`** — must pass with zero errors\n2. **If `PageNotFoundError` or `Cannot find module`**: Run `rm -rf .next node_modules/.cache && sleep 1 && pnpm build`\n3. **If type errors**: Fix them in the relevant files and rebuild\n4. **Close all child tasks**: `bd close beads-map-s0c.1` through `bd close beads-map-s0c.5`\n5. **Close the epic**: `bd close beads-map-s0c`\n6. **Sync and push**:\n ```bash\n git add -A\n git commit -m \"Add interactive tutorial with spotlight overlay and fix help page descriptions\"\n bd sync\n git push\n git status # MUST show \"up to date with origin\"\n ```\n\n## Common build issues to watch for\n\n- **Missing imports**: `TUTORIAL_STEPS` exported from `TutorialOverlay.tsx` and imported in `HelpPanel.tsx`\n- **Unused imports**: If any old imports are no longer needed after refactoring HelpContent\n- **Type mismatches**: Ensure `HelpPanelProps` interface updated with all new optional props\n- **ESLint exhaustive-deps**: The new useCallbacks in page.tsx should have correct deps arrays\n\n## Acceptance criteria\n\n- [ ] `pnpm build` exits with code 0\n- [ ] All child beads closed\n- [ ] Epic beads closed\n- [ ] `bd sync` succeeds\n- [ ] `git push` succeeds\n- [ ] `git status` shows clean working tree, up to date with origin","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:27:10.154802+13:00","updatedAt":"2026-02-12T15:32:22.229746+13:00","closedAt":"2026-02-12T15:32:22.229746+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-s0c","beads-map-s0c.5"]},{"id":"beads-map-s0c.7","title":"Rename Help pill to Learn with lightbulb icon, first pass at emoji restyle and overlay fix","description":"## What\n\nFirst iteration of polish after the initial tutorial build. Three changes in one commit:\n\n1. **Rename Help -> Learn** in the navbar pill button\n2. **Restyle HelpPanel** with emojis and Catppuccin colors (later reverted to dots)\n3. **First attempt at overlay click fix** using sidebar rect cutout + click delegation (later replaced)\n\n## Commit\n- 68dffea — Fix tutorial Back button, restyle help with emojis + Catppuccin colors, rename Help to Learn\n\n## Files modified\n\n### `app/page.tsx`\n- Changed comment `{/* Help pill */}` -> `{/* Learn pill */}`\n- Replaced question-mark-circle SVG with Heroicons lightbulb outline SVG:\n ```\n d=\"M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18\"\n ```\n- Changed label text from \"Help\" to \"Learn\"\n\n### `components/HelpPanel.tsx`\n- Added Catppuccin Latte color constants (`CAT_RED`, `CAT_TEAL`, `CAT_PEACH`, `CAT_BLUE`, `CAT_GREEN`, `CAT_MAUVE`)\n- Header text uses emoji: `\"\\ud83d\\udc9a Welcome to Heartbeads\"` / `\"\\u2728 Tutorial\"`\n- Replaced all `--` bullet prefixes with emoji characters (later reverted)\n- `SectionTitle` now accepts `color` prop, renders with `style={{ color }}`\n- Start Tutorial button changed to Catppuccin Mauve background with lightbulb SVG\n- Tutorial nav: Back button uses `CAT_BLUE`, Next/Done uses `CAT_GREEN`\n- First step shows \"Skip\" button instead of hiding Back entirely\n- Step indicator dots use `CAT_GREEN`/`CAT_TEAL`/`CAT.surface` palette\n\n### `components/TutorialOverlay.tsx`\n- Added `sidebarRect` state and sidebar detection via `document.querySelector(\"aside.translate-x-0\")`\n- SVG mask now has two cutout rects: target element + sidebar\n- `handleOverlayClick` checks if click lands in sidebar rect and returns early\n- Tutorial step titles prefixed with emojis (later removed)\n- Last step description changed \"Help\" to \"Learn\"\n\n## Key discovery\nThe sidebar rect cutout approach did NOT work — SVG mask cutouts are visual only, they do not affect pointer-event hit testing. The overlay div still captured clicks over the sidebar area. This was fixed in commit c47a631.\n\n## Acceptance criteria (all met at time of commit)\n- [x] Navbar shows \"Learn\" with lightbulb icon\n- [x] Help content uses Catppuccin colors for section titles\n- [x] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:50:23.532045+13:00","updatedAt":"2026-02-12T15:51:24.462279+13:00","closedAt":"2026-02-12T15:51:24.462279+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-s0c"]},{"id":"beads-map-s0c.8","title":"Redesign help content: Catppuccin colored dots, strip emojis, fix unicode escapes","description":"## What\n\nMajor redesign of the help panel content after emoji overload feedback. Replaced all emoji bullets with small Catppuccin-colored dot indicators, removed broken unicode escape sequences, and tightened copy.\n\n## Commit\n- fc08d3f — Fix tutorial overlay click-through, redesign help with Catppuccin colored dots\n\n## Files modified\n\n### `components/HelpPanel.tsx`\n- **New `Bullet` component:** Small 6px (`w-1.5 h-1.5`) rounded Catppuccin-colored dot with `mt-[6px]` vertical alignment\n ```tsx\n function Bullet({ color, children }: { color: string; children: React.ReactNode }) {\n return (\n <li className=\"flex gap-2.5 items-start\">\n <span className=\"w-1.5 h-1.5 rounded-full mt-[6px] shrink-0\" style={{ backgroundColor: color }} />\n <span>{children}</span>\n </li>\n );\n }\n ```\n- Replaced `CAT_RED` etc. individual constants with single `CAT` object:\n ```tsx\n const CAT = { red: \"#d20f39\", teal: \"#179299\", peach: \"#fe640b\", blue: \"#1e66f5\", green: \"#40a02b\", mauve: \"#8839ef\", sapphire: \"#209fb5\", pink: \"#ea76cb\", surface: \"#dce0e8\" };\n ```\n- `SectionTitle` tightened: `text-[11px] font-bold uppercase tracking-widest`\n- Removed all emoji from bullet items — each section uses different Catppuccin color dots\n- Removed emojis from header text: back to plain \"Tutorial\" / \"Welcome to Heartbeads\"\n- Start Tutorial button changed to Catppuccin Mauve with text \"Take the guided tour\"\n- **Fixed unicode escape bug:** `\\u2190` and `\\u2192` in JSX text nodes render as literal backslash sequences, not arrows. Changed to plain \"Back\" / \"Next\" / \"Done\" / \"Skip\"\n- Tutorial step titles: removed emoji prefixes, clean text only\n- Layout names in Layouts section: each colored with matching Catppuccin accent (Force=teal, DAG=green, Radial=peach, Cluster=blue, Spread=mauve)\n- Color mode names in Color modes section: each colored (Status=green, Priority=red, Owner=blue, Assignee=teal, Prefix=mauve)\n\n### `components/TutorialOverlay.tsx`\n- Changed overlay approach: SVG is `pointer-events: none`, only the dark-fill `<rect>` has `pointer-events: auto`\n- Removed sidebar rect tracking (sidebarRect state, querySelector for aside.translate-x-0)\n- Simplified mask: only one cutout rect (target element), no sidebar cutout\n- Pulsing ring moved from `absolute` to `fixed` positioning with `z-[56]`\n- Tutorial step descriptions tightened for conciseness\n\n## Key discovery\nSetting `pointer-events: auto` on an SVG `<rect>` inside a masked SVG still captures clicks on the full bounding box of the rect element — the mask only affects visual rendering. This approach still did not fix the sidebar click problem (fixed in next commit c47a631).\n\n## Acceptance criteria (all met)\n- [x] No emoji bullets in help content\n- [x] Small colored dots per section\n- [x] No broken unicode escape text\n- [x] Clean tutorial step titles\n- [x] `pnpm build` passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:50:44.278328+13:00","updatedAt":"2026-02-12T15:51:24.492945+13:00","closedAt":"2026-02-12T15:51:24.492945+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-s0c"]},{"id":"beads-map-s0c.9","title":"Fix tutorial sidebar click-through: raise HelpPanel z-index above overlay during tutorial","description":"## What\n\nThe definitive fix for the tutorial sidebar click-through bug. Previous attempts (sidebar rect cutout, SVG pointer-events delegation) all failed because SVG masks are visual-only — they do NOT affect hit testing. The solution is simple: raise the sidebar z-index above the overlay.\n\n## Root cause\n\n- `TutorialOverlay` renders at `z-[55]` (a `<div>` or SVG `<rect>` covering the full viewport)\n- `HelpPanel` sidebar is at `z-30`\n- Overlay is on top and captures all clicks, even in the \"cut out\" area\n- SVG `<mask>` makes pixels visually transparent but the element's bounding box still receives pointer events\n\n## Solution\n\nWhen the tutorial is active (`tutorialStep !== null`), bump the HelpPanel sidebar to `z-[60]` so it naturally sits above the `z-[55]` overlay. The sidebar receives clicks normally because it is physically above the overlay in the stacking context.\n\n## Commit\n- c47a631 — Fix tutorial sidebar click-through: raise sidebar z-index above overlay\n\n## Files modified\n\n### `components/HelpPanel.tsx`\n- Desktop sidebar: conditional z-index\n ```tsx\n className={`... ${isTutorialActive ? \"z-[60]\" : \"z-30\"}`}\n ```\n (Removed hardcoded `z-30` from the className, replaced with conditional)\n- Mobile drawer: same pattern\n ```tsx\n className={`... ${isTutorialActive ? \"z-[60]\" : \"z-20\"}`}\n ```\n- Start Tutorial button changed from Catppuccin Mauve to `bg-emerald-500 hover:bg-emerald-600` (app accent color)\n\n### `components/TutorialOverlay.tsx`\n- Simplified to a clean two-element approach:\n 1. A `<div>` with `fixed inset-0 z-[55]` dark background + `onClick={handleClick}` — handles advancing/ending\n 2. An SVG with `pointer-events: none` that masks out the spotlight cutout (visual only)\n- Removed all sidebar detection logic (`sidebarRect` state, `querySelector(\"aside.translate-x-0\")`, sidebar mask rect)\n- Pulsing ring at `z-[56]` with `pointer-events: none`\n\n## Z-index stack during tutorial\n- `z-30` — other sidebars (NodeDetail, Comments, Activity)\n- `z-[55]` — dark overlay backdrop (clickable, advances tutorial)\n- `z-[56]` — spotlight pulsing ring (visual only)\n- `z-[60]` — HelpPanel sidebar (fully interactive, Back/Skip/Next/Done)\n\n## Acceptance criteria (all met)\n- [x] Back button clickable during tutorial\n- [x] Skip button clickable on first step\n- [x] Next/Done buttons clickable\n- [x] Clicking dark overlay area still advances tutorial\n- [x] `pnpm build` passes","status":"closed","priority":0,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T15:51:01.3789+13:00","updatedAt":"2026-02-12T15:51:24.523752+13:00","closedAt":"2026-02-12T15:51:24.523752+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-s0c"]},{"id":"beads-map-vdg","title":"Enhanced comments: all-comments panel, likes, and threaded replies","description":"## Enhanced comments: all-comments panel, likes, and threaded replies\n\n### Summary\nThree major enhancements to the beads-map comment system, modeled after Hyperscan's ReviewSection:\n\n1. **All Comments panel** — A pill button in the top-right header area that opens a sidebar showing ALL comments across all nodes, sorted newest-first. Each comment links to its target node. This gives users a global activity feed.\n\n2. **Likes on comments** — Heart toggle on each comment (rose-500 when liked, zinc-300 when not), using the `org.impactindexer.review.like` lexicon. The like subject URI is the comment's AT-URI. Likes fetched from Hypergoat indexer. Same create/delete pattern as Hyperscan.\n\n3. **Threaded replies** — Reply button on each comment that shows an inline reply form. Uses the `replyTo` field on `org.impactindexer.review.comment` lexicon. Replies are indented with a left border (Hyperscan pattern: `ml-4 pl-3 border-l border-zinc-100`). Thread tree built client-side from flat comment list.\n\n### Architecture\n\n**Data fetching changes (`hooks/useBeadsComments.ts`):**\n- Fetch BOTH `org.impactindexer.review.comment` AND `org.impactindexer.review.like` from Hypergoat\n- Extend `BeadsComment` type: add `replyTo?: string`, `likes: BeadsLike[]`, `replies: BeadsComment[]`\n- Add `BeadsLike` type: `{ did, handle, displayName?, avatar?, createdAt, uri, rkey }`\n- Build thread tree: flat comments with `replyTo` assembled into nested `replies` arrays\n- Attach likes to their target comments (like subject.uri === comment AT-URI)\n- Export `allComments: BeadsComment[]` (flat list, newest first, for the All Comments panel)\n\n**New component (`components/AllCommentsPanel.tsx`):**\n- Slide-in sidebar from right (same pattern as NodeDetail sidebar)\n- Header: 'All Comments' title + close button\n- List of all comments sorted newest-first\n- Each comment shows: avatar, handle, time, text, target node ID (clickable to navigate)\n- Like button + reply count shown per comment\n\n**NodeDetail comment section enhancements (`components/NodeDetail.tsx`):**\n- Add HeartIcon component (Hyperscan pattern: filled/outline toggle)\n- Add like button on each CommentItem (heart + count, rose-500 when liked)\n- Add 'reply' text button on each CommentItem\n- Add InlineReplyForm (appears below comment being replied to)\n- Render threaded replies with recursive CommentItem (depth-based indentation)\n\n**page.tsx wiring:**\n- Add `allCommentsPanelOpen` state\n- Add pill button in header area\n- Add like/reply handlers\n- Render AllCommentsPanel component\n- Wire node navigation from AllCommentsPanel\n\n### Subject URI conventions\n- Comment on a beads issue: `{ uri: 'beads:<issue-id>', type: 'record' }`\n- Like on a comment: `{ uri: 'at://<did>/org.impactindexer.review.comment/<rkey>', type: 'record' }`\n- Reply to a comment: comment record with `replyTo: 'at://<did>/org.impactindexer.review.comment/<rkey>'`\n\n### Dependency chain\n- .1 (extend hook) is independent — foundational data layer\n- .2 (likes on comments in NodeDetail) depends on .1\n- .3 (threaded replies in NodeDetail) depends on .1\n- .4 (AllCommentsPanel component) depends on .1\n- .5 (page.tsx wiring + pill button) depends on .2, .3, .4\n- .6 (build verification) depends on .5\n\n### Reference files\n- Hyperscan ReviewSection: `/Users/david/Projects/gainforest/hyperscan/src/components/ReviewSection.tsx`\n- Like lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/like.json`\n- Comment lexicon: `/Users/david/Projects/gainforest/lexicons/lexicons/org/impactindexer/review/comment.json`\n- Current hook: `hooks/useBeadsComments.ts`\n- Current NodeDetail: `components/NodeDetail.tsx`\n- Current page.tsx: `app/page.tsx`","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:24:13.04637+13:00","updatedAt":"2026-02-11T01:37:27.139182+13:00","closedAt":"2026-02-11T01:37:27.139182+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":10,"dependentCount":1,"blockerIds":["beads-map-7r6","beads-map-mfw","beads-map-vdg.1","beads-map-vdg.2","beads-map-vdg.3","beads-map-vdg.4","beads-map-vdg.5","beads-map-vdg.6","beads-map-vdg.7","beads-map-z5w"],"dependentIds":["beads-map-dyi"]},{"id":"beads-map-vdg.1","title":"Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees","description":"## Extend useBeadsComments hook: fetch likes, parse replyTo, build thread trees\n\n### Goal\nExtend `hooks/useBeadsComments.ts` to fetch likes from Hypergoat, parse the `replyTo` field on comments, and build threaded comment trees.\n\n### File to modify\n`hooks/useBeadsComments.ts` (currently 289 lines)\n\n### Step 1: Add new types\n\n```typescript\nexport interface BeadsLike {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n createdAt: string;\n uri: string; // AT-URI of the like record\n rkey: string;\n}\n\n// Extend BeadsComment:\nexport interface BeadsComment {\n did: string;\n handle: string;\n displayName?: string;\n avatar?: string;\n text: string;\n createdAt: string;\n uri: string;\n rkey: string;\n replyTo?: string; // NEW — AT-URI of parent comment\n likes: BeadsLike[]; // NEW — likes on this comment\n replies: BeadsComment[]; // NEW — nested child comments\n}\n```\n\n### Step 2: Fetch likes from Hypergoat\n\nUse the same `FETCH_COMMENTS_QUERY` GraphQL query but with `collection: 'org.impactindexer.review.like'`. Create a `fetchLikeRecords()` function (same pagination pattern as `fetchCommentRecords()`).\n\n### Step 3: Parse replyTo from comment records\n\nIn the comment processing loop (currently line 217-238), extract `replyTo` from the record value:\n```typescript\nconst replyTo = (value.replyTo as string) || undefined;\n```\nAdd it to the BeadsComment object.\n\n### Step 4: Attach likes to comments\n\nAfter fetching both comments and likes:\n1. Filter likes to those whose `subject.uri` is an AT-URI of a comment (starts with `at://`)\n2. Build a `Map<commentUri, BeadsLike[]>` \n3. Attach likes to their target comments\n\n### Step 5: Build thread trees\n\nAfter grouping comments by node:\n1. For each node's comments, put all in a `Map<uri, BeadsComment>`\n2. For each comment with `replyTo`, push into `parent.replies`\n3. Root comments = those without `replyTo` (or whose parent is missing)\n4. Sort: root comments newest-first, replies oldest-first (chronological conversation)\n\n### Step 6: Export allComments\n\nAdd to the return value:\n```typescript\nallComments: BeadsComment[] // flat list of all root+reply comments, newest-first, for All Comments panel\n```\n\n### Step 7: Update UseBeadsCommentsResult interface\n\n```typescript\nexport interface UseBeadsCommentsResult {\n commentsByNode: Map<string, BeadsComment[]>; // now threaded trees\n commentedNodeIds: Map<string, number>;\n allComments: BeadsComment[]; // NEW — flat list, newest-first\n isLoading: boolean;\n error: string | null;\n refetch: () => Promise<void>;\n}\n```\n\n### Testing\n- `pnpm build` must pass\n- Verify that comments with `replyTo` fields are correctly nested\n- Verify that likes are attached to the correct comments\n- `allComments` should contain all comments (including replies) sorted newest-first","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:24:33.393775+13:00","updatedAt":"2026-02-11T01:33:11.516403+13:00","closedAt":"2026-02-11T01:33:11.516403+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":3,"dependentCount":1,"blockerIds":["beads-map-vdg.2","beads-map-vdg.3","beads-map-vdg.4"],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-vdg.2","title":"Add heart-toggle likes on comments in NodeDetail","description":"## Add heart-toggle likes on comments in NodeDetail\n\n### Goal\nAdd a heart icon like button on each comment in `components/NodeDetail.tsx`, following the Hyperscan pattern exactly.\n\n### File to modify\n`components/NodeDetail.tsx` (currently 511 lines)\n\n### Dependencies\n- beads-map-vdg.1 (likes data available on BeadsComment objects)\n\n### Step 1: Add HeartIcon component\n\nPort from Hyperscan — two variants (filled/outline):\n```typescript\nfunction HeartIcon({ className = 'w-3 h-3', filled = false }: { className?: string; filled?: boolean }) {\n if (filled) {\n return (\n <svg className={className} viewBox='0 0 24 24' fill='currentColor'>\n <path d='M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z' />\n </svg>\n );\n }\n return (\n <svg className={className} fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z' />\n </svg>\n );\n}\n```\n\n### Step 2: Add like button to CommentItem actions row\n\nIn the `CommentItem` component, add a like button in the actions area alongside the existing delete button.\n\nThe actions row for each comment should be: heart-like button | reply button | delete button (own only)\n\n```tsx\n// In CommentItem actions area\n<div className='flex items-center gap-2 mt-1 text-[10px]'>\n <button\n onClick={() => onLike?.(comment)}\n disabled={!isAuthenticated || isLiking}\n className={`flex items-center gap-0.5 transition-colors ${\n hasLiked ? 'text-rose-500' : 'text-zinc-300 hover:text-rose-500'\n } disabled:opacity-50`}\n >\n <HeartIcon className='w-3 h-3' filled={hasLiked} />\n {comment.likes.length > 0 && <span>{comment.likes.length}</span>}\n </button>\n {/* ... reply button (task .3) ... */}\n {/* ... delete button (existing) ... */}\n</div>\n```\n\n### Step 3: Add like handler props\n\nAdd to `NodeDetailProps`:\n```typescript\nonLikeComment?: (comment: BeadsComment) => Promise<void>;\n```\n\nAdd to `CommentItem` props:\n```typescript\nonLike?: (comment: BeadsComment) => Promise<void>;\nisAuthenticated?: boolean;\n```\n\n### Step 4: Determine `hasLiked` state\n\nIn CommentItem, check if the current user has liked:\n```typescript\nconst hasLiked = currentDid ? comment.likes.some(l => l.did === currentDid) : false;\n```\n\n### Like create/delete will be wired in page.tsx (task .5)\nThe actual API calls (POST/DELETE to /api/records with org.impactindexer.review.like) will be handled by callbacks passed from page.tsx.\n\n### Testing\n- `pnpm build` must pass\n- Heart icon renders in outline state by default\n- Heart icon renders filled + rose-500 when liked by current user\n- Like count shows next to heart when > 0","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:24:55.516758+13:00","updatedAt":"2026-02-11T01:33:11.647637+13:00","closedAt":"2026-02-11T01:33:11.647637+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-vdg.5"],"dependentIds":["beads-map-vdg","beads-map-vdg.1"]},{"id":"beads-map-vdg.3","title":"Add threaded replies with inline reply form in NodeDetail","description":"## Add threaded replies with inline reply form in NodeDetail\n\n### Goal\nAdd reply functionality to comments in `components/NodeDetail.tsx`: a 'reply' text button on each comment, an inline reply form, and recursive threaded rendering with indentation.\n\n### File to modify\n`components/NodeDetail.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (BeadsComment now has `replies: BeadsComment[]` and `replyTo?: string`)\n\n### Step 1: Add InlineReplyForm component\n\nPort from Hyperscan ReviewSection:\n```tsx\nfunction InlineReplyForm({\n replyingTo,\n replyText,\n onTextChange,\n onSubmit,\n onCancel,\n isSubmitting,\n}: {\n replyingTo: BeadsComment;\n replyText: string;\n onTextChange: (text: string) => void;\n onSubmit: () => void;\n onCancel: () => void;\n isSubmitting: boolean;\n}) {\n return (\n <div className='mt-2 ml-4 pl-3 border-l border-emerald-200 space-y-1.5'>\n <div className='flex items-center gap-1.5 text-[10px] text-zinc-400'>\n <span>Replying to</span>\n <span className='font-medium text-zinc-600'>\n {replyingTo.displayName || replyingTo.handle}\n </span>\n </div>\n <div className='flex gap-2'>\n <input\n type='text'\n value={replyText}\n onChange={(e) => onTextChange(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && onSubmit()}\n placeholder='Write a reply...'\n disabled={isSubmitting}\n autoFocus\n className='flex-1 px-2 py-1 text-xs bg-white border border-zinc-200 rounded placeholder-zinc-400 focus:outline-none focus:border-emerald-400 disabled:opacity-50'\n />\n <button onClick={onSubmit} disabled={!replyText.trim() || isSubmitting}\n className='px-2 py-1 text-[10px] font-medium text-emerald-600 hover:text-emerald-700 disabled:opacity-50'>\n {isSubmitting ? '...' : 'Reply'}\n </button>\n <button onClick={onCancel} disabled={isSubmitting}\n className='px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-600 disabled:opacity-50'>\n Cancel\n </button>\n </div>\n </div>\n );\n}\n```\n\n### Step 2: Add reply button to CommentItem actions row\n\nAdd a 'reply' text button (Hyperscan pattern):\n```tsx\n<button\n onClick={() => onStartReply?.(comment)}\n disabled={!isAuthenticated}\n className={`transition-colors disabled:opacity-50 ${\n isReplyingToThis ? 'text-emerald-500' : 'text-zinc-300 hover:text-zinc-500'\n }`}\n>\n reply\n</button>\n```\n\n### Step 3: Make CommentItem recursive for threading\n\nAdd `depth` prop (default 0). When `depth > 0`, add indentation:\n```tsx\n<div className={`${depth > 0 ? 'ml-4 pl-3 border-l border-zinc-100' : ''}`}>\n```\n\nAfter the comment content, render replies recursively:\n```tsx\n{comment.replies.length > 0 && (\n <div className='space-y-0'>\n {comment.replies.map((reply) => (\n <CommentItem key={reply.uri} comment={reply} depth={depth + 1} ... />\n ))}\n </div>\n)}\n```\n\n### Step 4: Add reply state management props\n\nAdd to `NodeDetailProps`:\n```typescript\nonReplyComment?: (parentComment: BeadsComment, text: string) => Promise<void>;\n```\n\nAdd reply state to the Comments section (managed locally in NodeDetail):\n```typescript\nconst [replyingToUri, setReplyingToUri] = useState<string | null>(null);\nconst [replyText, setReplyText] = useState('');\nconst [isSubmittingReply, setIsSubmittingReply] = useState(false);\n```\n\n### Step 5: Wire InlineReplyForm rendering\n\nIn CommentItem, show InlineReplyForm when `replyingToUri === comment.uri`:\n```tsx\n{isReplyingToThis && (\n <InlineReplyForm\n replyingTo={comment}\n replyText={replyText}\n onTextChange={onReplyTextChange}\n onSubmit={onSubmitReply}\n onCancel={onCancelReply}\n isSubmitting={isSubmittingReply}\n />\n)}\n```\n\n### Testing\n- `pnpm build` must pass \n- Reply button appears on each comment\n- Clicking reply shows inline form below that comment\n- Replies render indented with left border\n- Nested replies (reply to a reply) indent further","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:25:16.081672+13:00","updatedAt":"2026-02-11T01:33:11.780553+13:00","closedAt":"2026-02-11T01:33:11.780553+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-vdg.5"],"dependentIds":["beads-map-vdg","beads-map-vdg.1"]},{"id":"beads-map-vdg.4","title":"Create AllCommentsPanel sidebar component","description":"## Create AllCommentsPanel sidebar component\n\n### Goal\nCreate `components/AllCommentsPanel.tsx` — a slide-in sidebar that shows ALL comments across all beads nodes, sorted newest-first. This provides a global activity feed view.\n\n### File to create\n`components/AllCommentsPanel.tsx`\n\n### Dependencies\n- beads-map-vdg.1 (allComments flat list available from hook)\n\n### Props interface\n```typescript\ninterface AllCommentsPanelProps {\n isOpen: boolean;\n onClose: () => void;\n allComments: BeadsComment[]; // flat list, newest-first (from useBeadsComments)\n onNodeNavigate: (nodeId: string) => void; // click a comment's node ID to navigate\n isAuthenticated?: boolean;\n currentDid?: string;\n onLikeComment?: (comment: BeadsComment) => Promise<void>;\n onDeleteComment?: (comment: BeadsComment) => Promise<void>;\n}\n```\n\n### Design\nSame slide-in pattern as the NodeDetail sidebar:\n```tsx\n<aside className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${\n isOpen ? 'translate-x-0' : 'translate-x-full'\n}`}>\n```\n\n### Layout\n1. **Header**: 'All Comments' title + close X button + comment count badge\n2. **Scrollable list**: Each comment shows:\n - Target node pill: clickable badge with node ID (e.g., 'beads-map-cvh') in emerald — clicking calls `onNodeNavigate`\n - Avatar (24px circle) + handle + relative time\n - Comment text\n - Heart like button (rose-500 when liked) + like count\n - If it's a reply, show 'Re: {parentHandle}' label in zinc-400\n - Delete X for own comments\n3. **Empty state**: 'No comments yet' when list is empty\n4. **Footer**: count summary\n\n### Comment card design\n```tsx\n<div className='py-3 border-b border-zinc-50'>\n {/* Node target pill */}\n <button onClick={() => onNodeNavigate(comment.nodeId)}\n className='inline-flex items-center px-1.5 py-0.5 mb-1.5 rounded text-[10px] font-mono bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors'>\n {comment.nodeId}\n </button>\n {/* Rest of comment: avatar + name + time + text + actions */}\n</div>\n```\n\n### Important: nodeId on comments\nThe allComments list from the hook needs to include the target nodeId on each comment. Either:\n- Add `nodeId: string` to BeadsComment interface (set during processing in the hook)\n- Or derive it from `subject.uri.replace(/^beads:/, '')` at render time\n\nPreferred: add `nodeId` to BeadsComment in the hook (task .1) since it's needed here.\n\n### Testing\n- `pnpm build` must pass\n- Panel slides in/out with animation\n- Comments sorted newest-first\n- Clicking node ID navigates to that node\n- Like/delete actions work","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:25:35.922861+13:00","updatedAt":"2026-02-11T01:33:11.912418+13:00","closedAt":"2026-02-11T01:33:11.912418+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-vdg.5"],"dependentIds":["beads-map-vdg","beads-map-vdg.1"]},{"id":"beads-map-vdg.5","title":"Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel","description":"## Wire everything in page.tsx: pill button, like/reply handlers, AllCommentsPanel\n\n### Goal\nConnect all new components in `app/page.tsx`: add the 'Comments' pill button in the header, wire like/reply API handlers, render AllCommentsPanel.\n\n### File to modify\n`app/page.tsx` (currently 926 lines)\n\n### Dependencies\n- beads-map-vdg.1 (extended hook with allComments, likes, thread trees)\n- beads-map-vdg.2 (NodeDetail with like UI)\n- beads-map-vdg.3 (NodeDetail with reply UI) \n- beads-map-vdg.4 (AllCommentsPanel component)\n\n### Step 1: Update imports\n\n```typescript\nimport AllCommentsPanel from '@/components/AllCommentsPanel';\n```\n\n### Step 2: Update useBeadsComments destructuring\n\n```typescript\nconst { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } = useBeadsComments();\n```\n\n### Step 3: Add state\n\n```typescript\nconst [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);\n```\n\n### Step 4: Add like handler\n\n```typescript\nconst handleLikeComment = useCallback(async (comment: BeadsComment) => {\n // Check if already liked by current user\n const existingLike = comment.likes.find(l => l.did === session?.did);\n \n if (existingLike) {\n // Unlike: DELETE the like record\n const response = await fetch(\n \\`/api/records?collection=\\${encodeURIComponent('org.impactindexer.review.like')}&rkey=\\${encodeURIComponent(existingLike.rkey)}\\`,\n { method: 'DELETE' }\n );\n if (!response.ok) throw new Error('Failed to unlike');\n } else {\n // Like: POST a new like record\n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.like',\n record: {\n subject: { uri: comment.uri, type: 'record' },\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to like');\n }\n \n await refetchComments();\n}, [session?.did, refetchComments]);\n```\n\n### Step 5: Add reply handler\n\n```typescript\nconst handleReplyComment = useCallback(async (parentComment: BeadsComment, text: string) => {\n // Extract the nodeId from the parent comment's subject\n // The parent comment targets beads:<nodeId>, replies still target the same node\n // but include replyTo pointing to the parent comment's AT-URI\n const nodeId = /* derive from parentComment — needs nodeId field from task .1 */;\n \n const response = await fetch('/api/records', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n collection: 'org.impactindexer.review.comment',\n record: {\n subject: { uri: \\`beads:\\${nodeId}\\`, type: 'record' },\n text,\n replyTo: parentComment.uri,\n createdAt: new Date().toISOString(),\n },\n }),\n });\n if (!response.ok) throw new Error('Failed to post reply');\n await refetchComments();\n}, [refetchComments]);\n```\n\n### Step 6: Add pill button in header\n\nIn the stats/right area of the header (around line 716), add a comments pill:\n```tsx\n<button\n onClick={() => setAllCommentsPanelOpen(prev => !prev)}\n className={\\`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border transition-colors \\${\n allCommentsPanelOpen\n ? 'bg-emerald-50 text-emerald-600 border-emerald-200'\n : 'bg-white text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:border-zinc-300'\n }\\`}\n>\n <svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor'>\n <path strokeLinecap='round' strokeLinejoin='round' d='M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z' />\n </svg>\n Comments\n {allComments.length > 0 && (\n <span className='px-1.5 py-0.5 bg-red-500 text-white rounded-full text-[10px] font-medium min-w-[18px] text-center'>\n {allComments.length}\n </span>\n )}\n</button>\n```\n\n### Step 7: Render AllCommentsPanel\n\nAfter the NodeDetail sidebar (around line 866), add:\n```tsx\n<AllCommentsPanel\n isOpen={allCommentsPanelOpen}\n onClose={() => setAllCommentsPanelOpen(false)}\n allComments={allComments}\n onNodeNavigate={(nodeId) => {\n handleNodeNavigate(nodeId);\n setAllCommentsPanelOpen(false);\n }}\n isAuthenticated={isAuthenticated}\n currentDid={session?.did}\n onLikeComment={handleLikeComment}\n onDeleteComment={handleDeleteComment}\n/>\n```\n\n### Step 8: Pass new props to NodeDetail (both desktop + mobile instances)\n\nAdd to both NodeDetail instances:\n```typescript\nonLikeComment={handleLikeComment}\nonReplyComment={handleReplyComment}\n```\n\n### Step 9: Close AllCommentsPanel when NodeDetail opens (and vice versa)\n\nWhen selecting a node, close the all-comments panel:\n```typescript\n// In handleNodeClick:\nsetAllCommentsPanelOpen(false);\n```\n\n### Testing\n- `pnpm build` must pass\n- Pill button visible in header\n- Clicking pill opens AllCommentsPanel\n- Like toggle works (creates/deletes like records)\n- Reply creates comment with replyTo field\n- Panel and NodeDetail don't overlap","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:26:04.167505+13:00","updatedAt":"2026-02-11T01:33:12.043078+13:00","closedAt":"2026-02-11T01:33:12.043078+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":4,"blockerIds":["beads-map-vdg.6"],"dependentIds":["beads-map-vdg","beads-map-vdg.2","beads-map-vdg.3","beads-map-vdg.4"]},{"id":"beads-map-vdg.6","title":"Build verification and final cleanup","description":"## Build verification and final cleanup\n\n### Goal\nVerify the full build passes, clean up any TypeScript errors, and ensure all features work together.\n\n### Steps\n1. Run `pnpm build` — must pass with zero errors\n2. Verify no unused imports or dead code from the refactoring\n3. Test the full flow mentally: \n - Comments pill shows in header with count badge\n - Clicking opens AllCommentsPanel with all comments newest-first\n - Each comment shows heart like button\n - Clicking heart toggles like (creates/deletes org.impactindexer.review.like)\n - Reply button shows inline form\n - Submitting reply creates comment with replyTo field\n - Replies render threaded with indentation\n - Node ID in AllCommentsPanel navigates to that node\n4. Update AGENTS.md with new component/hook documentation\n\n### Testing\n- `pnpm build` must pass with zero errors","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:26:12.40132+13:00","updatedAt":"2026-02-11T01:33:12.174234+13:00","closedAt":"2026-02-11T01:33:12.174234+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-vdg","beads-map-vdg.5"]},{"id":"beads-map-vdg.7","title":"Restyle Comments pill to match layout toggle pill styling, remove red counter badge","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T01:36:49.288253+13:00","updatedAt":"2026-02-11T01:37:27.025479+13:00","closedAt":"2026-02-11T01:37:27.025479+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-z5w","title":"Right-click context menu: show description or add comment","description":"## Right-Click Context Menu\n\n### Summary\nReplace the current right-click behavior (which directly opens CommentTooltip) with a two-step interaction:\n1. Right-click a graph node → small context menu appears at cursor with two options\n2. User picks \"Show description\" → opens description modal, OR \"Add comment\" → opens the existing CommentTooltip\n\n### Current behavior\n- Right-click a node in BeadsGraph → `handleNodeRightClick` in `app/page.tsx:425-430` sets `contextMenu: { node, x, y }`\n- `contextMenu` state directly renders `CommentTooltip` component at `app/page.tsx:997-1010`\n- CommentTooltip shows node info, existing comments preview, and compose area\n\n### New behavior\n- Right-click a node → `contextMenu` state renders a NEW `ContextMenu` component (small 2-item menu)\n- \"Show description\" → opens a description modal (same portal modal currently in NodeDetail.tsx:332-370)\n- \"Add comment\" → opens the existing CommentTooltip at the same cursor position\n\n### Architecture\nThree changes needed:\n1. **New `ContextMenu` component** — small floating menu at cursor position with 2 items\n2. **Extract `DescriptionModal` component** — lift the portal modal from NodeDetail into a reusable component\n3. **Wire in `page.tsx`** — new state for `commentTooltipState` and `descriptionModalNode`, replace direct CommentTooltip render with ContextMenu → action flow\n\n### State model (page.tsx)\n```\ncontextMenu: { node, x, y } | null // phase 1: shows ContextMenu\ncommentTooltipState: { node, x, y } | null // phase 2a: shows CommentTooltip\ndescriptionModalNode: GraphNode | null // phase 2b: shows DescriptionModal\n```\n\nRight-click → sets contextMenu → renders ContextMenu\nContextMenu \"Show description\" → sets descriptionModalNode, clears contextMenu\nContextMenu \"Add comment\" → sets commentTooltipState, clears contextMenu\n\n### Subtasks\n- .1 Create ContextMenu component\n- .2 Extract DescriptionModal component from NodeDetail\n- .3 Wire context menu + actions in page.tsx\n- .4 Build verify and push\n\n### Acceptance criteria\n- Right-clicking a graph node shows a small 2-item context menu\n- \"Show description\" opens a full-screen modal with the issue description (markdown rendered)\n- \"Add comment\" opens the existing CommentTooltip (unchanged behavior)\n- Escape / click-outside dismisses the context menu\n- \"View in window\" button in NodeDetail sidebar still works\n- pnpm build passes","status":"closed","priority":1,"issueType":"epic","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:18:37.401674+13:00","updatedAt":"2026-02-11T10:47:46.54973+13:00","closedAt":"2026-02-11T10:47:46.54973+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":12,"dependentCount":1,"blockerIds":["beads-map-z5w.1","beads-map-z5w.10","beads-map-z5w.11","beads-map-z5w.12","beads-map-z5w.2","beads-map-z5w.3","beads-map-z5w.4","beads-map-z5w.5","beads-map-z5w.6","beads-map-z5w.7","beads-map-z5w.8","beads-map-z5w.9"],"dependentIds":["beads-map-vdg"]},{"id":"beads-map-z5w.1","title":"Create ContextMenu component","description":"## Create ContextMenu component\n\n### What\nA small floating context menu that appears on right-click of a graph node. Shows two options: \"Show description\" and \"Add comment\". Styled to match the existing app aesthetic.\n\n### New file: `components/ContextMenu.tsx`\n\n#### Props interface\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number; // clientX from right-click event\n y: number; // clientY from right-click event\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n```\n\n#### Positioning\n- `position: fixed`, placed at `(x + 4, y + 4)` — slightly offset from cursor\n- Viewport clamping: if menu would overflow right edge, shift left; if overflow bottom, shift up\n- The menu is small (~160px wide, ~80px tall) so clamping is simple\n- Use `useEffect` + `getBoundingClientRect()` on mount to measure and clamp (same pattern as CommentTooltip.tsx:35-55 but simpler)\n\n#### Visual design\n```\n┌─────────────────┐\n│ 📄 Show description │\n│ 💬 Add comment │\n└─────────────────┘\n```\n\n- Container: `bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden`\n- Shadow: `box-shadow: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.06)`\n- Each item: `px-3 py-2 text-xs text-zinc-700 hover:bg-zinc-50 cursor-pointer flex items-center gap-2 transition-colors`\n- Icons: small SVGs (w-3.5 h-3.5 text-zinc-400)\n - \"Show description\": document/page icon\n - \"Add comment\": chat bubble icon (same as CommentTooltip.tsx:172-183)\n- Divider between items: `border-b border-zinc-100` on first item\n- Animate in: opacity 0→1, translateY(2px)→0, transition 0.15s\n\n#### Dismiss behavior\n- Escape key → calls `onClose()`\n- Click outside → calls `onClose()` (with 50ms delay, same as CommentTooltip.tsx:76-94)\n- Prevent browser context menu on the component itself: `onContextMenu={(e) => e.preventDefault()}`\n\n#### Item click behavior\n- \"Show description\" → calls `onShowDescription()`\n- \"Add comment\" → calls `onAddComment()`\n- Both should also implicitly close the menu (parent handles this by clearing contextMenu state)\n\n#### Full component structure\n```tsx\n\"use client\";\nimport { useState, useRef, useEffect } from \"react\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClose: () => void;\n}\n\nexport function ContextMenu({ node, x, y, onShowDescription, onAddComment, onClose }: ContextMenuProps) {\n const menuRef = useRef<HTMLDivElement>(null);\n const [pos, setPos] = useState({ x: 0, y: 0 });\n const [visible, setVisible] = useState(false);\n\n // Position + clamp to viewport\n useEffect(() => {\n if (!menuRef.current) return;\n const rect = menuRef.current.getBoundingClientRect();\n const vw = window.innerWidth;\n const vh = window.innerHeight;\n let nx = x + 4;\n let ny = y + 4;\n if (nx + rect.width > vw - 16) nx = vw - rect.width - 16;\n if (nx < 16) nx = 16;\n if (ny + rect.height > vh - 16) ny = vh - rect.height - 16;\n if (ny < 16) ny = 16;\n setPos({ x: nx, y: ny });\n setVisible(true);\n }, [x, y]);\n\n // Escape key\n useEffect(() => {\n const handler = (e: KeyboardEvent) => { if (e.key === \"Escape\") onClose(); };\n window.addEventListener(\"keydown\", handler);\n return () => window.removeEventListener(\"keydown\", handler);\n }, [onClose]);\n\n // Click outside (with delay)\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();\n };\n const timer = setTimeout(() => window.addEventListener(\"mousedown\", handler), 50);\n return () => { clearTimeout(timer); window.removeEventListener(\"mousedown\", handler); };\n }, [onClose]);\n\n return (\n <div ref={menuRef} style={{ position: \"fixed\", left: pos.x, top: pos.y, zIndex: 100,\n opacity: visible ? 1 : 0, transform: visible ? \"translateY(0)\" : \"translateY(2px)\",\n transition: \"opacity 0.15s ease, transform 0.15s ease\" }}\n onContextMenu={(e) => e.preventDefault()}\n >\n <div className=\"bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden\" style={{ minWidth: 180 }}>\n <button onClick={onShowDescription}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-b border-zinc-100\">\n {/* Document icon SVG */}\n Show description\n </button>\n <button onClick={onAddComment}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors\">\n {/* Chat bubble icon SVG */}\n Add comment\n </button>\n </div>\n </div>\n );\n}\n```\n\n### SVG Icons\n**Document icon** (Show description):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z\" />\n</svg>\n```\n\n**Chat bubble icon** (Add comment):\n```tsx\n<svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z\" />\n</svg>\n```\n\n### Files to create\n- `components/ContextMenu.tsx`\n\n### Acceptance criteria\n- ContextMenu renders at cursor position with 2 items\n- Hover states on items (bg-zinc-50)\n- Escape key dismisses\n- Click outside dismisses\n- Clicking \"Show description\" calls onShowDescription\n- Clicking \"Add comment\" calls onAddComment\n- Viewport clamping works (menu stays on screen)\n- Animate-in transition (opacity + translateY)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:19:10.934581+13:00","updatedAt":"2026-02-11T09:23:40.14949+13:00","closedAt":"2026-02-11T09:23:40.14949+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-z5w.3"],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.10","title":"Avatar visual tuning: full opacity, persistent in clusters, minimap display","description":"## Avatar visual tuning\n\n### Changes\n\n#### 1. Full opacity (components/BeadsGraph.tsx paintNode)\n- Changed `ctx.globalAlpha = Math.min(opacity, 0.6)` → `ctx.globalAlpha = 1`\n- Avatars are now always at full opacity, not subtle/translucent\n\n#### 2. Never fade in clusters (components/BeadsGraph.tsx paintNode)\n- Removed `globalScale > 0.4` threshold from the avatar drawing condition\n- Changed `if (claimInfo && globalScale > 0.4)` → `if (claimInfo)`\n- Avatars remain visible even when zoomed out to cluster view\n\n#### 3. Constant screen-space size on zoom (components/BeadsGraph.tsx paintNode)\n- Changed `avatarSize = Math.min(8, Math.max(4, 10 / globalScale))` → `avatarSize = Math.max(4, 10 / globalScale)`\n- Removed the `Math.min(8, ...)` cap so avatar grows in graph-space as you zoom out, maintaining roughly the same pixel size on screen\n\n#### 4. Minimap avatar display (components/BeadsGraph.tsx redrawMinimap)\n- Added avatar drawing loop after the node dots loop in `redrawMinimap`\n- For each claimed node, draws a small circular avatar (radius 5px) at the node position on the minimap\n- Uses the same `getAvatarImage` cache for image loading\n- Fallback: gray circle if image not loaded\n- White border ring for contrast\n\n### Commits\n- `8693bec` Reduce claim avatar opacity to 0.6, constant screen-space size on zoom\n- `0a23755` Avatar: full opacity, never fade in clusters, show on minimap\n\n### Files changed\n- `components/BeadsGraph.tsx` — paintNode avatar section + redrawMinimap avatar loop\n\n### Status: DONE","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:26:08.912736+13:00","updatedAt":"2026-02-11T10:26:45.656242+13:00","closedAt":"2026-02-11T10:26:45.656242+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.11"],"dependentIds":["beads-map-z5w","beads-map-z5w.9"]},{"id":"beads-map-z5w.11","title":"Avatar hover tooltip: show profile info on mouseover","description":"## Avatar hover tooltip\n\n### What\nWhen hovering over a claimed node avatar on the graph, a small tooltip appears showing the profile picture and @handle.\n\n### Implementation\n\n#### 1. New prop on BeadsGraph (`components/BeadsGraph.tsx`)\n- Added `onAvatarHover?: (info: { handle: string; avatar?: string; x: number; y: number } | null) => void` to `BeadsGraphProps`\n- Added `onAvatarHoverRef` (stable ref for the callback, avoids stale closures)\n- Added `hoveredAvatarNodeRef` (tracks which avatar is hovered to avoid redundant callbacks)\n- Added `viewNodesRef` (ref synced from `viewNodes` memo, so mousemove respects epics view)\n\n#### 2. Mousemove hit-testing (`components/BeadsGraph.tsx`)\n- `useEffect` registers a `mousemove` listener on the container div\n- Converts screen coords → graph coords via `fg.screen2GraphCoords()`\n- Iterates `viewNodesRef.current` (respects full/epics view mode)\n- For each node with a claim, computes the avatar circle position (`node.x + size * 0.7`, `node.y + size * 0.7`) and radius (`Math.max(4, 10 / globalScale)`)\n- Hit-tests: if mouse is inside the avatar circle, emits `onAvatarHover({ handle, avatar, x, y })`\n- If mouse leaves all avatar circles, emits `onAvatarHover(null)`\n- Uses `[]` dependency (refs for everything) so listener is registered once\n\n#### 3. Epics view fix\n- Initially the hit-test iterated `nodes` (raw prop) which broke in epics view since child nodes are collapsed\n- Fixed to iterate `viewNodesRef.current` which reflects the current view mode\n- `viewNodesRef.current` is synced inline after the `viewNodes` useMemo\n\n#### 4. Tooltip rendering (`app/page.tsx`)\n- Added `avatarTooltip` state: `{ handle, avatar, x, y } | null`\n- Passed `onAvatarHover={setAvatarTooltip}` to `<BeadsGraph>`\n- Renders a `position: fixed` tooltip at `(x+12, y-8)` from cursor with `pointerEvents: none`\n- Tooltip shows: profile pic (20x20 rounded circle) + `@handle` text\n- White bg, zinc border, rounded-lg, shadow-lg — matches app aesthetic\n- Fallback: gray circle with first letter if no avatar URL\n\n### Commits\n- `ea0a905` Add avatar hover tooltip showing profile pic and handle\n- `5817941` Fix avatar tooltip in epics view: hit-test against viewNodes not raw nodes\n\n### Files changed\n- `components/BeadsGraph.tsx` — onAvatarHover prop, refs, mousemove useEffect, viewNodesRef\n- `app/page.tsx` — avatarTooltip state, onAvatarHover prop, tooltip JSX\n\n### Status: DONE","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:26:37.012238+13:00","updatedAt":"2026-02-11T10:26:45.776772+13:00","closedAt":"2026-02-11T10:26:45.776772+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-z5w","beads-map-z5w.10"]},{"id":"beads-map-z5w.12","title":"Unclaim task: right-click to remove claim from a node","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:47:43.376019+13:00","updatedAt":"2026-02-11T10:47:46.432125+13:00","closedAt":"2026-02-11T10:47:46.432125+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":1,"blockerIds":[],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.2","title":"Extract DescriptionModal component from NodeDetail","description":"## Extract DescriptionModal component from NodeDetail\n\n### What\nThe description modal currently lives inside `components/NodeDetail.tsx` (lines 332-370) using local `descriptionExpanded` state. We need the same modal accessible from the right-click context menu (which lives in `page.tsx`, not inside NodeDetail). \n\nExtract the modal into a standalone `DescriptionModal` component, then use it from both NodeDetail and page.tsx.\n\n### Current implementation in NodeDetail.tsx\n\n**State (line 52):**\n```typescript\nconst [descriptionExpanded, setDescriptionExpanded] = useState(false);\n```\n\n**\"View in window\" button (lines 317-322):**\n```tsx\n<button\n onClick={() => setDescriptionExpanded(true)}\n className=\"text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors\"\n>\n View in window\n</button>\n```\n\n**Portal modal (lines 332-370):**\n```tsx\n{descriptionExpanded && node.description && createPortal(\n <div className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={() => setDescriptionExpanded(false)}>\n <div className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}>\n {/* Header: node.id + node.title + X button */}\n {/* Body: ReactMarkdown with node.description */}\n </div>\n </div>,\n document.body\n)}\n```\n\n### New file: `components/DescriptionModal.tsx`\n\n```typescript\n\"use client\";\nimport { createPortal } from \"react-dom\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport type { GraphNode } from \"@/lib/types\";\n\ninterface DescriptionModalProps {\n node: GraphNode;\n onClose: () => void;\n}\n\nexport function DescriptionModal({ node, onClose }: DescriptionModalProps) {\n if (!node.description) return null;\n\n return createPortal(\n <div\n className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm\"\n onClick={onClose}\n >\n <div\n className=\"bg-white rounded-xl shadow-2xl w-[90vw] max-w-2xl max-h-[80vh] flex flex-col\"\n onClick={(e) => e.stopPropagation()}\n >\n {/* Modal header */}\n <div className=\"flex items-center justify-between px-5 py-3 border-b border-zinc-100\">\n <div className=\"flex items-center gap-2 min-w-0\">\n <span className=\"text-xs font-mono font-semibold text-emerald-600 shrink-0\">\n {node.id}\n </span>\n <span className=\"text-sm font-semibold text-zinc-900 truncate\">\n {node.title}\n </span>\n </div>\n <button\n onClick={onClose}\n className=\"shrink-0 p-1 text-zinc-400 hover:text-zinc-600 transition-colors\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {/* Modal body */}\n <div className=\"flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed\">\n <ReactMarkdown remarkPlugins={[remarkGfm]}>\n {node.description}\n </ReactMarkdown>\n </div>\n </div>\n </div>,\n document.body\n );\n}\n```\n\n### Refactor NodeDetail.tsx\n\n**Remove from NodeDetail.tsx:**\n- The `descriptionExpanded` state (line 52)\n- The portal modal JSX (lines 332-370)\n\n**Keep in NodeDetail.tsx:**\n- The \"View in window\" button (lines 317-322)\n- But change its onClick to use the new component locally\n\n**Two approaches for NodeDetail:**\n\n**Option A (keep local state):** NodeDetail keeps its own `descriptionExpanded` state and renders `<DescriptionModal>` when true. Simple, no prop drilling needed. The \"View in window\" button works exactly as before.\n\n**Option B (lift state via callback):** Add an `onExpandDescription?: () => void` prop to NodeDetail. \"View in window\" calls this callback, parent (page.tsx) sets `descriptionModalNode`. More centralized but adds a prop.\n\n**Recommended: Option A.** Keep NodeDetail self-contained. It renders `<DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />` instead of the inline portal. The context menu in page.tsx independently renders `<DescriptionModal>` from its own state. Two independent entry points, same component. No coupling needed.\n\n### Changes to NodeDetail.tsx\n\n1. **Add import:**\n```typescript\nimport { DescriptionModal } from \"./DescriptionModal\";\n```\n\n2. **Keep `descriptionExpanded` state** (line 52) — unchanged\n\n3. **Replace lines 332-370** (the inline createPortal block) with:\n```tsx\n{descriptionExpanded && node.description && (\n <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />\n)}\n```\n\n4. **Remove import** of `createPortal` from NodeDetail.tsx IF it is no longer used elsewhere in the file. Check: `createPortal` is only used for the description modal, so remove `import { createPortal } from \"react-dom\"` from NodeDetail.tsx.\n\n5. **Remove import** of `ReactMarkdown` and `remarkGfm` from NodeDetail.tsx IF they are no longer used. Check: ReactMarkdown is still used for the inline description preview (line 325-327), so KEEP these imports.\n\nActually wait — `createPortal` is imported at the top of NodeDetail.tsx. Let me check if it is used anywhere else in the file besides the description modal. Looking at the NodeDetail component, createPortal is ONLY used for the description modal (lines 332-370). So yes, remove the createPortal import.\n\n### Files to create\n- `components/DescriptionModal.tsx`\n\n### Files to edit \n- `components/NodeDetail.tsx`:\n - Add import for DescriptionModal\n - Remove import for createPortal (from \"react-dom\")\n - Replace inline portal JSX (lines 332-370) with `<DescriptionModal>` usage\n - Keep descriptionExpanded state and \"View in window\" button unchanged\n\n### Acceptance criteria\n- New `DescriptionModal` component renders the same modal as before\n- \"View in window\" button in NodeDetail sidebar still works identically\n- DescriptionModal uses createPortal to document.body with z-[100]\n- Backdrop click and X button close the modal\n- Markdown rendering with remarkGfm works\n- No visual regression in the modal appearance\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:19:41.231435+13:00","updatedAt":"2026-02-11T09:23:40.327949+13:00","closedAt":"2026-02-11T09:23:40.327949+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-z5w.3"],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.3","title":"Wire context menu and actions in page.tsx","description":"## Wire context menu and actions in page.tsx\n\n### What\nConnect the new ContextMenu and DescriptionModal components into the main page. Replace the direct CommentTooltip render with a two-phase flow: ContextMenu → action (description modal OR comment tooltip).\n\n### Current code to change\n\n**State (line 184-188):**\n```typescript\nconst [contextMenu, setContextMenu] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n```\n\n**Right-click handler (lines 425-430):**\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n },\n []\n);\n```\n\n**CommentTooltip render (lines 997-1010):**\n```tsx\n{contextMenu && (\n <CommentTooltip\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onClose={() => setContextMenu(null)}\n onSubmit={async (text) => {\n await handlePostComment(contextMenu.node.id, text);\n setContextMenu(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(contextMenu.node.id)}\n />\n)}\n```\n\n**Background click (lines 420-423):**\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n}, []);\n```\n\n### New state\n\nAdd after existing `contextMenu` state (~line 188):\n```typescript\n// Separate state for CommentTooltip (opened from context menu \"Add comment\")\nconst [commentTooltipState, setCommentTooltipState] = useState<{\n node: GraphNode;\n x: number;\n y: number;\n} | null>(null);\n\n// Description modal (opened from context menu \"Show description\")\nconst [descriptionModalNode, setDescriptionModalNode] = useState<GraphNode | null>(null);\n```\n\n### New imports\n\nAdd at top of file:\n```typescript\nimport { ContextMenu } from \"@/components/ContextMenu\";\nimport { DescriptionModal } from \"@/components/DescriptionModal\";\n```\n\n### Changes to handleNodeRightClick\n\nNo change needed — it still sets `contextMenu` state. But now `contextMenu` renders `ContextMenu` instead of `CommentTooltip`.\n\n### Changes to handleBackgroundClick\n\nAlso clear the new states:\n```typescript\nconst handleBackgroundClick = useCallback(() => {\n setSelectedNode(null);\n setContextMenu(null);\n setCommentTooltipState(null);\n}, []);\n```\n\nNote: do NOT clear `descriptionModalNode` on background click — the modal has its own backdrop click handler.\n\n### Replace CommentTooltip render block (lines 997-1010)\n\nReplace the entire block with:\n\n```tsx\n{/* Right-click context menu */}\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => {\n setDescriptionModalNode(contextMenu.node);\n setContextMenu(null);\n }}\n onAddComment={() => {\n setCommentTooltipState({\n node: contextMenu.node,\n x: contextMenu.x,\n y: contextMenu.y,\n });\n setContextMenu(null);\n }}\n onClose={() => setContextMenu(null)}\n />\n)}\n\n{/* Comment tooltip (opened from context menu \"Add comment\") */}\n{commentTooltipState && (\n <CommentTooltip\n node={commentTooltipState.node}\n x={commentTooltipState.x}\n y={commentTooltipState.y}\n onClose={() => setCommentTooltipState(null)}\n onSubmit={async (text) => {\n await handlePostComment(commentTooltipState.node.id, text);\n setCommentTooltipState(null);\n }}\n isAuthenticated={isAuthenticated}\n existingComments={commentsByNode.get(commentTooltipState.node.id)}\n />\n)}\n\n{/* Description modal (opened from context menu \"Show description\") */}\n{descriptionModalNode && (\n <DescriptionModal\n node={descriptionModalNode}\n onClose={() => setDescriptionModalNode(null)}\n />\n)}\n```\n\n### Placement in JSX\n\nThe three blocks above should go at the same location where the CommentTooltip currently renders (around line 997, just before `</div>` closing the graph area div at line 1012).\n\nThe `DescriptionModal` uses createPortal to document.body with z-[100], so its placement in the JSX tree does not matter for visual layering. But keeping it near the other overlays is cleaner.\n\n### Edge cases\n\n1. **Right-click while context menu is open**: Existing handler overwrites `contextMenu` state — ContextMenu repositions. Works correctly.\n2. **Right-click while CommentTooltip is open**: The `handleNodeRightClick` sets `contextMenu` which shows ContextMenu. The CommentTooltip from a previous action stays open (its state is separate). The user can dismiss CommentTooltip via its own Escape/click-outside, or just interact with the new context menu. To be cleaner, we should clear `commentTooltipState` when a new right-click happens:\n ```typescript\n const handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n setCommentTooltipState(null); // dismiss any open comment tooltip\n },\n []\n );\n ```\n3. **Right-click while description modal is open**: The modal has z-[100] with backdrop. A right-click on the graph behind the backdrop would not fire (backdrop captures click). If the modal IS open and user clicks backdrop to dismiss it, then right-clicks a node, the normal flow happens. No special handling needed.\n4. **Node with no description**: If user picks \"Show description\" on a node without a description, `DescriptionModal` receives a node with `node.description` being undefined/empty. The component should handle this gracefully — show a \"No description\" message, or the ContextMenu could disable/hide the option. **Recommended: hide \"Show description\" if `!node.description`** — add a `hasDescription` check in ContextMenu.\n\n### Update ContextMenu to conditionally show \"Show description\"\n\nPass `hasDescription` or check `node.description` inside ContextMenu. If no description, either:\n- (a) Hide the item entirely (cleaner)\n- (b) Show it grayed out / disabled\n\n**Recommended: (a) hide it.** If the node has no description, the context menu shows only \"Add comment\". If it has a description, both items show.\n\nTo handle this: ContextMenu already receives the `node` prop. It can check `node.description` internally:\n```tsx\n{node.description && (\n <button onClick={onShowDescription} ...>Show description</button>\n)}\n<button onClick={onAddComment} ...>Add comment</button>\n```\n\nBut wait — if a node has no description and the context menu only shows 1 item, the right-click context menu is pointless overhead (same as before — just open CommentTooltip directly). \n\n**Better approach:** In `handleNodeRightClick`, if the node has no description, skip the context menu and directly open the comment tooltip:\n```typescript\nconst handleNodeRightClick = useCallback(\n (node: GraphNode, event: MouseEvent) => {\n setCommentTooltipState(null);\n if (!node.description) {\n // No description → skip context menu, open comment tooltip directly\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n },\n []\n);\n```\n\nThis preserves the existing UX for nodes without descriptions (identical to current behavior) and only shows the context menu when there is a meaningful choice.\n\n### Files to edit\n- `app/page.tsx`:\n - Add imports for ContextMenu and DescriptionModal\n - Add `commentTooltipState` and `descriptionModalNode` state\n - Update `handleNodeRightClick` to check for description\n - Update `handleBackgroundClick` to clear new states\n - Replace CommentTooltip render block with ContextMenu + CommentTooltip + DescriptionModal\n\n### Acceptance criteria\n- Right-click a node WITH description → context menu with 2 items\n- Right-click a node WITHOUT description → CommentTooltip opens directly (no context menu)\n- \"Show description\" → description modal opens, context menu closes\n- \"Add comment\" → CommentTooltip opens at same position, context menu closes\n- Right-click another node while CommentTooltip is open → CommentTooltip closes, new context menu opens\n- Background click clears context menu and comment tooltip\n- Escape closes whichever overlay is topmost\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:20:21.369074+13:00","updatedAt":"2026-02-11T09:23:40.50276+13:00","closedAt":"2026-02-11T09:23:40.50276+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":3,"blockerIds":["beads-map-z5w.4"],"dependentIds":["beads-map-z5w","beads-map-z5w.1","beads-map-z5w.2"]},{"id":"beads-map-z5w.4","title":"Build verify and push context menu feature","description":"## Build verify and push\n\n### What\nFinal task: run pnpm build, fix any errors, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.1\nbd close beads-map-z5w.2\nbd close beads-map-z5w.3\nbd close beads-map-z5w.4\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add right-click context menu with show description and add comment options (beads-map-z5w)\"\ngit push\n```\n\n### Edge cases to verify\n- Right-click node with description → context menu → \"Show description\" → modal opens\n- Right-click node with description → context menu → \"Add comment\" → CommentTooltip opens\n- Right-click node without description → CommentTooltip opens directly (no context menu)\n- Escape dismisses context menu / comment tooltip / description modal\n- Click outside dismisses context menu / comment tooltip\n- Backdrop click dismisses description modal\n- \"View in window\" in NodeDetail sidebar still works\n- Right-click during timeline replay still works\n- Context menu does not overlap viewport edges\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks and epic closed in beads","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:20:31.850081+13:00","updatedAt":"2026-02-11T09:23:40.674292+13:00","closedAt":"2026-02-11T09:23:40.674292+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":2,"blockerIds":[],"dependentIds":["beads-map-z5w","beads-map-z5w.3"]},{"id":"beads-map-z5w.5","title":"Add 'Claim task' menu item to ContextMenu and post claim comment","description":"## Add \"Claim task\" menu item to ContextMenu and post claim comment\n\n### What\nAdd a third option \"Claim task\" to the right-click context menu. When clicked, it posts a comment on the node with the text `@<handle>` (e.g., `@satyam2.climateai.org`). The menu item only appears when the user is authenticated AND the node is not already claimed by anyone.\n\n### Detecting if a node is already claimed\nA node is \"claimed\" if any of its comments has text that starts with `@` and matches a handle pattern. We need to check `commentsByNode.get(nodeId)` to see if any comment text starts with `@`. Since claims are just `@handle`, a simple check is:\n\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\nThis checks: text starts with `@`, and is a single word (no spaces) — i.e., just a handle tag.\n\n### Changes to `components/ContextMenu.tsx`\n\n#### New props:\n```typescript\ninterface ContextMenuProps {\n node: GraphNode;\n x: number;\n y: number;\n onShowDescription: () => void;\n onAddComment: () => void;\n onClaimTask?: () => void; // NEW — undefined if not authenticated or already claimed\n onClose: () => void;\n}\n```\n\n#### New menu item (after \"Add comment\", before closing `</div>`):\n```tsx\n{onClaimTask && (\n <button\n onClick={onClaimTask}\n className=\"w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-t border-zinc-100\"\n >\n <svg className=\"w-3.5 h-3.5 text-zinc-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.5} stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z\" />\n </svg>\n Claim task\n </button>\n)}\n```\n\nThe person/user icon SVG is from Heroicons (user outline).\n\nAlso add `border-t border-zinc-100` to the \"Add comment\" button when \"Claim task\" follows it. Actually simpler: just add `border-t border-zinc-100` to the claim button itself (as shown above), and keep \"Add comment\" unchanged — it already has no bottom border.\n\n### Changes to `app/page.tsx`\n\n#### 1. Add claim handler function (after `handlePostComment`, around line 485):\n```typescript\nconst handleClaimTask = useCallback(\n async (nodeId: string) => {\n if (!session?.handle) return;\n await handlePostComment(nodeId, `@${session.handle}`);\n },\n [session?.handle, handlePostComment]\n);\n```\n\nThis reuses the existing `handlePostComment` which:\n- POSTs to `/api/records` with collection `org.impactindexer.review.comment`\n- Creates a comment with `subject.uri = \"beads:<nodeId>\"` and `text = \"@handle\"`\n- Calls `refetchComments()` to update the UI\n\n#### 2. Add `isNodeClaimed` helper (at module level or as a function in page.tsx):\n```typescript\nfunction isNodeClaimed(comments?: BeadsComment[]): boolean {\n if (!comments) return false;\n // A claim comment is just \"@handle\" — starts with @ and has no spaces\n return comments.some(c => c.text.startsWith(\"@\") && c.text.trim().indexOf(\" \") === -1);\n}\n```\n\n#### 3. Update ContextMenu render (around line 1022):\n```tsx\n{contextMenu && (\n <ContextMenu\n node={contextMenu.node}\n x={contextMenu.x}\n y={contextMenu.y}\n onShowDescription={() => { ... }} // unchanged\n onAddComment={() => { ... }} // unchanged\n onClaimTask={\n isAuthenticated && !isNodeClaimed(commentsByNode.get(contextMenu.node.id))\n ? () => {\n handleClaimTask(contextMenu.node.id);\n setContextMenu(null);\n }\n : undefined\n }\n onClose={() => setContextMenu(null)}\n />\n)}\n```\n\nWhen `onClaimTask` is undefined, the ContextMenu hides the \"Claim task\" button.\n\n### Edge cases\n1. **Not authenticated**: `onClaimTask` is undefined → button hidden\n2. **Already claimed**: `isNodeClaimed` returns true → button hidden\n3. **User claims their own node**: Works fine, comment is posted\n4. **Node with no description + not authenticated**: Right-click opens CommentTooltip directly (existing behavior in `handleNodeRightClick` which checks `!node.description`)\n5. **Node with no description + authenticated + not claimed**: Currently skips context menu. Need to update `handleNodeRightClick` to show context menu even for nodes without description IF the user is authenticated (so they can see \"Claim task\"). Update the condition:\n ```typescript\n if (!node.description && !isAuthenticated) {\n // No description and not logged in → only action is comment → skip menu\n setCommentTooltipState({ node, x: event.clientX, y: event.clientY });\n } else {\n setContextMenu({ node, x: event.clientX, y: event.clientY });\n }\n ```\n Wait, but if `isAuthenticated` but node has no description AND is already claimed, the menu would show just \"Add comment\" which is pointless overhead. The clean rule is: show context menu if there are 2+ items to choose from. For now, keep it simple: always show context menu when authenticated (even if only \"Add comment\" + \"Claim task\", or just \"Add comment\" if claimed). The overhead of one extra click is fine for the authenticated UX.\n\n### Files to edit\n- `components/ContextMenu.tsx` — add `onClaimTask` prop and conditional button\n- `app/page.tsx` — add `handleClaimTask`, `isNodeClaimed` helper, update ContextMenu render, update `handleNodeRightClick` condition\n\n### Acceptance criteria\n- \"Claim task\" appears in context menu when authenticated AND node not already claimed\n- \"Claim task\" hidden when not authenticated OR node already claimed\n- Clicking \"Claim task\" posts a comment `@<handle>` on the node\n- Comments refetch after claiming\n- Context menu closes after claiming\n- When authenticated, context menu always shows (even for nodes without description)\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:47:43.132495+13:00","updatedAt":"2026-02-11T09:54:17.219995+13:00","closedAt":"2026-02-11T09:54:17.219995+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":1,"blockerIds":["beads-map-z5w.6"],"dependentIds":["beads-map-z5w"]},{"id":"beads-map-z5w.6","title":"Compute claimed-node avatar map from comments","description":"## Compute claimed-node avatar map from comments\n\n### What\nDerive a `Map<string, { avatar?: string; handle: string }>` from `allComments` that maps node IDs to the claimant profile info. This map is then passed to `BeadsGraph` for rendering the avatar on the canvas.\n\n### How claims are detected\nA claim comment has text that starts with `@` and is a single word (no spaces). For example: `@satyam2.climateai.org`. Only the first claim per node counts (one claim only).\n\nThe comment object (`BeadsComment`) already has the resolved profile info: `handle`, `avatar`, `displayName`, `did`. So when we find a claim comment, we already have the avatar URL.\n\n### Implementation in `app/page.tsx`\n\n#### 1. Add a useMemo to compute claimed nodes (after `commentsByNode` is available):\n\n```typescript\n// Compute claimed node avatars from comments\n// A claim comment has text \"@handle\" (starts with @, no spaces)\nconst claimedNodeAvatars = useMemo(() => {\n const map = new Map<string, { avatar?: string; handle: string }>();\n if (!allComments) return map;\n for (const comment of allComments) {\n // Skip if this node already has a claimant (first claim wins)\n if (map.has(comment.nodeId)) continue;\n const text = comment.text.trim();\n if (text.startsWith(\"@\") && text.indexOf(\" \") === -1) {\n map.set(comment.nodeId, {\n avatar: comment.avatar,\n handle: comment.handle,\n });\n }\n }\n return map;\n}, [allComments]);\n```\n\nNote: `allComments` is the flat array from `useBeadsComments()` (already available in page.tsx at line 179). It contains all comments across all nodes, each with resolved profile info.\n\n#### 2. Pass to BeadsGraph:\n\n```tsx\n<BeadsGraph\n // ... existing props ...\n claimedNodeAvatars={claimedNodeAvatars}\n/>\n```\n\n#### 3. Add prop to BeadsGraphProps:\n\nIn `components/BeadsGraph.tsx`, add to the props interface:\n```typescript\nclaimedNodeAvatars?: Map<string, { avatar?: string; handle: string }>;\n```\n\nAnd add a ref to sync it (same pattern as `commentedNodeIdsRef`):\n```typescript\nconst claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string }>>(\n claimedNodeAvatars || new Map()\n);\n\nuseEffect(() => {\n claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map();\n refreshGraph(graphRef);\n}, [claimedNodeAvatars]);\n```\n\n### Why use a ref in BeadsGraph\nSame reason as `commentedNodeIdsRef`: the `paintNode` callback has `[]` dependencies (never recreated). It reads from refs, not from props or state. If we used props directly, we would need to add `claimedNodeAvatars` to the paintNode dependency array, which would cause the ForceGraph component to re-render and re-heat the simulation. The ref pattern avoids this.\n\n### Data flow summary\n```\nuseBeadsComments() → allComments (flat array with resolved profiles)\n ↓\nuseMemo → claimedNodeAvatars: Map<nodeId, { avatar, handle }>\n ↓\n<BeadsGraph claimedNodeAvatars={...}>\n ↓\nclaimedNodeAvatarsRef.current (ref, synced via useEffect)\n ↓\npaintNode reads claimedNodeAvatarsRef.current.get(nodeId)\n```\n\n### Files to edit\n- `app/page.tsx` — add `claimedNodeAvatars` useMemo, pass to BeadsGraph\n- `components/BeadsGraph.tsx` — add `claimedNodeAvatars` prop, add ref + sync effect\n\n### Acceptance criteria\n- `claimedNodeAvatars` correctly maps node IDs to claimant profile info\n- Only the first claim comment per node is used\n- Map updates reactively when comments change (useMemo dependency on allComments)\n- BeadsGraph receives the map and syncs it to a ref\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:48:05.021917+13:00","updatedAt":"2026-02-11T09:54:17.341949+13:00","closedAt":"2026-02-11T09:54:17.341949+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.7"],"dependentIds":["beads-map-z5w","beads-map-z5w.5"]},{"id":"beads-map-z5w.7","title":"Draw claimant avatar on canvas nodes in BeadsGraph paintNode","description":"## Draw claimant avatar on canvas nodes in BeadsGraph paintNode\n\n### What\nWhen a node has a claimant (from `claimedNodeAvatarsRef`), draw their profile picture as a small circular avatar at the bottom-right of the node on the canvas. If no avatar URL is available, draw a fallback circle with the first letter of the handle.\n\n### Avatar image cache\nCanvas `ctx.drawImage()` requires an `HTMLImageElement`. We need to pre-load avatar images and cache them. Use a **module-level cache** (outside the component, like the existing `_ForceGraph2DModule` pattern) to persist across re-renders:\n\n```typescript\n// Module-level avatar image cache\nconst avatarImageCache = new Map<string, HTMLImageElement | \"loading\" | \"failed\">();\n\nfunction getAvatarImage(url: string, onLoad: () => void): HTMLImageElement | null {\n const cached = avatarImageCache.get(url);\n if (cached === \"loading\" || cached === \"failed\") return null;\n if (cached) return cached;\n\n // Start loading\n avatarImageCache.set(url, \"loading\");\n const img = new Image();\n img.crossOrigin = \"anonymous\"; // Required for canvas drawImage with external URLs\n img.onload = () => {\n avatarImageCache.set(url, img);\n onLoad(); // Trigger a canvas redraw\n };\n img.onerror = () => {\n avatarImageCache.set(url, \"failed\");\n };\n img.src = url;\n return null;\n}\n```\n\nThe `onLoad` callback should call `refreshGraph(graphRef)` to trigger a canvas redraw once the image is loaded. We need `graphRef` accessible from the cache callback. Two approaches:\n\n**Approach A:** Store `graphRef` in a module-level variable that gets set on component mount. Ugly but simple.\n\n**Approach B:** Instead of `onLoad` callback, just call `refreshGraph` from within `paintNode` when cache state changes. But `paintNode` runs every frame anyway, so once the image loads and is in cache, the next frame will pick it up. The issue is that we need ONE extra redraw after the image loads to show it. Solution: in `getAvatarImage`, when image loads, set a module-level flag `avatarCacheDirty = true`. In `paintNode`, if `avatarCacheDirty`, call `refreshGraph` once and reset the flag. Actually this is complicated.\n\n**Approach C (recommended):** The simplest approach. In `paintNode`, just try to get the cached image. If not cached yet, start loading and draw fallback. On next `paintNode` call (which happens on every frame when force simulation is running, or on next user interaction), the image will be ready. For the case where simulation has settled (no movement), the image load triggers no redraw. Fix: attach `img.onload` that triggers `refreshGraph(graphRef)`. Since `graphRef` is available in the component scope, pass a `refreshFn` to the cache function.\n\nActually, the cleanest approach: keep the image cache at module level, but have `paintNode` call a helper that uses the graphRef from the component:\n\n```typescript\n// Inside the BeadsGraph component:\nconst avatarRefreshRef = useRef<() => void>(() => {});\nuseEffect(() => {\n avatarRefreshRef.current = () => refreshGraph(graphRef);\n}, []);\n```\n\nThen in paintNode:\n```typescript\nconst claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id);\nif (claimInfo && globalScale > 0.4) {\n const avatarSize = Math.min(8, Math.max(4, 10 / globalScale));\n const avatarX = node.x + animatedSize * 0.7;\n const avatarY = node.y + animatedSize * 0.7;\n\n ctx.save();\n ctx.globalAlpha = Math.min(opacity, 0.95);\n\n // Circular clipping path for avatar\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n\n if (claimInfo.avatar) {\n const img = getAvatarImage(claimInfo.avatar, () => avatarRefreshRef.current());\n if (img) {\n ctx.save();\n ctx.clip();\n ctx.drawImage(\n img,\n avatarX - avatarSize,\n avatarY - avatarSize,\n avatarSize * 2,\n avatarSize * 2\n );\n ctx.restore();\n } else {\n // Loading fallback — gray circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n } else {\n // No avatar URL — fallback circle with first letter\n drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale);\n }\n\n // White border ring around avatar for contrast\n ctx.beginPath();\n ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);\n ctx.strokeStyle = \"#ffffff\";\n ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);\n ctx.stroke();\n\n ctx.restore();\n}\n```\n\n### Fallback avatar drawing\n```typescript\nfunction drawAvatarFallback(\n ctx: CanvasRenderingContext2D,\n x: number, y: number, radius: number,\n handle: string, globalScale: number\n) {\n // Light gray circle\n ctx.beginPath();\n ctx.arc(x, y, radius, 0, Math.PI * 2);\n ctx.fillStyle = \"#e4e4e7\"; // zinc-200\n ctx.fill();\n\n // First letter of handle\n const letter = handle.replace(\"@\", \"\").charAt(0).toUpperCase();\n const fontSize = Math.min(7, Math.max(3, radius * 1.3));\n ctx.font = `600 ${fontSize}px \"Inter\", system-ui, sans-serif`;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillStyle = \"#71717a\"; // zinc-500\n ctx.fillText(letter, x, y + 0.3);\n}\n```\n\n### Placement: bottom-right of node\nThe existing comment badge is at top-right:\n```\nbadgeX = node.x + animatedSize * 0.75 // top-right\nbadgeY = node.y - animatedSize * 0.75 // top-right (negative Y = up)\n```\n\nFor the avatar at bottom-right:\n```\navatarX = node.x + animatedSize * 0.7 // right\navatarY = node.y + animatedSize * 0.7 // bottom (positive Y = down)\n```\n\n### Drawing order in paintNode\nAdd the avatar drawing AFTER the comment badge (line ~746), before the `ctx.restore()` at the end of paintNode. The order:\n1. ... existing drawing (body, ring, label, etc.) ...\n2. Comment count badge at top-right (lines 716-746) — existing\n3. **Claimant avatar at bottom-right** — NEW (lines ~748+)\n\n### `crossOrigin = \"anonymous\"` \nRequired because avatar URLs are from `cdn.bsky.app` (external domain). Without this, canvas becomes \"tainted\" and some operations may fail. The `crossOrigin = \"anonymous\"` on the Image element tells the browser to request the image with CORS headers. Bluesky CDN supports CORS.\n\n### Visibility threshold\nSame as comment badges: `globalScale > 0.4`. When zoomed out too far, avatars are invisible (too small to see anyway).\n\n### Files to edit\n- `components/BeadsGraph.tsx`:\n - Add module-level `avatarImageCache` and `getAvatarImage()` function\n - Add module-level `drawAvatarFallback()` function\n - Add `avatarRefreshRef` inside component\n - Add avatar drawing section in `paintNode` after comment badge\n - Destructure `claimedNodeAvatars` from props (already added in .6)\n\n### Acceptance criteria\n- Claimed nodes show a small circular avatar at bottom-right\n- Avatar loads asynchronously and appears after image loads\n- Fallback shows gray circle with first letter of handle when no avatar URL\n- Fallback shows while image is loading\n- Avatar has white border ring for contrast\n- Avatar only visible when zoomed in enough (globalScale > 0.4)\n- Avatar scales appropriately with zoom level\n- No canvas tainting errors (crossOrigin = \"anonymous\")\n- Multiple claimed nodes each show their respective claimant avatars\n- pnpm build passes","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:48:47.291793+13:00","updatedAt":"2026-02-11T09:54:17.463406+13:00","closedAt":"2026-02-11T09:54:17.463406+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.8"],"dependentIds":["beads-map-z5w","beads-map-z5w.6"]},{"id":"beads-map-z5w.8","title":"Build verify and push claim task feature","description":"## Build verify and push claim task feature\n\n### What\nFinal task: run pnpm build, fix any errors, close beads tasks, commit and push.\n\n### Commands\n```bash\nrm -rf .next && pnpm build\nbd close beads-map-z5w.5\nbd close beads-map-z5w.6\nbd close beads-map-z5w.7\nbd close beads-map-z5w.8\nbd close beads-map-z5w\nbd sync\ngit add -A\ngit commit -m \"Add claim task feature: right-click to claim with avatar on node (beads-map-z5w.5-8)\"\ngit push\n```\n\n### Edge cases to verify\n1. **Not authenticated** → \"Claim task\" not in context menu\n2. **Already claimed by someone** → \"Claim task\" not in context menu\n3. **Claim with avatar** → small circular avatar appears at bottom-right of node\n4. **Claim without avatar (no profile pic)** → gray circle with first letter of handle\n5. **Multiple nodes claimed by different users** → each shows correct avatar\n6. **Zoom out far** → avatars disappear (globalScale < 0.4)\n7. **Avatar image fails to load** → fallback circle shown\n8. **Timeline replay** → claimed avatars still show on visible nodes\n9. **Comment badges + avatar** → both visible (top-right badge, bottom-right avatar, no overlap)\n\n### Stale .next cache\nIf module resolution errors occur:\n```bash\nrm -rf .next && pnpm build\n```\n\n### Acceptance criteria\n- pnpm build passes with zero errors\n- git status clean after push\n- All subtasks (.5, .6, .7, .8) and epic closed in beads","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T09:48:59.026151+13:00","updatedAt":"2026-02-11T09:54:17.602817+13:00","closedAt":"2026-02-11T09:54:17.602817+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.9"],"dependentIds":["beads-map-z5w","beads-map-z5w.7"]},{"id":"beads-map-z5w.9","title":"Fix claim avatar loading: optimistic display, Bluesky API fallback, CORS fix","description":"## Fix claim avatar loading\n\n### Problem\nAfter implementing the claim feature (.5–.8), two bugs were found:\n1. **Avatar only appeared after page refresh**, not immediately after claiming — the Hypergoat indexer has latency before it indexes new comments, so `refetchComments()` returned stale data.\n2. **Avatar image never loaded (only fallback letter \"S\" shown)** — two causes:\n a. `session.avatar` was undefined (OAuth profile fetch failed silently during login)\n b. `crossOrigin = \"anonymous\"` on the HTMLImageElement caused CORS rejection from Bluesky CDN (`cdn.bsky.app`), triggering `onerror` and permanently caching the image as \"failed\"\n\n### Fixes applied\n\n#### 1. Optimistic claim display (`app/page.tsx`)\n- Added `optimisticClaims` state: `Map<string, { avatar?: string; handle: string }>`\n- `handleClaimTask` immediately sets the claimant avatar in `optimisticClaims` before posting the comment\n- `claimedNodeAvatars` useMemo merges optimistic claims (priority) with comment-derived claims\n- `isNodeClaimed` check in ContextMenu now uses `claimedNodeAvatars.has()` instead of `isNodeClaimed(commentsByNode)` so \"Claim task\" button hides immediately\n- Added 3-second delayed `refetchComments()` after claiming to eventually pick up the indexed comment\n\n#### 2. Bluesky public API avatar fallback (`app/page.tsx`)\n- In `handleClaimTask`, if `session.avatar` is undefined, fetches avatar from `public.api.bsky.app/xrpc/app.bsky.actor.getProfile` using `session.did`\n- This is the same API that `useBeadsComments` uses for profile resolution\n\n#### 3. Removed crossOrigin restriction (`components/BeadsGraph.tsx`)\n- Removed `img.crossOrigin = \"anonymous\"` from `getAvatarImage()` function\n- Canvas `drawImage()` works without CORS — canvas becomes \"tainted\" (cant read pixels back) but we never need `getImageData`/`toDataURL`\n- This fixed the Bluesky CDN image loading failure\n\n### Commits\n- `877b037` Fix claim: optimistic avatar display + delayed refetch for indexer latency\n- `efd6275` Fix claim avatar: remove crossOrigin restriction, fetch avatar from Bluesky public API as fallback\n\n### Files changed\n- `app/page.tsx` — optimisticClaims state, handleClaimTask with API fallback, claimedNodeAvatars merge, isNodeClaimed check\n- `components/BeadsGraph.tsx` — removed crossOrigin from Image element\n\n### Status: DONE","status":"closed","priority":1,"issueType":"task","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-11T10:25:55.858566+13:00","updatedAt":"2026-02-11T10:26:45.538096+13:00","closedAt":"2026-02-11T10:26:45.538096+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":1,"dependentCount":2,"blockerIds":["beads-map-z5w.10"],"dependentIds":["beads-map-z5w","beads-map-z5w.8"]},{"id":"beads-map-zr4","title":"Show/hide hierarchical cluster labels toggle","description":"## What (retroactive — already done)\n\nAdded a toggle button in the top-left controls to show/hide the hierarchical cluster circles and labels that appear when zoomed out. Clusters represent epic parent nodes and their children, rendered as dashed circles with titles at centroids.\n\n## Commit\n- 6cfc26c — Add toggle to show/hide hierarchical cluster labels when zoomed out\n\n## Changes\n\n### components/BeadsGraph.tsx\n\n1. **New state** (line ~278): \\`const [showClusters, setShowClusters] = useState(true)\\`\n - Defaults to true (existing behavior preserved)\n\n2. **Guard in paintClusterLabels** (line ~1333): Added \\`if (!showClusters) return;\\` at the top of the callback, before any computation. Added \\`showClusters\\` to the dependency array.\n\n3. **Toggle button** in top-left controls (after Collapse/Expand button):\n - Emerald-500 background when active (clusters visible), white/zinc when inactive\n - Dashed circle + text lines SVG icon representing the cluster overlay\n - Label: \"Clusters\" (hidden on mobile, visible on sm+)\n - Title tooltip: \"Hide cluster labels\" / \"Show cluster labels\"\n - Same style as the collapse/expand button (rounded-lg, border, shadow-sm, backdrop-blur)\n\n## UI placement\n```\n[Force][DAG][Radial][Cluster][Spread] [Collapse all] [Clusters]\n```\nThe Clusters button is the rightmost in the top-left control row.\n\n## Behavior\n- **ON (default)**: Cluster circles fade in when zoomed out past globalScale 0.8, fully visible below 0.4. Shows dashed prefix-colored circle, epic title, epic ID, and member count.\n- **OFF**: No cluster rendering at any zoom level. Saves rendering cost on large graphs.","status":"closed","priority":2,"issueType":"feature","owner":"david@gainforest.net","createdBy":"daviddao","createdAt":"2026-02-12T14:22:32.483636+13:00","updatedAt":"2026-02-12T14:22:50.485493+13:00","closedAt":"2026-02-12T14:22:50.485493+13:00","closeReason":"Closed","prefix":"beads-map","blockerCount":0,"dependentCount":0,"blockerIds":[],"dependentIds":[]}],"links":[{"source":"beads-map-3jy","target":"beads-map-21c","type":"blocks","createdAt":"2026-02-12T10:39:55.244292+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.1","type":"parent-child","createdAt":"2026-02-11T01:47:27.389228+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.10","type":"parent-child","createdAt":"2026-02-11T02:07:34.243147+13:00"},{"source":"beads-map-21c.9","target":"beads-map-21c.10","type":"blocks","createdAt":"2026-02-11T02:07:38.658953+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.11","type":"parent-child","createdAt":"2026-02-11T02:12:07.010331+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.12","type":"parent-child","createdAt":"2026-02-11T02:12:14.930729+13:00"},{"source":"beads-map-21c.11","target":"beads-map-21c.12","type":"blocks","createdAt":"2026-02-11T02:12:15.066268+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.2","type":"parent-child","createdAt":"2026-02-11T01:47:41.591486+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.3","type":"parent-child","createdAt":"2026-02-11T01:48:09.027391+13:00"},{"source":"beads-map-21c.2","target":"beads-map-21c.3","type":"blocks","createdAt":"2026-02-11T01:51:32.440174+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.4","type":"parent-child","createdAt":"2026-02-11T01:48:40.961908+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.5","type":"parent-child","createdAt":"2026-02-11T01:49:28.830802+13:00"},{"source":"beads-map-21c.2","target":"beads-map-21c.5","type":"blocks","createdAt":"2026-02-11T01:51:32.557476+13:00"},{"source":"beads-map-21c.3","target":"beads-map-21c.5","type":"blocks","createdAt":"2026-02-11T01:51:32.669716+13:00"},{"source":"beads-map-21c.4","target":"beads-map-21c.5","type":"blocks","createdAt":"2026-02-11T01:51:32.780421+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.6","type":"parent-child","createdAt":"2026-02-11T01:49:44.216474+13:00"},{"source":"beads-map-21c.5","target":"beads-map-21c.6","type":"blocks","createdAt":"2026-02-11T01:51:32.89299+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.7","type":"parent-child","createdAt":"2026-02-11T02:00:49.847169+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.8","type":"parent-child","createdAt":"2026-02-11T02:00:58.652019+13:00"},{"source":"beads-map-21c.7","target":"beads-map-21c.8","type":"blocks","createdAt":"2026-02-11T02:01:02.543151+13:00"},{"source":"beads-map-21c","target":"beads-map-21c.9","type":"parent-child","createdAt":"2026-02-11T02:07:25.358814+13:00"},{"source":"beads-map-3jy","target":"beads-map-2fk","type":"parent-child","createdAt":"2026-02-10T23:19:22.39819+13:00"},{"source":"beads-map-gjo","target":"beads-map-2fk","type":"blocks","createdAt":"2026-02-10T23:19:28.995145+13:00"},{"source":"beads-map-3jy","target":"beads-map-2qg","type":"parent-child","createdAt":"2026-02-10T23:19:22.706041+13:00"},{"source":"beads-map-mq9","target":"beads-map-2qg","type":"blocks","createdAt":"2026-02-10T23:19:29.394542+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.1","type":"parent-child","createdAt":"2026-02-12T16:08:05.404061+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.2","type":"parent-child","createdAt":"2026-02-12T16:08:31.187092+13:00"},{"source":"beads-map-3pg.1","target":"beads-map-3pg.2","type":"blocks","createdAt":"2026-02-12T16:08:31.188462+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.3","type":"parent-child","createdAt":"2026-02-12T16:08:40.196547+13:00"},{"source":"beads-map-3pg.1","target":"beads-map-3pg.3","type":"blocks","createdAt":"2026-02-12T16:08:40.197777+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.4","type":"parent-child","createdAt":"2026-02-12T16:09:08.246591+13:00"},{"source":"beads-map-3pg.3","target":"beads-map-3pg.4","type":"blocks","createdAt":"2026-02-12T16:09:08.248185+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.5","type":"parent-child","createdAt":"2026-02-12T16:09:17.523491+13:00"},{"source":"beads-map-3pg.2","target":"beads-map-3pg.5","type":"blocks","createdAt":"2026-02-12T16:09:17.525027+13:00"},{"source":"beads-map-3pg.4","target":"beads-map-3pg.5","type":"blocks","createdAt":"2026-02-12T16:09:17.527425+13:00"},{"source":"beads-map-3pg.7","target":"beads-map-3pg.5","type":"blocks","createdAt":"2026-02-12T16:15:02.933093+13:00"},{"source":"beads-map-3pg.8","target":"beads-map-3pg.5","type":"blocks","createdAt":"2026-02-12T16:15:03.067512+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.6","type":"parent-child","createdAt":"2026-02-12T16:13:04.418858+13:00"},{"source":"beads-map-3pg.4","target":"beads-map-3pg.6","type":"blocks","createdAt":"2026-02-12T16:13:04.420528+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.7","type":"parent-child","createdAt":"2026-02-12T16:14:39.402553+13:00"},{"source":"beads-map-3pg.1","target":"beads-map-3pg.7","type":"blocks","createdAt":"2026-02-12T16:14:39.40454+13:00"},{"source":"beads-map-3pg.3","target":"beads-map-3pg.7","type":"blocks","createdAt":"2026-02-12T16:14:39.406102+13:00"},{"source":"beads-map-3pg","target":"beads-map-3pg.8","type":"parent-child","createdAt":"2026-02-12T16:14:58.679502+13:00"},{"source":"beads-map-3pg.7","target":"beads-map-3pg.8","type":"blocks","createdAt":"2026-02-12T16:14:58.681794+13:00"},{"source":"beads-map-3jy","target":"beads-map-7j2","type":"parent-child","createdAt":"2026-02-10T23:19:22.316735+13:00"},{"source":"beads-map-m1o","target":"beads-map-7j2","type":"blocks","createdAt":"2026-02-10T23:19:28.909987+13:00"},{"source":"beads-map-vdg","target":"beads-map-7r6","type":"blocks","createdAt":"2026-02-12T10:39:55.410329+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.1","type":"parent-child","createdAt":"2026-02-11T11:54:21.795118+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.2","type":"parent-child","createdAt":"2026-02-11T11:54:21.923002+13:00"},{"source":"beads-map-7r6.1","target":"beads-map-7r6.2","type":"blocks","createdAt":"2026-02-11T12:12:24.073985+13:00"},{"source":"beads-map-7r6.7","target":"beads-map-7r6.2","type":"blocks","createdAt":"2026-02-11T12:12:27.830152+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.3","type":"parent-child","createdAt":"2026-02-11T11:54:22.048183+13:00"},{"source":"beads-map-7r6.1","target":"beads-map-7r6.3","type":"blocks","createdAt":"2026-02-11T12:12:12.799635+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.4","type":"parent-child","createdAt":"2026-02-11T11:54:22.174711+13:00"},{"source":"beads-map-7r6.3","target":"beads-map-7r6.4","type":"blocks","createdAt":"2026-02-11T12:12:16.524399+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.5","type":"parent-child","createdAt":"2026-02-11T11:54:22.303116+13:00"},{"source":"beads-map-7r6.3","target":"beads-map-7r6.5","type":"blocks","createdAt":"2026-02-11T12:12:20.162124+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.6","type":"parent-child","createdAt":"2026-02-11T11:54:22.428287+13:00"},{"source":"beads-map-7r6.2","target":"beads-map-7r6.6","type":"blocks","createdAt":"2026-02-11T12:12:31.588158+13:00"},{"source":"beads-map-7r6.4","target":"beads-map-7r6.6","type":"blocks","createdAt":"2026-02-11T12:12:35.542205+13:00"},{"source":"beads-map-7r6.5","target":"beads-map-7r6.6","type":"blocks","createdAt":"2026-02-11T12:12:39.650845+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.7","type":"parent-child","createdAt":"2026-02-11T11:54:22.552867+13:00"},{"source":"beads-map-7r6.1","target":"beads-map-7r6.7","type":"blocks","createdAt":"2026-02-11T12:12:09.2907+13:00"},{"source":"beads-map-7r6","target":"beads-map-7r6.8","type":"parent-child","createdAt":"2026-02-11T11:54:22.675891+13:00"},{"source":"beads-map-7r6.6","target":"beads-map-7r6.8","type":"blocks","createdAt":"2026-02-11T12:12:44.251892+13:00"},{"source":"beads-map-9d3","target":"beads-map-8np","type":"blocks","createdAt":"2026-02-12T10:39:55.489578+13:00"},{"source":"beads-map-8np","target":"beads-map-8np.1","type":"parent-child","createdAt":"2026-02-12T10:33:56.34421+13:00"},{"source":"beads-map-8np","target":"beads-map-8np.2","type":"parent-child","createdAt":"2026-02-12T10:34:01.699953+13:00"},{"source":"beads-map-8np.1","target":"beads-map-8np.2","type":"blocks","createdAt":"2026-02-12T10:34:12.820355+13:00"},{"source":"beads-map-8np","target":"beads-map-8np.3","type":"parent-child","createdAt":"2026-02-12T10:34:07.490637+13:00"},{"source":"beads-map-8np.1","target":"beads-map-8np.3","type":"blocks","createdAt":"2026-02-12T10:34:12.951842+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.1","type":"parent-child","createdAt":"2026-02-12T15:12:32.786117+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.10","type":"parent-child","createdAt":"2026-02-12T15:15:12.207348+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.2","type":"parent-child","createdAt":"2026-02-12T15:12:51.584816+13:00"},{"source":"beads-map-8tp.1","target":"beads-map-8tp.2","type":"blocks","createdAt":"2026-02-12T15:12:51.586097+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.3","type":"parent-child","createdAt":"2026-02-12T15:13:08.950526+13:00"},{"source":"beads-map-8tp.2","target":"beads-map-8tp.3","type":"blocks","createdAt":"2026-02-12T15:13:08.951859+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.4","type":"parent-child","createdAt":"2026-02-12T15:13:26.55342+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.5","type":"parent-child","createdAt":"2026-02-12T15:13:41.075186+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.6","type":"parent-child","createdAt":"2026-02-12T15:13:55.422504+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.7","type":"parent-child","createdAt":"2026-02-12T15:14:12.523784+13:00"},{"source":"beads-map-8tp.5","target":"beads-map-8tp.7","type":"blocks","createdAt":"2026-02-12T15:14:12.525112+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.8","type":"parent-child","createdAt":"2026-02-12T15:14:28.79067+13:00"},{"source":"beads-map-8tp.1","target":"beads-map-8tp.8","type":"blocks","createdAt":"2026-02-12T15:14:28.792079+13:00"},{"source":"beads-map-8tp","target":"beads-map-8tp.9","type":"parent-child","createdAt":"2026-02-12T15:14:47.216998+13:00"},{"source":"beads-map-8tp.1","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.218878+13:00"},{"source":"beads-map-8tp.2","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.220248+13:00"},{"source":"beads-map-8tp.3","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.221773+13:00"},{"source":"beads-map-8tp.4","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.223045+13:00"},{"source":"beads-map-8tp.5","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.224557+13:00"},{"source":"beads-map-8tp.6","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.225986+13:00"},{"source":"beads-map-8tp.7","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.227396+13:00"},{"source":"beads-map-8tp.8","target":"beads-map-8tp.9","type":"blocks","createdAt":"2026-02-12T15:14:47.228629+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.1","type":"parent-child","createdAt":"2026-02-12T10:50:17.43527+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.2","type":"parent-child","createdAt":"2026-02-12T10:50:27.399923+13:00"},{"source":"beads-map-8z1.1","target":"beads-map-8z1.2","type":"blocks","createdAt":"2026-02-12T10:50:56.363125+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.3","type":"parent-child","createdAt":"2026-02-12T10:50:36.643125+13:00"},{"source":"beads-map-8z1","target":"beads-map-8z1.4","type":"parent-child","createdAt":"2026-02-12T10:50:48.970023+13:00"},{"source":"beads-map-8z1.1","target":"beads-map-8z1.4","type":"blocks","createdAt":"2026-02-12T10:50:56.478692+13:00"},{"source":"beads-map-8z1.2","target":"beads-map-8z1.4","type":"blocks","createdAt":"2026-02-12T10:50:56.600112+13:00"},{"source":"beads-map-8z1.3","target":"beads-map-8z1.4","type":"blocks","createdAt":"2026-02-12T10:50:56.718812+13:00"},{"source":"beads-map-9d3","target":"beads-map-9d3.2","type":"parent-child","createdAt":"2026-02-12T10:26:40.269874+13:00"},{"source":"beads-map-9d3","target":"beads-map-9d3.3","type":"parent-child","createdAt":"2026-02-12T10:26:46.062066+13:00"},{"source":"beads-map-9d3","target":"beads-map-9d3.4","type":"parent-child","createdAt":"2026-02-12T10:26:51.725102+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.1","type":"parent-child","createdAt":"2026-02-12T11:23:50.371471+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.3","type":"parent-child","createdAt":"2026-02-12T11:24:08.11827+13:00"},{"source":"beads-map-9lm.1","target":"beads-map-9lm.3","type":"blocks","createdAt":"2026-02-12T11:24:35.58504+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.4","type":"parent-child","createdAt":"2026-02-12T11:24:14.898283+13:00"},{"source":"beads-map-9lm.1","target":"beads-map-9lm.4","type":"blocks","createdAt":"2026-02-12T11:24:35.764197+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.5","type":"parent-child","createdAt":"2026-02-12T11:24:20.513717+13:00"},{"source":"beads-map-9lm.1","target":"beads-map-9lm.5","type":"blocks","createdAt":"2026-02-12T11:24:35.949509+13:00"},{"source":"beads-map-9lm","target":"beads-map-9lm.6","type":"parent-child","createdAt":"2026-02-12T11:24:29.192269+13:00"},{"source":"beads-map-9lm.3","target":"beads-map-9lm.6","type":"blocks","createdAt":"2026-02-12T11:24:36.145483+13:00"},{"source":"beads-map-9lm.4","target":"beads-map-9lm.6","type":"blocks","createdAt":"2026-02-12T11:24:36.312505+13:00"},{"source":"beads-map-9lm.5","target":"beads-map-9lm.6","type":"blocks","createdAt":"2026-02-12T11:24:36.484971+13:00"},{"source":"beads-map-a2e","target":"beads-map-a2e.1","type":"parent-child","createdAt":"2026-02-12T17:15:45.738001+13:00"},{"source":"beads-map-a2e","target":"beads-map-a2e.2","type":"parent-child","createdAt":"2026-02-12T17:15:54.279327+13:00"},{"source":"beads-map-a2e","target":"beads-map-a2e.3","type":"parent-child","createdAt":"2026-02-12T17:16:00.098891+13:00"},{"source":"beads-map-a2e","target":"beads-map-a2e.4","type":"parent-child","createdAt":"2026-02-12T17:16:11.444833+13:00"},{"source":"beads-map-3jy","target":"beads-map-cvh","type":"blocks","createdAt":"2026-02-12T10:39:55.001081+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.1","type":"parent-child","createdAt":"2026-02-10T23:56:38.694406+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.2","type":"parent-child","createdAt":"2026-02-10T23:57:01.112211+13:00"},{"source":"beads-map-cvh.1","target":"beads-map-cvh.2","type":"blocks","createdAt":"2026-02-10T23:57:01.113311+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.3","type":"parent-child","createdAt":"2026-02-10T23:57:16.26232+13:00"},{"source":"beads-map-cvh.2","target":"beads-map-cvh.3","type":"blocks","createdAt":"2026-02-10T23:57:16.263416+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.4","type":"parent-child","createdAt":"2026-02-10T23:57:32.924539+13:00"},{"source":"beads-map-cvh.3","target":"beads-map-cvh.4","type":"blocks","createdAt":"2026-02-10T23:57:32.926286+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.5","type":"parent-child","createdAt":"2026-02-10T23:57:56.263692+13:00"},{"source":"beads-map-cvh.4","target":"beads-map-cvh.5","type":"blocks","createdAt":"2026-02-10T23:57:56.264726+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.6","type":"parent-child","createdAt":"2026-02-10T23:58:15.699689+13:00"},{"source":"beads-map-cvh.5","target":"beads-map-cvh.6","type":"blocks","createdAt":"2026-02-10T23:58:15.700911+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.7","type":"parent-child","createdAt":"2026-02-10T23:58:28.65065+13:00"},{"source":"beads-map-cvh.3","target":"beads-map-cvh.7","type":"blocks","createdAt":"2026-02-10T23:58:28.65195+13:00"},{"source":"beads-map-cvh","target":"beads-map-cvh.8","type":"parent-child","createdAt":"2026-02-10T23:58:49.015822+13:00"},{"source":"beads-map-cvh.6","target":"beads-map-cvh.8","type":"blocks","createdAt":"2026-02-10T23:58:49.016931+13:00"},{"source":"beads-map-cvh.7","target":"beads-map-cvh.8","type":"blocks","createdAt":"2026-02-10T23:58:49.017826+13:00"},{"source":"beads-map-dwk","target":"beads-map-dwk.1","type":"parent-child","createdAt":"2026-02-12T13:58:57.259359+13:00"},{"source":"beads-map-dwk","target":"beads-map-dwk.2","type":"parent-child","createdAt":"2026-02-12T13:59:08.218477+13:00"},{"source":"beads-map-dwk.1","target":"beads-map-dwk.2","type":"blocks","createdAt":"2026-02-12T13:59:08.220794+13:00"},{"source":"beads-map-dwk","target":"beads-map-dwk.3","type":"parent-child","createdAt":"2026-02-12T13:59:56.526648+13:00"},{"source":"beads-map-dwk.1","target":"beads-map-dwk.3","type":"blocks","createdAt":"2026-02-12T13:59:56.528522+13:00"},{"source":"beads-map-dwk.2","target":"beads-map-dwk.3","type":"blocks","createdAt":"2026-02-12T13:59:56.530415+13:00"},{"source":"beads-map-dwk","target":"beads-map-dwk.4","type":"parent-child","createdAt":"2026-02-12T14:00:09.763561+13:00"},{"source":"beads-map-dwk.3","target":"beads-map-dwk.4","type":"blocks","createdAt":"2026-02-12T14:00:09.76645+13:00"},{"source":"beads-map-cvh","target":"beads-map-dyi","type":"blocks","createdAt":"2026-02-12T10:39:55.083326+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.1","type":"parent-child","createdAt":"2026-02-11T00:31:20.161533+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.2","type":"parent-child","createdAt":"2026-02-11T00:31:28.754207+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.3","type":"parent-child","createdAt":"2026-02-11T00:31:39.227376+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.4","type":"parent-child","createdAt":"2026-02-11T00:31:47.745514+13:00"},{"source":"beads-map-dyi.2","target":"beads-map-dyi.4","type":"blocks","createdAt":"2026-02-11T00:38:43.253835+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.5","type":"parent-child","createdAt":"2026-02-11T00:31:54.778714+13:00"},{"source":"beads-map-dyi.2","target":"beads-map-dyi.5","type":"blocks","createdAt":"2026-02-11T00:38:43.395175+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.6","type":"parent-child","createdAt":"2026-02-11T00:32:01.725925+13:00"},{"source":"beads-map-dyi.1","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.522633+13:00"},{"source":"beads-map-dyi.2","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.647344+13:00"},{"source":"beads-map-dyi.3","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.773371+13:00"},{"source":"beads-map-dyi.4","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:43.895718+13:00"},{"source":"beads-map-dyi.5","target":"beads-map-dyi.6","type":"blocks","createdAt":"2026-02-11T00:38:44.013093+13:00"},{"source":"beads-map-dyi","target":"beads-map-dyi.7","type":"parent-child","createdAt":"2026-02-11T00:45:37.232842+13:00"},{"source":"beads-map-3jy","target":"beads-map-ecl","type":"parent-child","createdAt":"2026-02-10T23:19:22.476318+13:00"},{"source":"beads-map-7j2","target":"beads-map-ecl","type":"blocks","createdAt":"2026-02-10T23:19:29.07598+13:00"},{"source":"beads-map-2fk","target":"beads-map-ecl","type":"blocks","createdAt":"2026-02-10T23:19:29.155362+13:00"},{"source":"beads-map-2u2","target":"beads-map-f8f","type":"blocks","createdAt":"2026-02-12T14:22:17.912819+13:00"},{"source":"beads-map-3jy","target":"beads-map-gjo","type":"parent-child","createdAt":"2026-02-10T23:19:22.148777+13:00"},{"source":"beads-map-3jy","target":"beads-map-iyn","type":"parent-child","createdAt":"2026-02-10T23:19:22.553429+13:00"},{"source":"beads-map-ecl","target":"beads-map-iyn","type":"blocks","createdAt":"2026-02-10T23:19:29.234083+13:00"},{"source":"beads-map-3jy","target":"beads-map-m1o","type":"parent-child","createdAt":"2026-02-10T23:19:22.23277+13:00"},{"source":"beads-map-gjo","target":"beads-map-m1o","type":"blocks","createdAt":"2026-02-10T23:19:28.823723+13:00"},{"source":"beads-map-8np","target":"beads-map-mfw","type":"blocks","createdAt":"2026-02-12T10:39:55.570556+13:00"},{"source":"beads-map-vdg","target":"beads-map-mfw","type":"blocks","createdAt":"2026-02-12T10:39:55.652022+13:00"},{"source":"beads-map-mfw","target":"beads-map-mfw.1","type":"parent-child","createdAt":"2026-02-12T10:38:08.455822+13:00"},{"source":"beads-map-3jy","target":"beads-map-mq9","type":"parent-child","createdAt":"2026-02-10T23:19:22.630363+13:00"},{"source":"beads-map-iyn","target":"beads-map-mq9","type":"blocks","createdAt":"2026-02-10T23:19:29.312556+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.1","type":"parent-child","createdAt":"2026-02-12T15:25:11.058045+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.10","type":"parent-child","createdAt":"2026-02-12T15:51:17.69846+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.2","type":"parent-child","createdAt":"2026-02-12T15:25:28.384637+13:00"},{"source":"beads-map-s0c.1","target":"beads-map-s0c.2","type":"blocks","createdAt":"2026-02-12T15:25:28.392+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.3","type":"parent-child","createdAt":"2026-02-12T15:26:04.162359+13:00"},{"source":"beads-map-s0c.2","target":"beads-map-s0c.3","type":"blocks","createdAt":"2026-02-12T15:26:04.163786+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.4","type":"parent-child","createdAt":"2026-02-12T15:26:35.755265+13:00"},{"source":"beads-map-s0c.3","target":"beads-map-s0c.4","type":"blocks","createdAt":"2026-02-12T15:26:35.756906+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.5","type":"parent-child","createdAt":"2026-02-12T15:26:57.640666+13:00"},{"source":"beads-map-s0c.3","target":"beads-map-s0c.5","type":"blocks","createdAt":"2026-02-12T15:26:57.642105+13:00"},{"source":"beads-map-s0c.4","target":"beads-map-s0c.5","type":"blocks","createdAt":"2026-02-12T15:26:57.643209+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.6","type":"parent-child","createdAt":"2026-02-12T15:27:10.156391+13:00"},{"source":"beads-map-s0c.5","target":"beads-map-s0c.6","type":"blocks","createdAt":"2026-02-12T15:27:10.157664+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.7","type":"parent-child","createdAt":"2026-02-12T15:50:23.533357+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.8","type":"parent-child","createdAt":"2026-02-12T15:50:44.2796+13:00"},{"source":"beads-map-s0c","target":"beads-map-s0c.9","type":"parent-child","createdAt":"2026-02-12T15:51:01.379692+13:00"},{"source":"beads-map-dyi","target":"beads-map-vdg","type":"blocks","createdAt":"2026-02-11T01:26:33.09446+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.1","type":"parent-child","createdAt":"2026-02-11T01:24:33.395429+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.2","type":"parent-child","createdAt":"2026-02-11T01:24:55.518315+13:00"},{"source":"beads-map-vdg.1","target":"beads-map-vdg.2","type":"blocks","createdAt":"2026-02-11T01:26:28.248408+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.3","type":"parent-child","createdAt":"2026-02-11T01:25:16.083107+13:00"},{"source":"beads-map-vdg.1","target":"beads-map-vdg.3","type":"blocks","createdAt":"2026-02-11T01:26:28.371142+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.4","type":"parent-child","createdAt":"2026-02-11T01:25:35.924466+13:00"},{"source":"beads-map-vdg.1","target":"beads-map-vdg.4","type":"blocks","createdAt":"2026-02-11T01:26:28.487447+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.5","type":"parent-child","createdAt":"2026-02-11T01:26:04.1688+13:00"},{"source":"beads-map-vdg.2","target":"beads-map-vdg.5","type":"blocks","createdAt":"2026-02-11T01:26:28.611742+13:00"},{"source":"beads-map-vdg.3","target":"beads-map-vdg.5","type":"blocks","createdAt":"2026-02-11T01:26:28.725946+13:00"},{"source":"beads-map-vdg.4","target":"beads-map-vdg.5","type":"blocks","createdAt":"2026-02-11T01:26:28.84169+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.6","type":"parent-child","createdAt":"2026-02-11T01:26:12.402978+13:00"},{"source":"beads-map-vdg.5","target":"beads-map-vdg.6","type":"blocks","createdAt":"2026-02-11T01:26:28.956024+13:00"},{"source":"beads-map-vdg","target":"beads-map-vdg.7","type":"parent-child","createdAt":"2026-02-11T01:36:49.289175+13:00"},{"source":"beads-map-vdg","target":"beads-map-z5w","type":"blocks","createdAt":"2026-02-12T10:39:55.328556+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.1","type":"parent-child","createdAt":"2026-02-11T09:19:10.936853+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.10","type":"parent-child","createdAt":"2026-02-11T10:26:08.914469+13:00"},{"source":"beads-map-z5w.9","target":"beads-map-z5w.10","type":"blocks","createdAt":"2026-02-11T10:26:08.915791+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.11","type":"parent-child","createdAt":"2026-02-11T10:26:37.013442+13:00"},{"source":"beads-map-z5w.10","target":"beads-map-z5w.11","type":"blocks","createdAt":"2026-02-11T10:26:37.015186+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.12","type":"parent-child","createdAt":"2026-02-11T10:47:43.377971+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.2","type":"parent-child","createdAt":"2026-02-11T09:19:41.234513+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.3","type":"parent-child","createdAt":"2026-02-11T09:20:21.370692+13:00"},{"source":"beads-map-z5w.1","target":"beads-map-z5w.3","type":"blocks","createdAt":"2026-02-11T09:20:21.372378+13:00"},{"source":"beads-map-z5w.2","target":"beads-map-z5w.3","type":"blocks","createdAt":"2026-02-11T09:20:21.374047+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.4","type":"parent-child","createdAt":"2026-02-11T09:20:31.852407+13:00"},{"source":"beads-map-z5w.3","target":"beads-map-z5w.4","type":"blocks","createdAt":"2026-02-11T09:20:31.854127+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.5","type":"parent-child","createdAt":"2026-02-11T09:47:43.133427+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.6","type":"parent-child","createdAt":"2026-02-11T09:48:05.022796+13:00"},{"source":"beads-map-z5w.5","target":"beads-map-z5w.6","type":"blocks","createdAt":"2026-02-11T09:48:05.023797+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.7","type":"parent-child","createdAt":"2026-02-11T09:48:47.292966+13:00"},{"source":"beads-map-z5w.6","target":"beads-map-z5w.7","type":"blocks","createdAt":"2026-02-11T09:48:47.301709+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.8","type":"parent-child","createdAt":"2026-02-11T09:48:59.028136+13:00"},{"source":"beads-map-z5w.7","target":"beads-map-z5w.8","type":"blocks","createdAt":"2026-02-11T09:48:59.029382+13:00"},{"source":"beads-map-z5w","target":"beads-map-z5w.9","type":"parent-child","createdAt":"2026-02-11T10:25:55.859836+13:00"},{"source":"beads-map-z5w.8","target":"beads-map-z5w.9","type":"blocks","createdAt":"2026-02-11T10:25:55.860815+13:00"}]},"stats":{"total":137,"open":0,"inProgress":0,"blocked":0,"closed":137,"actionable":0,"edges":226,"prefixes":["beads-map"]}}