beads-map 0.3.4 → 0.3.5

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 (35) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +2 -2
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +1 -1
  17. package/.next/server/functions-config-manifest.json +1 -1
  18. package/.next/server/pages/404.html +1 -1
  19. package/.next/server/pages/500.html +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-7a8908706a09b720.js +1 -0
  22. package/.next/static/css/a506e3d172da58ef.css +3 -0
  23. package/app/page.tsx +23 -0
  24. package/components/DescriptionModal.tsx +313 -10
  25. package/components/HelpPanel.tsx +4 -1
  26. package/components/NodeDetail.tsx +3 -1
  27. package/components/SettingsModal.tsx +235 -0
  28. package/components/TutorialOverlay.tsx +3 -3
  29. package/lib/settings.ts +42 -0
  30. package/lib/tts.ts +137 -0
  31. package/package.json +1 -1
  32. package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +0 -1
  33. package/.next/static/css/ade5301262971664.css +0 -3
  34. /package/.next/static/{JmL0suxsggbSwPxWcmUFV → e6v54SLUeGDtx1DXW7JjL}/_buildManifest.js +0 -0
  35. /package/.next/static/{JmL0suxsggbSwPxWcmUFV → e6v54SLUeGDtx1DXW7JjL}/_ssgManifest.js +0 -0
package/app/page.tsx CHANGED
@@ -19,6 +19,7 @@ import AllCommentsPanel from "@/components/AllCommentsPanel";
19
19
  import { ActivityOverlay } from "@/components/ActivityOverlay";
20
20
  import { ActivityPanel } from "@/components/ActivityPanel";
21
21
  import { HelpPanel } from "@/components/HelpPanel";
22
+ import { SettingsModal } from "@/components/SettingsModal";
22
23
  import { TutorialOverlay, TUTORIAL_STEPS } from "@/components/TutorialOverlay";
23
24
  import { useBeadsComments } from "@/hooks/useBeadsComments";
24
25
  import type { BeadsComment } from "@/hooks/useBeadsComments";
@@ -294,6 +295,9 @@ export default function Home() {
294
295
  const [descriptionModalNode, setDescriptionModalNode] =
295
296
  useState<GraphNode | null>(null);
296
297
 
298
+ // Settings modal state
299
+ const [settingsModalOpen, setSettingsModalOpen] = useState(false);
300
+
297
301
  // Avatar hover tooltip state
298
302
  const [avatarTooltip, setAvatarTooltip] = useState<{
299
303
  handle: string;
@@ -1306,6 +1310,16 @@ export default function Home() {
1306
1310
  <span className="hidden sm:inline">Learn</span>
1307
1311
  </button>
1308
1312
  <div className="w-px h-5 bg-zinc-200 mx-2" />
1313
+ <button
1314
+ onClick={() => setSettingsModalOpen(true)}
1315
+ className="p-2 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50 rounded-full transition-colors"
1316
+ title="Settings"
1317
+ >
1318
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1319
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
1320
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
1321
+ </svg>
1322
+ </button>
1309
1323
  <AuthButton />
1310
1324
  </div>
1311
1325
  </div>
@@ -1497,9 +1511,16 @@ export default function Home() {
1497
1511
  node={descriptionModalNode}
1498
1512
  onClose={() => setDescriptionModalNode(null)}
1499
1513
  repoUrl={repoUrls[descriptionModalNode.prefix]}
1514
+ onOpenSettings={() => setSettingsModalOpen(true)}
1500
1515
  />
1501
1516
  )}
1502
1517
 
1518
+ {/* Settings modal */}
1519
+ <SettingsModal
1520
+ isOpen={settingsModalOpen}
1521
+ onClose={() => setSettingsModalOpen(false)}
1522
+ />
1523
+
1503
1524
  {/* Node hover tooltip */}
1504
1525
  {nodeTooltip && !avatarTooltip && (
1505
1526
  <BeadTooltip
@@ -1618,6 +1639,7 @@ export default function Home() {
1618
1639
  isAuthenticated={isAuthenticated}
1619
1640
  currentDid={session?.did}
1620
1641
  repoUrls={repoUrls}
1642
+ onOpenSettings={() => setSettingsModalOpen(true)}
1621
1643
  />
1622
1644
 
1623
1645
  </div>
@@ -1676,6 +1698,7 @@ export default function Home() {
1676
1698
  isAuthenticated={isAuthenticated}
1677
1699
  currentDid={session?.did}
1678
1700
  repoUrls={repoUrls}
1701
+ onOpenSettings={() => setSettingsModalOpen(true)}
1679
1702
  />
1680
1703
  </div>
1681
1704
  </div>
@@ -1,29 +1,124 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import ReactMarkdown from "react-markdown";
6
6
  import remarkGfm from "remark-gfm";
7
7
  import type { GraphNode } from "@/lib/types";
8
8
  import { buildDescriptionCopyText } from "@/lib/utils";
9
+ import {
10
+ speakWithElevenLabs,
11
+ stopTts,
12
+ stripMarkdown,
13
+ setTtsPlaybackRate,
14
+ type TtsState,
15
+ } from "@/lib/tts";
16
+ import { hasApiKey } from "@/lib/settings";
17
+
18
+ const SPEED_PRESETS = [
19
+ { label: "Normal", value: 1 },
20
+ { label: "1.25x", value: 1.25 },
21
+ { label: "1.5x", value: 1.5 },
22
+ { label: "1.75x", value: 1.75 },
23
+ { label: "2x", value: 2 },
24
+ ];
9
25
 
10
26
  interface DescriptionModalProps {
11
27
  node: GraphNode;
12
28
  onClose: () => void;
13
29
  repoUrl?: string;
30
+ onOpenSettings?: () => void;
14
31
  }
15
32
 
16
- export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalProps) {
33
+ export function DescriptionModal({
34
+ node,
35
+ onClose,
36
+ repoUrl,
37
+ onOpenSettings,
38
+ }: DescriptionModalProps) {
17
39
  const [copied, setCopied] = useState(false);
40
+ const [ttsState, setTtsState] = useState<TtsState>("idle");
41
+ const [ttsError, setTtsError] = useState<string | null>(null);
42
+ const [ttsSpeed, setTtsSpeedState] = useState(1);
43
+ const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
44
+ const [customSpeedInput, setCustomSpeedInput] = useState("");
45
+ const [showCustomInput, setShowCustomInput] = useState(false);
46
+ const speedBtnRef = useRef<HTMLButtonElement>(null);
47
+ const speedMenuRef = useRef<HTMLDivElement>(null);
18
48
 
19
49
  const handleCopy = () => {
20
50
  if (!node.description) return;
21
- navigator.clipboard.writeText(buildDescriptionCopyText(node, repoUrl)).then(() => {
22
- setCopied(true);
23
- setTimeout(() => setCopied(false), 1500);
24
- });
51
+ navigator.clipboard
52
+ .writeText(buildDescriptionCopyText(node, repoUrl))
53
+ .then(() => {
54
+ setCopied(true);
55
+ setTimeout(() => setCopied(false), 1500);
56
+ });
25
57
  };
26
58
 
59
+ const handleTts = useCallback(() => {
60
+ if (!hasApiKey()) {
61
+ onOpenSettings?.();
62
+ return;
63
+ }
64
+ const plainText = stripMarkdown(node.description || "");
65
+ if (!plainText) return;
66
+ setTtsError(null);
67
+ speakWithElevenLabs(plainText, (state, error) => {
68
+ setTtsState(state);
69
+ if (error) setTtsError(error);
70
+ });
71
+ }, [node.description, onOpenSettings]);
72
+
73
+ const handleStopTts = useCallback(() => {
74
+ stopTts();
75
+ setTtsState("idle");
76
+ setSpeedMenuOpen(false);
77
+ setShowCustomInput(false);
78
+ }, []);
79
+
80
+ const handleSpeedChange = useCallback((speed: number) => {
81
+ const clamped = Math.max(0.25, Math.min(4, speed));
82
+ setTtsSpeedState(clamped);
83
+ setTtsPlaybackRate(clamped);
84
+ setSpeedMenuOpen(false);
85
+ setShowCustomInput(false);
86
+ }, []);
87
+
88
+ // Apply speed when playback starts (carries over from previous session)
89
+ useEffect(() => {
90
+ if (ttsState === "playing") {
91
+ setTtsPlaybackRate(ttsSpeed);
92
+ }
93
+ }, [ttsState, ttsSpeed]);
94
+
95
+ // Click-outside handler for speed menu
96
+ useEffect(() => {
97
+ if (!speedMenuOpen) return;
98
+ const handler = (e: MouseEvent) => {
99
+ if (
100
+ speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node) &&
101
+ speedBtnRef.current && !speedBtnRef.current.contains(e.target as Node)
102
+ ) {
103
+ setSpeedMenuOpen(false);
104
+ setShowCustomInput(false);
105
+ }
106
+ };
107
+ const timer = setTimeout(() => window.addEventListener("mousedown", handler), 50);
108
+ return () => {
109
+ clearTimeout(timer);
110
+ window.removeEventListener("mousedown", handler);
111
+ };
112
+ }, [speedMenuOpen]);
113
+
114
+ // Stop TTS on unmount / modal close
115
+ useEffect(() => {
116
+ return () => {
117
+ stopTts();
118
+ setSpeedMenuOpen(false);
119
+ };
120
+ }, []);
121
+
27
122
  if (!node.description) return null;
28
123
 
29
124
  return createPortal(
@@ -46,21 +141,203 @@ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalPro
46
141
  </span>
47
142
  </div>
48
143
  <div className="flex items-center gap-1 shrink-0">
144
+ {/* Copy button */}
49
145
  <button
50
146
  onClick={handleCopy}
51
147
  className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
52
148
  title="Copy description"
53
149
  >
54
150
  {copied ? (
55
- <svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
56
- <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
151
+ <svg
152
+ className="w-4 h-4 text-emerald-500"
153
+ fill="none"
154
+ stroke="currentColor"
155
+ viewBox="0 0 24 24"
156
+ strokeWidth={2}
157
+ >
158
+ <path
159
+ strokeLinecap="round"
160
+ strokeLinejoin="round"
161
+ d="M4.5 12.75l6 6 9-13.5"
162
+ />
57
163
  </svg>
58
164
  ) : (
59
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
60
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
165
+ <svg
166
+ className="w-4 h-4"
167
+ fill="none"
168
+ stroke="currentColor"
169
+ viewBox="0 0 24 24"
170
+ strokeWidth={2}
171
+ >
172
+ <path
173
+ strokeLinecap="round"
174
+ strokeLinejoin="round"
175
+ d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
176
+ />
61
177
  </svg>
62
178
  )}
63
179
  </button>
180
+
181
+ {/* TTS button */}
182
+ {ttsState === "loading" ? (
183
+ <button
184
+ disabled
185
+ className="p-1 text-zinc-300 cursor-wait"
186
+ title="Loading audio..."
187
+ >
188
+ <svg
189
+ className="w-4 h-4 animate-spin"
190
+ fill="none"
191
+ viewBox="0 0 24 24"
192
+ >
193
+ <circle
194
+ className="opacity-25"
195
+ cx="12"
196
+ cy="12"
197
+ r="10"
198
+ stroke="currentColor"
199
+ strokeWidth={4}
200
+ />
201
+ <path
202
+ className="opacity-75"
203
+ fill="currentColor"
204
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
205
+ />
206
+ </svg>
207
+ </button>
208
+ ) : ttsState === "playing" ? (
209
+ <button
210
+ onClick={handleStopTts}
211
+ className="p-1 text-emerald-500 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
212
+ title="Stop reading"
213
+ >
214
+ <svg
215
+ className="w-4 h-4"
216
+ fill="none"
217
+ viewBox="0 0 24 24"
218
+ strokeWidth={1.5}
219
+ stroke="currentColor"
220
+ >
221
+ <path
222
+ strokeLinecap="round"
223
+ strokeLinejoin="round"
224
+ d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
225
+ />
226
+ </svg>
227
+ </button>
228
+ ) : (
229
+ <button
230
+ onClick={handleTts}
231
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
232
+ title="Read aloud"
233
+ >
234
+ <svg
235
+ className="w-4 h-4"
236
+ fill="none"
237
+ viewBox="0 0 24 24"
238
+ strokeWidth={1.5}
239
+ stroke="currentColor"
240
+ >
241
+ <path
242
+ strokeLinecap="round"
243
+ strokeLinejoin="round"
244
+ d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
245
+ />
246
+ </svg>
247
+ </button>
248
+ )}
249
+
250
+ {/* Speed selector — visible during playback/loading */}
251
+ {(ttsState === "playing" || ttsState === "loading") && (
252
+ <div className="relative">
253
+ <button
254
+ ref={speedBtnRef}
255
+ onClick={() => setSpeedMenuOpen((v) => !v)}
256
+ className="px-1.5 py-0.5 text-[11px] font-mono font-medium rounded text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 transition-colors"
257
+ title="Playback speed"
258
+ >
259
+ {ttsSpeed === 1 ? "1x" : `${ttsSpeed}x`}
260
+ </button>
261
+
262
+ {speedMenuOpen && (
263
+ <div
264
+ ref={speedMenuRef}
265
+ className="absolute right-0 top-full mt-1 bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden z-[110]"
266
+ style={{ minWidth: 150 }}
267
+ onClick={(e) => e.stopPropagation()}
268
+ >
269
+ {SPEED_PRESETS.map((preset) => (
270
+ <button
271
+ key={preset.value}
272
+ onClick={() => handleSpeedChange(preset.value)}
273
+ className={`w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-zinc-50 transition-colors ${
274
+ ttsSpeed === preset.value ? "text-emerald-600 font-medium" : "text-zinc-700"
275
+ }`}
276
+ >
277
+ <span>{preset.label}</span>
278
+ {ttsSpeed === preset.value && (
279
+ <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
280
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
281
+ </svg>
282
+ )}
283
+ </button>
284
+ ))}
285
+
286
+ <div className="border-t border-zinc-100" />
287
+
288
+ {!showCustomInput ? (
289
+ <button
290
+ onClick={() => {
291
+ setShowCustomInput(true);
292
+ setCustomSpeedInput(String(ttsSpeed));
293
+ }}
294
+ className={`w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-zinc-50 transition-colors ${
295
+ !SPEED_PRESETS.some((p) => p.value === ttsSpeed) ? "text-emerald-600 font-medium" : "text-zinc-700"
296
+ }`}
297
+ >
298
+ <span>Custom{!SPEED_PRESETS.some((p) => p.value === ttsSpeed) ? ` (${ttsSpeed}x)` : "..."}</span>
299
+ {!SPEED_PRESETS.some((p) => p.value === ttsSpeed) && (
300
+ <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
301
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
302
+ </svg>
303
+ )}
304
+ </button>
305
+ ) : (
306
+ <div className="px-3 py-2 flex items-center gap-2">
307
+ <input
308
+ type="number"
309
+ min={0.25}
310
+ max={4}
311
+ step={0.25}
312
+ value={customSpeedInput}
313
+ onChange={(e) => setCustomSpeedInput(e.target.value)}
314
+ onKeyDown={(e) => {
315
+ if (e.key === "Enter") {
316
+ const val = parseFloat(customSpeedInput);
317
+ if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val);
318
+ }
319
+ if (e.key === "Escape") {
320
+ setShowCustomInput(false);
321
+ setSpeedMenuOpen(false);
322
+ }
323
+ }}
324
+ onBlur={() => {
325
+ const val = parseFloat(customSpeedInput);
326
+ if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val);
327
+ else setShowCustomInput(false);
328
+ }}
329
+ autoFocus
330
+ className="w-16 rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-xs text-zinc-900 focus:outline-none focus:ring-1 focus:ring-emerald-500"
331
+ />
332
+ <span className="text-[11px] text-zinc-400">x</span>
333
+ </div>
334
+ )}
335
+ </div>
336
+ )}
337
+ </div>
338
+ )}
339
+
340
+ {/* Close button */}
64
341
  <button
65
342
  onClick={onClose}
66
343
  className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
@@ -81,6 +358,32 @@ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalPro
81
358
  </button>
82
359
  </div>
83
360
  </div>
361
+
362
+ {/* TTS error banner */}
363
+ {ttsError && (
364
+ <div className="px-5 py-2 text-xs text-red-500 bg-red-50 border-b border-red-100 flex items-center justify-between">
365
+ <span>{ttsError}</span>
366
+ <button
367
+ onClick={() => setTtsError(null)}
368
+ className="text-red-400 hover:text-red-600 ml-2"
369
+ >
370
+ <svg
371
+ className="w-3 h-3"
372
+ fill="none"
373
+ viewBox="0 0 24 24"
374
+ strokeWidth={2}
375
+ stroke="currentColor"
376
+ >
377
+ <path
378
+ strokeLinecap="round"
379
+ strokeLinejoin="round"
380
+ d="M6 18L18 6M6 6l12 12"
381
+ />
382
+ </svg>
383
+ </button>
384
+ </div>
385
+ )}
386
+
84
387
  {/* Modal body */}
85
388
  <div className="flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed">
86
389
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
@@ -268,7 +268,7 @@ function HelpContent({ onStartTutorial }: { onStartTutorial?: () => void }) {
268
268
  <ul className="space-y-1.5 mb-3">
269
269
  <Bullet color={CAT.blue}><strong>Click</strong> a node to open its details</Bullet>
270
270
  <Bullet color={CAT.blue}><strong>Hover</strong> for a quick summary</Bullet>
271
- <Bullet color={CAT.blue}><strong>Right-click</strong> for actions &mdash; descriptions, comments, claims, collapse</Bullet>
271
+ <Bullet color={CAT.blue}><strong>Right-click</strong> for actions &mdash; descriptions, comments, claims, collapse, or focus on an epic</Bullet>
272
272
  <Bullet color={CAT.blue}><strong>Scroll</strong> to zoom, <strong>drag</strong> to pan</Bullet>
273
273
  <Bullet color={CAT.blue}><strong>Cmd/Ctrl+F</strong> to search by name, ID, owner, or commenter</Bullet>
274
274
  </ul>
@@ -300,12 +300,15 @@ function HelpContent({ onStartTutorial }: { onStartTutorial?: () => void }) {
300
300
  <SectionTitle color={CAT.mauve}>More</SectionTitle>
301
301
  <ul className="space-y-1.5 mb-3">
302
302
  <Bullet color={CAT.mauve}><strong>Collapse/Expand</strong> &mdash; tidy up epics into single nodes</Bullet>
303
+ <Bullet color={CAT.mauve}><strong>Focus on epic</strong> &mdash; right-click an epic to isolate its subgraph (children + dependencies). Click the banner or right-click again to return</Bullet>
303
304
  <Bullet color={CAT.mauve}><strong>Clusters</strong> &mdash; dashed circles grouping projects when zoomed out</Bullet>
304
305
  <Bullet color={CAT.mauve}><strong>Replay</strong> &mdash; step through your project&apos;s history</Bullet>
305
306
  <Bullet color={CAT.mauve}><strong>Comments</strong> &mdash; leave notes on tasks (sign in first)</Bullet>
307
+ <Bullet color={CAT.mauve}><strong>Activity feed</strong> &mdash; real-time feed filtered to only your local beads, not global</Bullet>
306
308
  <Bullet color={CAT.mauve}><strong>Claim tasks</strong> &mdash; right-click to mark as yours</Bullet>
307
309
  <Bullet color={CAT.mauve}><strong>Minimap</strong> &mdash; click to jump, drag edges to resize</Bullet>
308
310
  <Bullet color={CAT.mauve}><strong>Auto-fit</strong> &mdash; top-left toggle to lock/unlock automatic camera reframing</Bullet>
311
+ <Bullet color={CAT.mauve}><strong>Pulse</strong> &mdash; the most recently active node pulses with emerald ripples. Toggle it on/off in view controls</Bullet>
309
312
  <Bullet color={CAT.mauve}><strong>Copy</strong> &mdash; clipboard icon copies task with project info</Bullet>
310
313
  </ul>
311
314
 
@@ -30,6 +30,7 @@ interface NodeDetailProps {
30
30
  isAuthenticated?: boolean;
31
31
  currentDid?: string;
32
32
  repoUrls?: Record<string, string>;
33
+ onOpenSettings?: () => void;
33
34
  }
34
35
 
35
36
  export default function NodeDetail({
@@ -44,6 +45,7 @@ export default function NodeDetail({
44
45
  isAuthenticated,
45
46
  currentDid,
46
47
  repoUrls,
48
+ onOpenSettings,
47
49
  }: NodeDetailProps) {
48
50
  // Reply state — managed here so it's shared across the comment tree
49
51
  const [replyingToUri, setReplyingToUri] = useState<string | null>(null);
@@ -356,7 +358,7 @@ export default function NodeDetail({
356
358
 
357
359
  {/* Description expanded modal */}
358
360
  {descriptionExpanded && node.description && (
359
- <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} repoUrl={repoUrls?.[node.prefix]} />
361
+ <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} repoUrl={repoUrls?.[node.prefix]} onOpenSettings={onOpenSettings} />
360
362
  )}
361
363
 
362
364
  {/* Blocks (issues this blocks) */}