beads-map 0.3.4 → 0.3.6

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-40b0a256ec8af9de.js +1 -0
  22. package/.next/static/css/454d96c40653b263.css +3 -0
  23. package/app/page.tsx +23 -0
  24. package/components/DescriptionModal.tsx +507 -11
  25. package/components/HelpPanel.tsx +4 -1
  26. package/components/NodeDetail.tsx +3 -1
  27. package/components/SettingsModal.tsx +237 -0
  28. package/components/TutorialOverlay.tsx +3 -3
  29. package/lib/settings.ts +42 -0
  30. package/lib/tts.ts +397 -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 → msGLXabX0J07WQq7roVpb}/_buildManifest.js +0 -0
  35. /package/.next/static/{JmL0suxsggbSwPxWcmUFV → msGLXabX0J07WQq7roVpb}/_ssgManifest.js +0 -0
@@ -1,29 +1,211 @@
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
+ speakSelection,
12
+ stopTts,
13
+ pauseTts,
14
+ resumeTts,
15
+ stripMarkdown,
16
+ setTtsPlaybackRate,
17
+ type TtsState,
18
+ } from "@/lib/tts";
19
+ import { hasApiKey } from "@/lib/settings";
20
+
21
+ const SPEED_PRESETS = [
22
+ { label: "Normal", value: 1 },
23
+ { label: "1.25x", value: 1.25 },
24
+ { label: "1.5x", value: 1.5 },
25
+ { label: "1.75x", value: 1.75 },
26
+ { label: "2x", value: 2 },
27
+ ];
9
28
 
10
29
  interface DescriptionModalProps {
11
30
  node: GraphNode;
12
31
  onClose: () => void;
13
32
  repoUrl?: string;
33
+ onOpenSettings?: () => void;
14
34
  }
15
35
 
16
- export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalProps) {
36
+ export function DescriptionModal({
37
+ node,
38
+ onClose,
39
+ repoUrl,
40
+ onOpenSettings,
41
+ }: DescriptionModalProps) {
17
42
  const [copied, setCopied] = useState(false);
43
+ const [ttsState, setTtsState] = useState<TtsState>("idle");
44
+ const [ttsError, setTtsError] = useState<string | null>(null);
45
+ const [ttsSpeed, setTtsSpeedState] = useState(1);
46
+ const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
47
+ const [customSpeedInput, setCustomSpeedInput] = useState("");
48
+ const [showCustomInput, setShowCustomInput] = useState(false);
49
+ const speedBtnRef = useRef<HTMLButtonElement>(null);
50
+ const speedMenuRef = useRef<HTMLDivElement>(null);
51
+
52
+ // Selection tooltip state
53
+ const [selectionTooltip, setSelectionTooltip] = useState<{
54
+ text: string;
55
+ x: number;
56
+ y: number;
57
+ } | null>(null);
58
+ const selectionTooltipRef = useRef<HTMLDivElement>(null);
59
+ // Guard: when true, selectionchange listener won't clear the tooltip
60
+ // (prevents race where clicking the tooltip clears browser selection before handler runs)
61
+ const ttsStartingRef = useRef(false);
18
62
 
19
63
  const handleCopy = () => {
20
64
  if (!node.description) return;
21
- navigator.clipboard.writeText(buildDescriptionCopyText(node, repoUrl)).then(() => {
22
- setCopied(true);
23
- setTimeout(() => setCopied(false), 1500);
24
- });
65
+ navigator.clipboard
66
+ .writeText(buildDescriptionCopyText(node, repoUrl))
67
+ .then(() => {
68
+ setCopied(true);
69
+ setTimeout(() => setCopied(false), 1500);
70
+ });
25
71
  };
26
72
 
73
+ // --- TTS handlers -------------------------------------------------------
74
+
75
+ const ttsStateChange = useCallback((state: TtsState, error?: string) => {
76
+ setTtsState(state);
77
+ if (error) setTtsError(error);
78
+ }, []);
79
+
80
+ const handleTts = useCallback(() => {
81
+ if (!hasApiKey()) {
82
+ onOpenSettings?.();
83
+ return;
84
+ }
85
+ const plainText = stripMarkdown(node.description || "");
86
+ if (!plainText) return;
87
+ setTtsError(null);
88
+ speakWithElevenLabs(plainText, ttsStateChange);
89
+ }, [node.description, onOpenSettings, ttsStateChange]);
90
+
91
+ const handleStopTts = useCallback(() => {
92
+ stopTts();
93
+ setTtsState("idle");
94
+ setSpeedMenuOpen(false);
95
+ setShowCustomInput(false);
96
+ }, []);
97
+
98
+ const handlePauseTts = useCallback(() => {
99
+ pauseTts();
100
+ setTtsState("paused");
101
+ }, []);
102
+
103
+ const handleResumeTts = useCallback(() => {
104
+ resumeTts();
105
+ setTtsState("playing");
106
+ }, []);
107
+
108
+ const handleSpeedChange = useCallback((speed: number) => {
109
+ const clamped = Math.max(0.25, Math.min(4, speed));
110
+ setTtsSpeedState(clamped);
111
+ setTtsPlaybackRate(clamped);
112
+ setSpeedMenuOpen(false);
113
+ setShowCustomInput(false);
114
+ }, []);
115
+
116
+ // Apply speed when playback starts (carries over from previous session)
117
+ useEffect(() => {
118
+ if (ttsState === "playing") {
119
+ setTtsPlaybackRate(ttsSpeed);
120
+ }
121
+ }, [ttsState, ttsSpeed]);
122
+
123
+ // Click-outside handler for speed menu
124
+ useEffect(() => {
125
+ if (!speedMenuOpen) return;
126
+ const handler = (e: MouseEvent) => {
127
+ if (
128
+ speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node) &&
129
+ speedBtnRef.current && !speedBtnRef.current.contains(e.target as Node)
130
+ ) {
131
+ setSpeedMenuOpen(false);
132
+ setShowCustomInput(false);
133
+ }
134
+ };
135
+ const timer = setTimeout(() => window.addEventListener("mousedown", handler), 50);
136
+ return () => {
137
+ clearTimeout(timer);
138
+ window.removeEventListener("mousedown", handler);
139
+ };
140
+ }, [speedMenuOpen]);
141
+
142
+ // --- Selection tooltip handlers -----------------------------------------
143
+
144
+ const handleSelectionMouseUp = useCallback(() => {
145
+ // Small delay to let the browser finalize the selection
146
+ setTimeout(() => {
147
+ const sel = window.getSelection();
148
+ if (!sel || sel.isCollapsed) return;
149
+ const selectedText = sel.toString().trim();
150
+ if (selectedText.length < 3) return;
151
+ try {
152
+ const range = sel.getRangeAt(0);
153
+ const rect = range.getBoundingClientRect();
154
+ setSelectionTooltip({
155
+ text: selectedText,
156
+ x: rect.left + rect.width / 2,
157
+ y: rect.top - 8,
158
+ });
159
+ } catch {
160
+ // getRangeAt can throw if selection is weird
161
+ }
162
+ }, 10);
163
+ }, []);
164
+
165
+ // Clear tooltip when selection is lost (guarded during TTS initiation)
166
+ useEffect(() => {
167
+ const handler = () => {
168
+ if (ttsStartingRef.current) return;
169
+ const sel = window.getSelection();
170
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) {
171
+ setSelectionTooltip(null);
172
+ }
173
+ };
174
+ document.addEventListener("selectionchange", handler);
175
+ return () => document.removeEventListener("selectionchange", handler);
176
+ }, []);
177
+
178
+ const handleSelectionTts = useCallback(() => {
179
+ if (!selectionTooltip) return;
180
+ const text = selectionTooltip.text;
181
+ if (!text.trim()) return;
182
+
183
+ if (!hasApiKey()) {
184
+ onOpenSettings?.();
185
+ setSelectionTooltip(null);
186
+ return;
187
+ }
188
+
189
+ ttsStartingRef.current = true; // Guard against selectionchange race
190
+ setTtsError(null);
191
+ setSelectionTooltip(null);
192
+
193
+ // speakSelection() checks cache first — zero API call if full text was played
194
+ speakSelection(text, ttsStateChange);
195
+
196
+ // Release guard after a tick (React state updates are batched)
197
+ setTimeout(() => { ttsStartingRef.current = false; }, 100);
198
+ }, [selectionTooltip, onOpenSettings, ttsStateChange]);
199
+
200
+ // Stop TTS on unmount / modal close
201
+ useEffect(() => {
202
+ return () => {
203
+ stopTts();
204
+ setSpeedMenuOpen(false);
205
+ setSelectionTooltip(null);
206
+ };
207
+ }, []);
208
+
27
209
  if (!node.description) return null;
28
210
 
29
211
  return createPortal(
@@ -46,21 +228,270 @@ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalPro
46
228
  </span>
47
229
  </div>
48
230
  <div className="flex items-center gap-1 shrink-0">
231
+ {/* Copy button */}
49
232
  <button
50
233
  onClick={handleCopy}
51
234
  className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
52
235
  title="Copy description"
53
236
  >
54
237
  {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" />
238
+ <svg
239
+ className="w-4 h-4 text-emerald-500"
240
+ fill="none"
241
+ stroke="currentColor"
242
+ viewBox="0 0 24 24"
243
+ strokeWidth={2}
244
+ >
245
+ <path
246
+ strokeLinecap="round"
247
+ strokeLinejoin="round"
248
+ d="M4.5 12.75l6 6 9-13.5"
249
+ />
57
250
  </svg>
58
251
  ) : (
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" />
252
+ <svg
253
+ className="w-4 h-4"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ viewBox="0 0 24 24"
257
+ strokeWidth={2}
258
+ >
259
+ <path
260
+ strokeLinecap="round"
261
+ strokeLinejoin="round"
262
+ 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"
263
+ />
61
264
  </svg>
62
265
  )}
63
266
  </button>
267
+
268
+ {/* TTS buttons — 4 states: idle, loading, playing, paused */}
269
+ {ttsState === "loading" ? (
270
+ <button
271
+ disabled
272
+ className="p-1 text-zinc-300 cursor-wait"
273
+ title="Loading audio..."
274
+ >
275
+ <svg
276
+ className="w-4 h-4 animate-spin"
277
+ fill="none"
278
+ viewBox="0 0 24 24"
279
+ >
280
+ <circle
281
+ className="opacity-25"
282
+ cx="12"
283
+ cy="12"
284
+ r="10"
285
+ stroke="currentColor"
286
+ strokeWidth={4}
287
+ />
288
+ <path
289
+ className="opacity-75"
290
+ fill="currentColor"
291
+ 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"
292
+ />
293
+ </svg>
294
+ </button>
295
+ ) : ttsState === "playing" ? (
296
+ <>
297
+ {/* Pause button */}
298
+ <button
299
+ onClick={handlePauseTts}
300
+ className="p-1 text-emerald-500 hover:text-amber-500 hover:bg-amber-50 rounded transition-colors"
301
+ title="Pause"
302
+ >
303
+ <svg
304
+ className="w-4 h-4"
305
+ fill="none"
306
+ viewBox="0 0 24 24"
307
+ strokeWidth={1.5}
308
+ stroke="currentColor"
309
+ >
310
+ <path
311
+ strokeLinecap="round"
312
+ strokeLinejoin="round"
313
+ d="M15.75 5.25v13.5m-7.5-13.5v13.5"
314
+ />
315
+ </svg>
316
+ </button>
317
+ {/* Stop button */}
318
+ <button
319
+ onClick={handleStopTts}
320
+ className="p-1 text-zinc-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
321
+ title="Stop"
322
+ >
323
+ <svg
324
+ className="w-4 h-4"
325
+ fill="none"
326
+ viewBox="0 0 24 24"
327
+ strokeWidth={1.5}
328
+ stroke="currentColor"
329
+ >
330
+ <path
331
+ strokeLinecap="round"
332
+ strokeLinejoin="round"
333
+ 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"
334
+ />
335
+ </svg>
336
+ </button>
337
+ </>
338
+ ) : ttsState === "paused" ? (
339
+ <>
340
+ {/* Resume button */}
341
+ <button
342
+ onClick={handleResumeTts}
343
+ className="p-1 text-amber-500 hover:text-emerald-500 hover:bg-emerald-50 rounded transition-colors"
344
+ title="Resume"
345
+ >
346
+ <svg
347
+ className="w-4 h-4"
348
+ fill="none"
349
+ viewBox="0 0 24 24"
350
+ strokeWidth={1.5}
351
+ stroke="currentColor"
352
+ >
353
+ <path
354
+ strokeLinecap="round"
355
+ strokeLinejoin="round"
356
+ d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
357
+ />
358
+ </svg>
359
+ </button>
360
+ {/* Stop button */}
361
+ <button
362
+ onClick={handleStopTts}
363
+ className="p-1 text-zinc-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
364
+ title="Stop"
365
+ >
366
+ <svg
367
+ className="w-4 h-4"
368
+ fill="none"
369
+ viewBox="0 0 24 24"
370
+ strokeWidth={1.5}
371
+ stroke="currentColor"
372
+ >
373
+ <path
374
+ strokeLinecap="round"
375
+ strokeLinejoin="round"
376
+ 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"
377
+ />
378
+ </svg>
379
+ </button>
380
+ </>
381
+ ) : (
382
+ /* Idle — play/speaker button */
383
+ <button
384
+ onClick={handleTts}
385
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
386
+ title="Read aloud"
387
+ >
388
+ <svg
389
+ className="w-4 h-4"
390
+ fill="none"
391
+ viewBox="0 0 24 24"
392
+ strokeWidth={1.5}
393
+ stroke="currentColor"
394
+ >
395
+ <path
396
+ strokeLinecap="round"
397
+ strokeLinejoin="round"
398
+ 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"
399
+ />
400
+ </svg>
401
+ </button>
402
+ )}
403
+
404
+ {/* Speed selector — visible during playback/loading/paused */}
405
+ {(ttsState === "playing" || ttsState === "loading" || ttsState === "paused") && (
406
+ <div className="relative">
407
+ <button
408
+ ref={speedBtnRef}
409
+ onClick={() => setSpeedMenuOpen((v) => !v)}
410
+ 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"
411
+ title="Playback speed"
412
+ >
413
+ {ttsSpeed === 1 ? "1x" : `${ttsSpeed}x`}
414
+ </button>
415
+
416
+ {speedMenuOpen && (
417
+ <div
418
+ ref={speedMenuRef}
419
+ className="absolute right-0 top-full mt-1 bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden z-[110]"
420
+ style={{ minWidth: 150 }}
421
+ onClick={(e) => e.stopPropagation()}
422
+ >
423
+ {SPEED_PRESETS.map((preset) => (
424
+ <button
425
+ key={preset.value}
426
+ onClick={() => handleSpeedChange(preset.value)}
427
+ className={`w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-zinc-50 transition-colors ${
428
+ ttsSpeed === preset.value ? "text-emerald-600 font-medium" : "text-zinc-700"
429
+ }`}
430
+ >
431
+ <span>{preset.label}</span>
432
+ {ttsSpeed === preset.value && (
433
+ <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
434
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
435
+ </svg>
436
+ )}
437
+ </button>
438
+ ))}
439
+
440
+ <div className="border-t border-zinc-100" />
441
+
442
+ {!showCustomInput ? (
443
+ <button
444
+ onClick={() => {
445
+ setShowCustomInput(true);
446
+ setCustomSpeedInput(String(ttsSpeed));
447
+ }}
448
+ className={`w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-zinc-50 transition-colors ${
449
+ !SPEED_PRESETS.some((p) => p.value === ttsSpeed) ? "text-emerald-600 font-medium" : "text-zinc-700"
450
+ }`}
451
+ >
452
+ <span>Custom{!SPEED_PRESETS.some((p) => p.value === ttsSpeed) ? ` (${ttsSpeed}x)` : "..."}</span>
453
+ {!SPEED_PRESETS.some((p) => p.value === ttsSpeed) && (
454
+ <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
455
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
456
+ </svg>
457
+ )}
458
+ </button>
459
+ ) : (
460
+ <div className="px-3 py-2 flex items-center gap-2">
461
+ <input
462
+ type="number"
463
+ min={0.25}
464
+ max={4}
465
+ step={0.25}
466
+ value={customSpeedInput}
467
+ onChange={(e) => setCustomSpeedInput(e.target.value)}
468
+ onKeyDown={(e) => {
469
+ if (e.key === "Enter") {
470
+ const val = parseFloat(customSpeedInput);
471
+ if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val);
472
+ }
473
+ if (e.key === "Escape") {
474
+ setShowCustomInput(false);
475
+ setSpeedMenuOpen(false);
476
+ }
477
+ }}
478
+ onBlur={() => {
479
+ const val = parseFloat(customSpeedInput);
480
+ if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val);
481
+ else setShowCustomInput(false);
482
+ }}
483
+ autoFocus
484
+ 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"
485
+ />
486
+ <span className="text-[11px] text-zinc-400">x</span>
487
+ </div>
488
+ )}
489
+ </div>
490
+ )}
491
+ </div>
492
+ )}
493
+
494
+ {/* Close button */}
64
495
  <button
65
496
  onClick={onClose}
66
497
  className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
@@ -81,13 +512,78 @@ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalPro
81
512
  </button>
82
513
  </div>
83
514
  </div>
515
+
516
+ {/* TTS error banner */}
517
+ {ttsError && (
518
+ <div className="px-5 py-2 text-xs text-red-500 bg-red-50 border-b border-red-100 flex items-center justify-between">
519
+ <span>{ttsError}</span>
520
+ <button
521
+ onClick={() => setTtsError(null)}
522
+ className="text-red-400 hover:text-red-600 ml-2"
523
+ >
524
+ <svg
525
+ className="w-3 h-3"
526
+ fill="none"
527
+ viewBox="0 0 24 24"
528
+ strokeWidth={2}
529
+ stroke="currentColor"
530
+ >
531
+ <path
532
+ strokeLinecap="round"
533
+ strokeLinejoin="round"
534
+ d="M6 18L18 6M6 6l12 12"
535
+ />
536
+ </svg>
537
+ </button>
538
+ </div>
539
+ )}
540
+
84
541
  {/* Modal body */}
85
- <div className="flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed">
542
+ <div
543
+ className="flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed"
544
+ onMouseUp={handleSelectionMouseUp}
545
+ >
86
546
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
87
547
  {node.description}
88
548
  </ReactMarkdown>
89
549
  </div>
90
550
  </div>
551
+
552
+ {/* Selection TTS tooltip — outside modal card, inside portal backdrop */}
553
+ {selectionTooltip && (
554
+ <div
555
+ ref={selectionTooltipRef}
556
+ className="fixed z-[110] flex items-center gap-1.5 px-2.5 py-1.5 bg-zinc-800 text-white text-xs rounded-lg shadow-lg select-none"
557
+ style={{
558
+ left: selectionTooltip.x,
559
+ top: selectionTooltip.y,
560
+ transform: "translate(-50%, -100%)",
561
+ pointerEvents: "auto",
562
+ }}
563
+ onMouseDown={(e) => e.stopPropagation()}
564
+ onClick={(e) => e.stopPropagation()}
565
+ >
566
+ <button
567
+ onClick={handleSelectionTts}
568
+ className="flex items-center gap-1 hover:text-emerald-300 transition-colors"
569
+ >
570
+ <svg
571
+ className="w-3.5 h-3.5"
572
+ fill="none"
573
+ viewBox="0 0 24 24"
574
+ strokeWidth={1.5}
575
+ stroke="currentColor"
576
+ >
577
+ <path
578
+ strokeLinecap="round"
579
+ strokeLinejoin="round"
580
+ 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"
581
+ />
582
+ </svg>
583
+ <span>Read aloud</span>
584
+ </button>
585
+ </div>
586
+ )}
91
587
  </div>,
92
588
  document.body
93
589
  );
@@ -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) */}