beads-map 0.1.0

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 (142) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +27 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +10 -0
  20. package/.next/server/app/api/beads/route.js +8 -0
  21. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/stream/route.js +10 -0
  23. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads.body +1 -0
  25. package/.next/server/app/api/beads.meta +1 -0
  26. package/.next/server/app/api/config/route.js +8 -0
  27. package/.next/server/app/api/config/route.js.nft.json +1 -0
  28. package/.next/server/app/api/config.body +1 -0
  29. package/.next/server/app/api/config.meta +1 -0
  30. package/.next/server/app/api/login/route.js +1 -0
  31. package/.next/server/app/api/login/route.js.nft.json +1 -0
  32. package/.next/server/app/api/logout/route.js +1 -0
  33. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  34. package/.next/server/app/api/oauth/callback/route.js +1 -0
  35. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  36. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  37. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  38. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  39. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  40. package/.next/server/app/api/records/route.js +1 -0
  41. package/.next/server/app/api/records/route.js.nft.json +1 -0
  42. package/.next/server/app/api/status/route.js +1 -0
  43. package/.next/server/app/api/status/route.js.nft.json +1 -0
  44. package/.next/server/app/index.html +1 -0
  45. package/.next/server/app/index.meta +5 -0
  46. package/.next/server/app/index.rsc +8 -0
  47. package/.next/server/app/page.js +24 -0
  48. package/.next/server/app/page.js.nft.json +1 -0
  49. package/.next/server/app/page_client-reference-manifest.js +1 -0
  50. package/.next/server/app-paths-manifest.json +14 -0
  51. package/.next/server/chunks/247.js +12 -0
  52. package/.next/server/chunks/251.js +2 -0
  53. package/.next/server/chunks/29.js +1 -0
  54. package/.next/server/chunks/343.js +1 -0
  55. package/.next/server/chunks/533.js +38 -0
  56. package/.next/server/chunks/590.js +6 -0
  57. package/.next/server/chunks/615.js +15 -0
  58. package/.next/server/chunks/696.js +25 -0
  59. package/.next/server/chunks/719.js +2 -0
  60. package/.next/server/chunks/739.js +1 -0
  61. package/.next/server/chunks/font-manifest.json +1 -0
  62. package/.next/server/font-manifest.json +1 -0
  63. package/.next/server/functions-config-manifest.json +1 -0
  64. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  65. package/.next/server/middleware-build-manifest.js +1 -0
  66. package/.next/server/middleware-manifest.json +6 -0
  67. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  68. package/.next/server/next-font-manifest.js +1 -0
  69. package/.next/server/next-font-manifest.json +1 -0
  70. package/.next/server/pages/404.html +1 -0
  71. package/.next/server/pages/500.html +1 -0
  72. package/.next/server/pages/_app.js +1 -0
  73. package/.next/server/pages/_app.js.nft.json +1 -0
  74. package/.next/server/pages/_document.js +1 -0
  75. package/.next/server/pages/_document.js.nft.json +1 -0
  76. package/.next/server/pages/_error.js +1 -0
  77. package/.next/server/pages/_error.js.nft.json +1 -0
  78. package/.next/server/pages-manifest.json +1 -0
  79. package/.next/server/server-reference-manifest.js +1 -0
  80. package/.next/server/server-reference-manifest.json +1 -0
  81. package/.next/server/webpack-runtime.js +1 -0
  82. package/.next/static/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
  83. package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.js +1 -0
  84. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  85. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  86. package/.next/static/chunks/666-fb778298a77f3754.js +1 -0
  87. package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
  88. package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
  89. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  90. package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
  91. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  92. package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
  93. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  94. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  95. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  96. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  97. package/.next/static/chunks/webpack-c8b9ebfd35ae1d92.js +1 -0
  98. package/.next/static/css/10ef08b24212fe36.css +3 -0
  99. package/README.md +243 -0
  100. package/app/api/beads/route.ts +27 -0
  101. package/app/api/beads/stream/route.ts +83 -0
  102. package/app/api/config/route.ts +46 -0
  103. package/app/api/login/route.ts +42 -0
  104. package/app/api/logout/route.ts +14 -0
  105. package/app/api/oauth/callback/route.ts +94 -0
  106. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  107. package/app/api/oauth/jwks.json/route.ts +32 -0
  108. package/app/api/records/route.ts +168 -0
  109. package/app/api/status/route.ts +25 -0
  110. package/app/globals.css +192 -0
  111. package/app/layout.tsx +30 -0
  112. package/app/page.tsx +1151 -0
  113. package/bin/beads-map.mjs +175 -0
  114. package/components/AllCommentsPanel.tsx +265 -0
  115. package/components/AuthButton.tsx +197 -0
  116. package/components/BeadsGraph.tsx +1539 -0
  117. package/components/CommentTooltip.tsx +310 -0
  118. package/components/GraphStats.tsx +121 -0
  119. package/components/HeartIcon.tsx +33 -0
  120. package/components/NodeDetail.tsx +741 -0
  121. package/components/StatusLegend.tsx +99 -0
  122. package/components/TimelineBar.tsx +116 -0
  123. package/hooks/useBeadsComments.ts +412 -0
  124. package/lib/agent.ts +29 -0
  125. package/lib/auth/client.ts +221 -0
  126. package/lib/auth.tsx +159 -0
  127. package/lib/diff-beads.ts +125 -0
  128. package/lib/discover.ts +228 -0
  129. package/lib/env.ts +28 -0
  130. package/lib/parse-beads.ts +232 -0
  131. package/lib/session.ts +52 -0
  132. package/lib/timeline.ts +138 -0
  133. package/lib/types.ts +202 -0
  134. package/lib/utils.ts +25 -0
  135. package/lib/watch-beads.ts +97 -0
  136. package/next.config.mjs +4 -0
  137. package/package.json +75 -0
  138. package/postcss.config.mjs +9 -0
  139. package/public/image.png +0 -0
  140. package/scripts/generate-jwk.js +38 -0
  141. package/tailwind.config.ts +41 -0
  142. package/tsconfig.json +24 -0
@@ -0,0 +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"}]}],"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"}],"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"]}],"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"}]},"stats":{"total":49,"open":0,"inProgress":0,"blocked":0,"closed":49,"actionable":0,"edges":81,"prefixes":["beads-map"]}}