beads-map 0.2.0 → 0.2.2

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 (39) 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 +6 -6
  17. package/.next/server/pages/404.html +1 -1
  18. package/.next/server/pages/500.html +1 -1
  19. package/.next/server/pages-manifest.json +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-13ee27a84e4a0c70.js +1 -0
  22. package/.next/static/css/dbf588b653aa4019.css +3 -0
  23. package/app/page.tsx +118 -6
  24. package/bin/beads-map.mjs +32 -0
  25. package/components/ActivityItem.tsx +326 -0
  26. package/components/ActivityOverlay.tsx +123 -0
  27. package/components/ActivityPanel.tsx +345 -0
  28. package/components/AllCommentsPanel.tsx +9 -4
  29. package/components/AuthButton.tsx +7 -2
  30. package/components/BeadsGraph.tsx +11 -5
  31. package/components/CommentTooltip.tsx +7 -2
  32. package/components/NodeDetail.tsx +14 -4
  33. package/lib/activity.ts +377 -0
  34. package/lib/diff-beads.ts +3 -0
  35. package/package.json +1 -1
  36. package/.next/static/chunks/app/page-f36cdcae49f1d2af.js +0 -1
  37. package/.next/static/css/a4e34aaaa51183d9.css +0 -3
  38. /package/.next/static/{YVdbDxCehgqcYmLncYRFB → dxp53pVl-eTmydUx_hpyJ}/_buildManifest.js +0 -0
  39. /package/.next/static/{YVdbDxCehgqcYmLncYRFB → dxp53pVl-eTmydUx_hpyJ}/_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"},{"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-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"},{"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"},{"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-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"},{"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.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-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.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-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.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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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","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","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","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","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","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","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","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":8,"dependentCount":0,"blockerIds":["beads-map-2fk","beads-map-2qg","beads-map-7j2","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","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","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","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-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","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":8,"dependentCount":0,"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"],"dependentIds":[]},{"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","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","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","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","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","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","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","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","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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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","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","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","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-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","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","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":7,"dependentCount":1,"blockerIds":["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"],"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","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","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","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","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","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","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","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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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","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","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","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","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-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-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-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-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-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":62,"open":0,"inProgress":0,"blocked":0,"closed":62,"actionable":0,"edges":102,"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"},{"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"},{"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-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"},{"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"},{"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-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"},{"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.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.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-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.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-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.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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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","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","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","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","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","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","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","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":8,"dependentCount":0,"blockerIds":["beads-map-2fk","beads-map-2qg","beads-map-7j2","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","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","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","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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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-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","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":8,"dependentCount":0,"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"],"dependentIds":[]},{"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","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","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","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","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","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","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","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","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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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","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","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","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-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","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","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":7,"dependentCount":1,"blockerIds":["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"],"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","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","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","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","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","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","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","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","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":0,"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":[]},{"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","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","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","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","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","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","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","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","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","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","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","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","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-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-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-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-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-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-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":71,"open":0,"inProgress":0,"blocked":0,"closed":71,"actionable":0,"edges":120,"prefixes":["beads-map"]}}