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